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.
Files changed (32) hide show
  1. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/PKG-INFO +1 -1
  2. ground_control_cli-0.3.0/actions.py +271 -0
  3. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/claude.py +110 -0
  4. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/cli.py +125 -15
  5. ground_control_cli-0.3.0/hooks.py +457 -0
  6. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/machine.py +7 -1
  7. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/__init__.py +9 -1
  8. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/code/bash.py +5 -22
  9. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/code/javascript.py +15 -18
  10. ground_control_cli-0.3.0/probes/deploy.py +261 -0
  11. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/git.py +90 -37
  12. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/health.py +58 -2
  13. ground_control_cli-0.3.0/probes/request.py +262 -0
  14. ground_control_cli-0.3.0/probes/workspace.py +341 -0
  15. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/pyproject.toml +10 -2
  16. ground_control_cli-0.3.0/queue.py +328 -0
  17. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/service.py +152 -13
  18. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/system/__init__.py +6 -0
  19. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/system/macos.py +9 -0
  20. ground_control_cli-0.3.0/tools.py +232 -0
  21. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/.gitignore +0 -0
  22. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/__init__.py +0 -0
  23. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/connect.py +0 -0
  24. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/code/__init__.py +0 -0
  25. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/code/javascript.js +0 -0
  26. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/code/python.py +0 -0
  27. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/errors.py +0 -0
  28. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/gh.py +0 -0
  29. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/settings.py +0 -0
  30. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/probes/tests.py +0 -0
  31. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/repos.py +0 -0
  32. {ground_control_cli-0.2.0 → ground_control_cli-0.3.0}/update.py +0 -0
@@ -1,5 +1,5 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ground-control-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: What runs on a developer's machine: the command line and the agent's plugin.
5
5
  Requires-Python: >=3.12
@@ -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 session's start, repo, remove.
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
- `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.
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; 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.
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
- try:
296
- event = json.load(sys.stdin)
297
- except ValueError:
298
- event = {}
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":