ground-control-cli 0.2.0__tar.gz → 0.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.
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/PKG-INFO +1 -1
- ground_control_cli-0.3.0/actions.py +271 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/claude.py +110 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/cli.py +125 -15
- ground_control_cli-0.3.0/hooks.py +457 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/machine.py +7 -1
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/__init__.py +9 -1
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/code/bash.py +5 -22
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/code/javascript.py +15 -18
- ground_control_cli-0.3.0/probes/deploy.py +261 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/git.py +90 -37
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/health.py +58 -2
- ground_control_cli-0.3.0/probes/request.py +262 -0
- ground_control_cli-0.3.0/probes/workspace.py +341 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/pyproject.toml +10 -2
- ground_control_cli-0.3.0/queue.py +328 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/service.py +152 -13
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/system/__init__.py +6 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/system/macos.py +9 -0
- ground_control_cli-0.3.0/tools.py +232 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/.gitignore +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/__init__.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/connect.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/code/__init__.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/code/javascript.js +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/code/python.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/errors.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/gh.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/settings.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/tests.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/repos.py +0 -0
- {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/update.py +0 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""The actions: what the method changes on the developer's machine, each with its fixed command.
|
|
2
|
+
|
|
3
|
+
The service names an action from the closed set (app/method/facts.py, ACTIONS) with typed values;
|
|
4
|
+
the client runs it here with its own command, so the service never sends a command line. Git and
|
|
5
|
+
gh run in the repo, as the developer, with their own access. An action that cannot be done says
|
|
6
|
+
which of the probes' errors stopped it, never what a command printed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .probes import errors, request, workspace
|
|
17
|
+
from .probes.git import main_checkout, worktree
|
|
18
|
+
|
|
19
|
+
MAIN = "main"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Failed(Exception):
|
|
23
|
+
"""A command failed; `error` is which of errors.ERRORS it stands for."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, error: str):
|
|
26
|
+
"""Keep the error."""
|
|
27
|
+
super().__init__(error)
|
|
28
|
+
self.error = error
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run(tool: str, root, *args, data: str | None = None) -> str:
|
|
32
|
+
"""Run git or gh in the folder; its output, or Failed with what its failure stands for."""
|
|
33
|
+
try:
|
|
34
|
+
out = subprocess.run([tool, *args], cwd=root, input=data, capture_output=True, text=True)
|
|
35
|
+
except FileNotFoundError:
|
|
36
|
+
raise Failed(errors.TOOL_MISSING) from None
|
|
37
|
+
if out.returncode != 0:
|
|
38
|
+
raise Failed(errors.failed(out.stderr))
|
|
39
|
+
return out.stdout.strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _git(root, *args) -> str:
|
|
43
|
+
return _run("git", root, *args)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _gh(root, *args, data: str | None = None) -> str:
|
|
47
|
+
return _run("gh", root, *args, data=data)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _done(work: Callable[[], dict | None]) -> dict:
|
|
51
|
+
try:
|
|
52
|
+
return work() or {}
|
|
53
|
+
except Failed as e:
|
|
54
|
+
return {"error": e.error}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def worktree_add(params: dict, root) -> dict:
|
|
58
|
+
"""git.worktree_add: the branch and its worktree, beside the repo in `<repo>.worktrees`."""
|
|
59
|
+
main = main_checkout(root)
|
|
60
|
+
branch, base = params["branch"], params.get("base") or MAIN
|
|
61
|
+
tree = main.parent / f"{main.name}.worktrees" / branch
|
|
62
|
+
|
|
63
|
+
def work():
|
|
64
|
+
if _git_ok(main, "rev-parse", "--verify", "-q", f"refs/heads/{branch}"):
|
|
65
|
+
raise Failed(errors.REFUSED)
|
|
66
|
+
_git(main, "worktree", "add", "-q", "-b", branch, str(tree), base)
|
|
67
|
+
return {"path": str(tree)}
|
|
68
|
+
|
|
69
|
+
return _done(work)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _git_ok(root, *args) -> bool:
|
|
73
|
+
try:
|
|
74
|
+
_git(root, *args)
|
|
75
|
+
except Failed:
|
|
76
|
+
return False
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def worktree_remove(params: dict, root) -> dict:
|
|
81
|
+
"""git.worktree_remove: the branch's worktree, when it has one; the main checkout's folder."""
|
|
82
|
+
main, branch = main_checkout(root), params["branch"]
|
|
83
|
+
|
|
84
|
+
def work():
|
|
85
|
+
tree = worktree(main, branch)
|
|
86
|
+
if tree is not None and tree != main:
|
|
87
|
+
_git(main, "worktree", "remove", "--force", str(tree))
|
|
88
|
+
return {"path": str(main)}
|
|
89
|
+
|
|
90
|
+
return _done(work)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def branch_delete(params: dict, root) -> dict:
|
|
94
|
+
"""git.branch_delete: the local branch, unless it is gone or the main checkout's."""
|
|
95
|
+
main, branch = main_checkout(root), params["branch"]
|
|
96
|
+
|
|
97
|
+
def work():
|
|
98
|
+
if not _git_ok(main, "rev-parse", "--verify", "-q", f"refs/heads/{branch}"):
|
|
99
|
+
return
|
|
100
|
+
if worktree(main, branch) == main:
|
|
101
|
+
return
|
|
102
|
+
_git(main, "branch", "-D", branch)
|
|
103
|
+
|
|
104
|
+
return _done(work)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fetch(params: dict, root) -> dict:
|
|
108
|
+
"""git.fetch: from origin, everything, main only, or the tags."""
|
|
109
|
+
what = params.get("what") or "all"
|
|
110
|
+
args = {"all": (), "main": ("main",), "tags": ("--tags",)}[what]
|
|
111
|
+
return _done(lambda: _git(root, "fetch", "-q", "origin", *args) and None)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def tag(params: dict, root) -> dict:
|
|
115
|
+
"""git.tag: tag the commit and push the tag to origin."""
|
|
116
|
+
|
|
117
|
+
def work():
|
|
118
|
+
_git(root, "tag", params["tag"], params["commit"])
|
|
119
|
+
_git(root, "push", "-q", "origin", f"refs/tags/{params['tag']}")
|
|
120
|
+
|
|
121
|
+
return _done(work)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def delete_remote_branch(params: dict, root) -> dict:
|
|
125
|
+
"""git.delete_remote_branch: the branch on origin."""
|
|
126
|
+
return _done(lambda: _git(root, "push", "-q", "origin", "--delete", params["branch"]) and None)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def move_onto_main(params: dict, root) -> dict:
|
|
130
|
+
"""git.move_onto_main: the branch checked out onto origin's main: fast-forward, or reset."""
|
|
131
|
+
if params["method"] == "merge":
|
|
132
|
+
return _done(lambda: _git(root, "merge", "-q", "--ff-only", "origin/main") and None)
|
|
133
|
+
return _done(lambda: _git(root, "reset", "-q", "--hard", "origin/main") and None)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def comment(params: dict, root) -> dict:
|
|
137
|
+
"""gh.comment: comment on the request, as the developer; the comment's address."""
|
|
138
|
+
|
|
139
|
+
def work():
|
|
140
|
+
out = _gh(root, "issue", "comment", params["url"], "--body-file", "-", data=params["text"])
|
|
141
|
+
links = [line.strip() for line in out.splitlines() if line.strip().startswith("https://")]
|
|
142
|
+
if not links:
|
|
143
|
+
raise Failed(errors.REFUSED)
|
|
144
|
+
return {"url": links[-1]}
|
|
145
|
+
|
|
146
|
+
return _done(work)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def label(params: dict, root) -> dict:
|
|
150
|
+
"""gh.label: add a label to the request."""
|
|
151
|
+
url, name = params["url"], params["label"]
|
|
152
|
+
return _done(lambda: _gh(root, "issue", "edit", url, "--add-label", name) and None)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def close(params: dict, root) -> dict:
|
|
156
|
+
"""gh.close: close the request."""
|
|
157
|
+
return _done(lambda: _gh(root, "issue", "close", params["url"]) and None)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def issue(params: dict, root) -> dict:
|
|
161
|
+
"""gh.issue: open an issue in the repo, as the developer; its address."""
|
|
162
|
+
|
|
163
|
+
def work():
|
|
164
|
+
out = _gh(
|
|
165
|
+
root,
|
|
166
|
+
"issue",
|
|
167
|
+
"create",
|
|
168
|
+
"--title",
|
|
169
|
+
params["title"],
|
|
170
|
+
"--body-file",
|
|
171
|
+
"-",
|
|
172
|
+
data=params["body"],
|
|
173
|
+
)
|
|
174
|
+
return {"url": out.splitlines()[-1].strip()}
|
|
175
|
+
|
|
176
|
+
return _done(work)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _title(params: dict, root) -> str:
|
|
180
|
+
"""The PR's title: the one given, else the request's own, read here."""
|
|
181
|
+
if params.get("title"):
|
|
182
|
+
return params["title"]
|
|
183
|
+
found = request.read_title(params["request"], root)
|
|
184
|
+
if not found:
|
|
185
|
+
raise Failed(errors.NOT_FOUND)
|
|
186
|
+
return found
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def pr_open(params: dict, root) -> dict:
|
|
190
|
+
"""gh.pr_open: push the branch and open its PR, or update the body of the one open for it."""
|
|
191
|
+
branch, base = params["branch"], params["base"]
|
|
192
|
+
body = Path(params["body_file"]).expanduser()
|
|
193
|
+
if not body.is_absolute():
|
|
194
|
+
body = Path(root).resolve() / body
|
|
195
|
+
if not body.is_file():
|
|
196
|
+
return {"no_body": True}
|
|
197
|
+
|
|
198
|
+
def work():
|
|
199
|
+
if branch == base:
|
|
200
|
+
raise Failed(errors.REFUSED)
|
|
201
|
+
_git(root, "push", "-q", "-u", "origin", f"refs/heads/{branch}:refs/heads/{branch}")
|
|
202
|
+
try:
|
|
203
|
+
found = json.loads(_gh(root, "pr", "view", branch, "--json", "url,state") or "{}")
|
|
204
|
+
except Failed:
|
|
205
|
+
found = {}
|
|
206
|
+
if found.get("state") == "OPEN":
|
|
207
|
+
_gh(root, "pr", "edit", found["url"], "--body-file", str(body))
|
|
208
|
+
return {"url": found["url"], "opened": False}
|
|
209
|
+
title = _title(params, root) + (params.get("suffix") or "")
|
|
210
|
+
out = _gh(
|
|
211
|
+
root,
|
|
212
|
+
"pr",
|
|
213
|
+
"create",
|
|
214
|
+
"--base",
|
|
215
|
+
base,
|
|
216
|
+
"--head",
|
|
217
|
+
branch,
|
|
218
|
+
"--title",
|
|
219
|
+
title,
|
|
220
|
+
"--body-file",
|
|
221
|
+
str(body),
|
|
222
|
+
)
|
|
223
|
+
return {"url": out.splitlines()[-1].strip(), "opened": True}
|
|
224
|
+
|
|
225
|
+
return _done(work)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def pr_merge(params: dict, root) -> dict:
|
|
229
|
+
"""gh.pr_merge: merge the PR, by merge or squash."""
|
|
230
|
+
return _done(lambda: _gh(root, "pr", "merge", params["url"], f"--{params['method']}") and None)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# Every action the client runs: its name -> the function of (params, root) that runs it.
|
|
234
|
+
RUNS: dict[str, Callable[[dict, object], dict]] = {
|
|
235
|
+
"git.worktree_add": worktree_add,
|
|
236
|
+
"git.worktree_remove": worktree_remove,
|
|
237
|
+
"git.branch_delete": branch_delete,
|
|
238
|
+
"git.fetch": fetch,
|
|
239
|
+
"git.tag": tag,
|
|
240
|
+
"git.delete_remote_branch": delete_remote_branch,
|
|
241
|
+
"git.move_onto_main": move_onto_main,
|
|
242
|
+
"gh.comment": comment,
|
|
243
|
+
"gh.label": label,
|
|
244
|
+
"gh.close": close,
|
|
245
|
+
"gh.issue": issue,
|
|
246
|
+
"gh.pr_open": pr_open,
|
|
247
|
+
"gh.pr_merge": pr_merge,
|
|
248
|
+
"workspace.record": workspace.record,
|
|
249
|
+
"workspace.put_back": workspace.put_back,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def run(name: str, params: dict, root) -> dict:
|
|
254
|
+
"""Run one action and return what it did, as plain values.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
name: The action, one of RUNS.
|
|
258
|
+
params: Its values.
|
|
259
|
+
root: Where on the machine it runs.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
What it did: the fields of its result.
|
|
263
|
+
|
|
264
|
+
Raises:
|
|
265
|
+
ValueError: If the client runs no action of that name.
|
|
266
|
+
|
|
267
|
+
Company: assumes none.
|
|
268
|
+
"""
|
|
269
|
+
if name not in RUNS:
|
|
270
|
+
raise ValueError(f"the client runs no action `{name}`; it runs {', '.join(RUNS)}")
|
|
271
|
+
return RUNS[name](dict(params or {}), root)
|
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import io
|
|
13
13
|
import json
|
|
14
|
+
import os
|
|
14
15
|
import re
|
|
15
16
|
import shutil
|
|
16
17
|
import subprocess
|
|
@@ -238,3 +239,112 @@ def remove() -> list[str]:
|
|
|
238
239
|
done.append(str(e))
|
|
239
240
|
shutil.rmtree(store_dir(), ignore_errors=True)
|
|
240
241
|
return done
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# The plugin before this one, from the engineering-methods store; it goes at the cutover.
|
|
245
|
+
OLD_PLUGIN, OLD_STORE = "engineering@engineering-methods", "engineering-methods"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def remove_old() -> list[str]:
|
|
249
|
+
"""Remove the old engineering plugin and its store, wherever Claude Code installed it.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
What was done, a line each; none when it was not there.
|
|
253
|
+
|
|
254
|
+
Company: assumes none.
|
|
255
|
+
"""
|
|
256
|
+
try:
|
|
257
|
+
listed = json.loads(_ran("plugin", "list", "--json") or "[]")
|
|
258
|
+
except (ClaudeError, ValueError):
|
|
259
|
+
return []
|
|
260
|
+
done = []
|
|
261
|
+
for plugin in listed:
|
|
262
|
+
if plugin.get("id") != OLD_PLUGIN:
|
|
263
|
+
continue
|
|
264
|
+
scope = plugin.get("scope", "user")
|
|
265
|
+
args = ["plugin", "uninstall", OLD_PLUGIN, "--scope", scope]
|
|
266
|
+
where = plugin.get("projectPath") if scope != "user" else None
|
|
267
|
+
try:
|
|
268
|
+
if where:
|
|
269
|
+
_in(where, *args)
|
|
270
|
+
else:
|
|
271
|
+
_ran(*args)
|
|
272
|
+
done.append(f"removed the old plugin {OLD_PLUGIN} ({scope})")
|
|
273
|
+
except ClaudeError as e:
|
|
274
|
+
done.append(str(e))
|
|
275
|
+
try:
|
|
276
|
+
stores = json.loads(_ran("plugin", "marketplace", "list", "--json") or "[]")
|
|
277
|
+
if any(m.get("name") == OLD_STORE for m in stores):
|
|
278
|
+
_ran("plugin", "marketplace", "remove", OLD_STORE)
|
|
279
|
+
done.append(f"removed the old store {OLD_STORE}")
|
|
280
|
+
except (ClaudeError, ValueError) as e:
|
|
281
|
+
done.append(str(e))
|
|
282
|
+
return [line for line in done if line]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _in(folder: str, *args: str) -> str:
|
|
286
|
+
"""Run one `claude` command in a project's folder, for a plugin installed for it."""
|
|
287
|
+
try:
|
|
288
|
+
done = subprocess.run(
|
|
289
|
+
["claude", *args],
|
|
290
|
+
cwd=folder,
|
|
291
|
+
capture_output=True,
|
|
292
|
+
text=True,
|
|
293
|
+
timeout=TIMEOUT,
|
|
294
|
+
stdin=subprocess.DEVNULL,
|
|
295
|
+
)
|
|
296
|
+
except (FileNotFoundError, NotADirectoryError, subprocess.TimeoutExpired) as e:
|
|
297
|
+
raise ClaudeError(f"`claude {' '.join(args)}` could not run in {folder}: {e}") from None
|
|
298
|
+
if done.returncode != 0:
|
|
299
|
+
said = (done.stderr or done.stdout).strip().splitlines()
|
|
300
|
+
raise ClaudeError(f"`claude {' '.join(args)}` failed: {said[-1] if said else ''}")
|
|
301
|
+
return ""
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# The settings files whose `disableAllHooks` turns every hook off, and the managed one, whose
|
|
305
|
+
# `allowManagedHooksOnly` turns the plugin's off.
|
|
306
|
+
MANAGED_SETTINGS = Path("/Library/Application Support/ClaudeCode/managed-settings.json")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def config_dir() -> Path:
|
|
310
|
+
"""Claude Code's own folder for the person: CLAUDE_CONFIG_DIR, else ~/.claude."""
|
|
311
|
+
found = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
|
|
312
|
+
return Path(found).expanduser() if found else Path.home() / ".claude"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _read(target: Path) -> dict:
|
|
316
|
+
try:
|
|
317
|
+
found = json.loads(target.read_text(encoding="utf-8"))
|
|
318
|
+
except (OSError, ValueError):
|
|
319
|
+
return {}
|
|
320
|
+
return found if isinstance(found, dict) else {}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def hooks_off(root) -> list[str]:
|
|
324
|
+
"""The settings file that turns Claude Code's hooks off for the repo at root, if any.
|
|
325
|
+
|
|
326
|
+
`disableAllHooks` is read where Claude Code reads it first: the managed settings, then the
|
|
327
|
+
repo's local settings, its settings, and the person's; the managed settings'
|
|
328
|
+
`allowManagedHooksOnly` turns the plugin's hooks off too.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
root: The repo's top folder.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
The file that does, by its path; empty when the hooks run.
|
|
335
|
+
|
|
336
|
+
Company: assumes none.
|
|
337
|
+
"""
|
|
338
|
+
managed = _read(MANAGED_SETTINGS)
|
|
339
|
+
if managed.get("allowManagedHooksOnly") is True:
|
|
340
|
+
return [str(MANAGED_SETTINGS)]
|
|
341
|
+
for target in (
|
|
342
|
+
MANAGED_SETTINGS,
|
|
343
|
+
Path(root) / ".claude" / "settings.local.json",
|
|
344
|
+
Path(root) / ".claude" / "settings.json",
|
|
345
|
+
config_dir() / "settings.json",
|
|
346
|
+
):
|
|
347
|
+
found = _read(target)
|
|
348
|
+
if "disableAllHooks" in found:
|
|
349
|
+
return [str(target)] if found["disableAllHooks"] is True else []
|
|
350
|
+
return []
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
"""The groundcontrol command (and its alias, gctl): init, the
|
|
1
|
+
"""The groundcontrol command (and its alias, gctl): init, the method, the hooks, repo, remove.
|
|
2
2
|
|
|
3
3
|
`groundcontrol init <address>` connects the machine to a company and installs the plugin,
|
|
4
4
|
saying in one line what it does at each step; the person only approves in the browser.
|
|
5
|
-
`
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
`gctl method <command>` runs one of the method's commands on the repo's company's service
|
|
6
|
+
(client.service: Service.method). `gctl hook <event>` is what the plugin's hooks run
|
|
7
|
+
(client/hooks.py); at a session's start (`hook session-start`, or `session-start`) the client
|
|
8
|
+
updates itself and the plugin, removes the old engineering plugin, finds the repo's company,
|
|
9
|
+
and sends its queue; it is silent unless something needs the person. `groundcontrol repo` says
|
|
10
|
+
which company the repo here is of, or what is missing. `groundcontrol remove` removes it all
|
|
11
|
+
from the machine. `send` and `down` run in the background, started by the hooks: the queue's
|
|
12
|
+
sender, and the environments a session brought up taken down.
|
|
9
13
|
"""
|
|
10
14
|
|
|
11
15
|
from __future__ import annotations
|
|
@@ -17,7 +21,8 @@ import subprocess
|
|
|
17
21
|
import sys
|
|
18
22
|
from pathlib import Path
|
|
19
23
|
|
|
20
|
-
from client import claude, connect, machine, system, update
|
|
24
|
+
from client import claude, connect, hooks, machine, queue, system, tools, update
|
|
25
|
+
from client.probes import health
|
|
21
26
|
from client.service import PACKAGE, VERSION, Service, ServiceError
|
|
22
27
|
|
|
23
28
|
TIMEOUT = 30
|
|
@@ -89,8 +94,17 @@ def init(given: str, store_file: str | None = None, door: str | None = None) ->
|
|
|
89
94
|
f"{called.address} knows you: {person['name']} ({person['email']}) at {company['name']}, "
|
|
90
95
|
f"on the machine {me['machine']['name']}."
|
|
91
96
|
)
|
|
97
|
+
for line in claude.remove_old():
|
|
98
|
+
say(line[:1].upper() + line[1:] + ".")
|
|
92
99
|
if not _plugin(called, store_file):
|
|
93
100
|
return 1
|
|
101
|
+
say("Installing the code readers' tools: Node, the TypeScript compiler and shfmt.")
|
|
102
|
+
try:
|
|
103
|
+
done = tools.install(here.processor())
|
|
104
|
+
except tools.ToolsError as e:
|
|
105
|
+
say(f"Stopped: {e}.")
|
|
106
|
+
return 1
|
|
107
|
+
say(f"The tools: {', '.join(done) or 'current'}.")
|
|
94
108
|
say(f"Done. Open Claude Code in a repo of {company['name']}.")
|
|
95
109
|
return 0
|
|
96
110
|
|
|
@@ -149,9 +163,9 @@ def session_start(event: dict) -> list[str]:
|
|
|
149
163
|
"""What runs when a Claude Code session starts; what the person needs to know, if anything.
|
|
150
164
|
|
|
151
165
|
The client updates itself when it is behind, before anything else; then the plugin, when
|
|
152
|
-
it is behind or missing
|
|
153
|
-
|
|
154
|
-
not managed, or on a machine connected to no company.
|
|
166
|
+
it is behind or missing, and the old engineering plugin goes; then it reads the managed
|
|
167
|
+
lists and finds the repo's company. Nothing is written into the repo. Silent in a repo
|
|
168
|
+
that is not managed, or on a machine connected to no company.
|
|
155
169
|
|
|
156
170
|
Args:
|
|
157
171
|
event: The SessionStart event Claude Code sends, with its cwd.
|
|
@@ -190,6 +204,11 @@ def session_start(event: dict) -> list[str]:
|
|
|
190
204
|
claude.install(claude.unpack(called.store()))
|
|
191
205
|
except (ServiceError, claude.ClaudeError, OSError) as e:
|
|
192
206
|
said.append(f"The plugin needs updating to {held['plugin']}: {e}.")
|
|
207
|
+
claude.remove_old()
|
|
208
|
+
try:
|
|
209
|
+
tools.install(here.processor())
|
|
210
|
+
except tools.ToolsError as e:
|
|
211
|
+
said.append(f"The code readers' tools need installing: {e}.")
|
|
193
212
|
cwd = event.get("cwd")
|
|
194
213
|
if cwd:
|
|
195
214
|
machine.find(Path(cwd), here, refresh=True)
|
|
@@ -238,6 +257,8 @@ def remove() -> int:
|
|
|
238
257
|
machine.forget_company(address)
|
|
239
258
|
for line in claude.remove():
|
|
240
259
|
say(line[:1].upper() + line[1:] + ".")
|
|
260
|
+
if tools.remove():
|
|
261
|
+
say("Removed the code readers' tools.")
|
|
241
262
|
shutil.rmtree(machine.home(), ignore_errors=True)
|
|
242
263
|
if update.installed_by_uv():
|
|
243
264
|
subprocess.run(["uv", "tool", "uninstall", "--quiet", PACKAGE], check=False)
|
|
@@ -264,6 +285,73 @@ def save_store(target: str) -> int:
|
|
|
264
285
|
return 0
|
|
265
286
|
|
|
266
287
|
|
|
288
|
+
HOOKS = (
|
|
289
|
+
"session-start",
|
|
290
|
+
"session-end",
|
|
291
|
+
"pre-tool-use",
|
|
292
|
+
"post-tool-use",
|
|
293
|
+
"post-tool-use-failure",
|
|
294
|
+
"user-prompt-submit",
|
|
295
|
+
"subagent-start",
|
|
296
|
+
"subagent-stop",
|
|
297
|
+
"stop",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _event() -> dict:
|
|
302
|
+
"""The hook's event, from standard input; {} when there is none."""
|
|
303
|
+
try:
|
|
304
|
+
found = json.load(sys.stdin)
|
|
305
|
+
except ValueError:
|
|
306
|
+
return {}
|
|
307
|
+
return found if isinstance(found, dict) else {}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _service_for(address: str) -> Service | None:
|
|
311
|
+
"""The service at a company's address with this machine's key, for the queue's sender."""
|
|
312
|
+
try:
|
|
313
|
+
return machine.service(address, _system())
|
|
314
|
+
except system.Unsupported:
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def method(argv: list[str], root: Path) -> int:
|
|
319
|
+
"""`gctl method <command>`: run one of the method's commands on the repo's company's service.
|
|
320
|
+
|
|
321
|
+
The repo is found from the folder; its company's service runs the command and has this
|
|
322
|
+
machine run what it needs; what the command prints is printed here.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
argv: The command and its arguments.
|
|
326
|
+
root: The folder it is run in.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
The command's exit code.
|
|
330
|
+
|
|
331
|
+
Company: the repo's.
|
|
332
|
+
"""
|
|
333
|
+
try:
|
|
334
|
+
found = machine.find(root, _system(), known_only=True)
|
|
335
|
+
if isinstance(found, machine.Missing):
|
|
336
|
+
found = machine.find(root, _system(), refresh=True)
|
|
337
|
+
except system.Unsupported as e:
|
|
338
|
+
print(f"refused: {e}", file=sys.stderr)
|
|
339
|
+
return 1
|
|
340
|
+
if isinstance(found, machine.Missing):
|
|
341
|
+
print(f"refused: not managed here: {found.why}", file=sys.stderr)
|
|
342
|
+
return 1
|
|
343
|
+
top = hooks._git(str(root), "rev-parse", "--show-toplevel") or str(root)
|
|
344
|
+
try:
|
|
345
|
+
queue.flush(_service_for, found.service.address)
|
|
346
|
+
done = found.service.method(found.repo, argv, str(root), claude.hooks_off(top))
|
|
347
|
+
except ServiceError as e:
|
|
348
|
+
print(f"refused: {found.service.address}: {e.detail}", file=sys.stderr)
|
|
349
|
+
return 1
|
|
350
|
+
sys.stdout.write(done.get("out") or "")
|
|
351
|
+
sys.stderr.write(done.get("err") or "")
|
|
352
|
+
return int(done.get("code") or 0)
|
|
353
|
+
|
|
354
|
+
|
|
267
355
|
def main(argv: list[str] | None = None) -> int:
|
|
268
356
|
"""Run one command of groundcontrol.
|
|
269
357
|
|
|
@@ -283,23 +371,45 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
283
371
|
started.add_argument("--store", help="install the plugin from this archive")
|
|
284
372
|
started.add_argument("--door", help="the door's address, as the install line gives it")
|
|
285
373
|
commands.add_parser("session-start", help="what the plugin's hook runs as a session starts")
|
|
374
|
+
hooked = commands.add_parser("hook", help="what the plugin's hooks run")
|
|
375
|
+
hooked.add_argument("event", choices=HOOKS)
|
|
376
|
+
method_p = commands.add_parser(
|
|
377
|
+
"method", help="run one of the method's commands", add_help=False
|
|
378
|
+
)
|
|
379
|
+
method_p.add_argument("argv", nargs=argparse.REMAINDER)
|
|
380
|
+
commands.add_parser("send", help="send the queue to the service, in the background")
|
|
381
|
+
down_p = commands.add_parser("down", help="take down what sessions brought up under a folder")
|
|
382
|
+
down_p.add_argument("folder")
|
|
286
383
|
commands.add_parser("repo", help="say which company the repo here is of")
|
|
287
384
|
commands.add_parser("remove", help="remove it all from this machine")
|
|
288
385
|
saved = commands.add_parser("store", help="save the plugin's store as an archive")
|
|
289
386
|
saved.add_argument("file")
|
|
387
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
388
|
+
if argv[:1] == ["method"]:
|
|
389
|
+
return method(argv[1:], Path.cwd())
|
|
290
390
|
args = parser.parse_args(argv)
|
|
291
391
|
try:
|
|
292
392
|
if args.command == "init":
|
|
293
393
|
return init(args.address, args.store, args.door)
|
|
294
|
-
if args.command == "session-start"
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
lines = session_start(event if isinstance(event, dict) else {})
|
|
394
|
+
if args.command == "session-start" or (
|
|
395
|
+
args.command == "hook" and args.event == "session-start"
|
|
396
|
+
):
|
|
397
|
+
event = _event()
|
|
398
|
+
lines = hooks.session_start(event, session_start(event))
|
|
300
399
|
if lines:
|
|
301
400
|
print(json.dumps({"systemMessage": " ".join(lines)}))
|
|
302
401
|
return 0
|
|
402
|
+
if args.command == "hook":
|
|
403
|
+
code, out = hooks.run(args.event, _event())
|
|
404
|
+
if out:
|
|
405
|
+
print(out)
|
|
406
|
+
return code
|
|
407
|
+
if args.command == "send":
|
|
408
|
+
queue.send_until_empty(_service_for)
|
|
409
|
+
return 0
|
|
410
|
+
if args.command == "down":
|
|
411
|
+
health.down_all(args.folder)
|
|
412
|
+
return 0
|
|
303
413
|
if args.command == "repo":
|
|
304
414
|
return repo(Path.cwd())
|
|
305
415
|
if args.command == "remove":
|