ground-control-cli 0.3.0__tar.gz → 0.5.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.3.0 → ground_control_cli-0.5.0}/PKG-INFO +1 -1
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/claude.py +96 -10
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/cli.py +114 -6
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/hooks.py +148 -83
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/machine.py +10 -2
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/__init__.py +4 -1
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/git.py +63 -0
- ground_control_cli-0.5.0/probes/queue.py +31 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/request.py +4 -10
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/pyproject.toml +1 -1
- ground_control_cli-0.5.0/queue.py +633 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/service.py +58 -7
- ground_control_cli-0.3.0/queue.py +0 -328
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/.gitignore +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/__init__.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/actions.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/connect.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/code/__init__.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/code/bash.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/code/javascript.js +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/code/javascript.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/code/python.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/deploy.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/errors.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/gh.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/health.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/settings.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/tests.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/workspace.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/repos.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/system/__init__.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/system/macos.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/tools.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/update.py +0 -0
|
@@ -181,10 +181,10 @@ def installed(store: Store) -> str | None:
|
|
|
181
181
|
|
|
182
182
|
|
|
183
183
|
def _registered(store: Store) -> bool:
|
|
184
|
-
"""Whether Claude Code knows the store."""
|
|
184
|
+
"""Whether Claude Code knows the store; not when Claude Code cannot say."""
|
|
185
185
|
try:
|
|
186
186
|
listed = json.loads(_ran("plugin", "marketplace", "list", "--json") or "[]")
|
|
187
|
-
except ValueError:
|
|
187
|
+
except (ValueError, ClaudeError):
|
|
188
188
|
return False
|
|
189
189
|
return any(m.get("name") == store.name for m in listed)
|
|
190
190
|
|
|
@@ -301,9 +301,90 @@ def _in(folder: str, *args: str) -> str:
|
|
|
301
301
|
return ""
|
|
302
302
|
|
|
303
303
|
|
|
304
|
-
# The
|
|
305
|
-
|
|
306
|
-
|
|
304
|
+
# The plugin's name, as its manifest in claude-code/ gives it.
|
|
305
|
+
PLUGIN = "groundcontrol"
|
|
306
|
+
# Claude Code's managed settings on a Mac: a configuration profile (MDM) of the domain
|
|
307
|
+
# com.anthropic.claudecode, and the system folder's file with its drop-ins. Only the first of
|
|
308
|
+
# them that holds a policy applies; the profile comes first.
|
|
309
|
+
MANAGED_FOLDER = Path("/Library/Application Support/ClaudeCode")
|
|
310
|
+
MANAGED_SETTINGS = MANAGED_FOLDER / "managed-settings.json"
|
|
311
|
+
MANAGED_DROP_INS = MANAGED_FOLDER / "managed-settings.d"
|
|
312
|
+
MANAGED_PROFILE = Path("/Library/Managed Preferences/com.anthropic.claudecode.plist")
|
|
313
|
+
# The keys that are no policy of their own: a source holding only these does not apply.
|
|
314
|
+
CONTROL_KEYS = ("wslInheritsWindowsSettings", "managedSourcesBehavior")
|
|
315
|
+
# What the lock is read from in the managed settings, by the name the facts give it.
|
|
316
|
+
LOCK_KEYS = {
|
|
317
|
+
"disable_all_hooks": "disableAllHooks",
|
|
318
|
+
"allow_managed_hooks_only": "allowManagedHooksOnly",
|
|
319
|
+
"disable_sideload_flags": "disableSideloadFlags",
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _profile() -> dict:
|
|
324
|
+
"""The managed configuration profile, as JSON; {} when there is none."""
|
|
325
|
+
if not MANAGED_PROFILE.is_file():
|
|
326
|
+
return {}
|
|
327
|
+
try:
|
|
328
|
+
done = subprocess.run(
|
|
329
|
+
["plutil", "-convert", "json", "-o", "-", str(MANAGED_PROFILE)],
|
|
330
|
+
capture_output=True,
|
|
331
|
+
text=True,
|
|
332
|
+
timeout=10,
|
|
333
|
+
)
|
|
334
|
+
found = json.loads(done.stdout) if done.returncode == 0 else {}
|
|
335
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
336
|
+
return {}
|
|
337
|
+
return found if isinstance(found, dict) else {}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _policy(found: dict) -> bool:
|
|
341
|
+
return any(k not in CONTROL_KEYS for k in found)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def managed() -> tuple[str, dict]:
|
|
345
|
+
"""The managed settings that apply on this machine, and where they come from.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
("profile", its settings), ("file", the file's with its drop-ins over it, in their
|
|
349
|
+
order), or ("none", {}).
|
|
350
|
+
|
|
351
|
+
Company: assumes none.
|
|
352
|
+
"""
|
|
353
|
+
profile = _profile()
|
|
354
|
+
if _policy(profile):
|
|
355
|
+
return "profile", profile
|
|
356
|
+
found = _read(MANAGED_SETTINGS)
|
|
357
|
+
try:
|
|
358
|
+
drop_ins = sorted(MANAGED_DROP_INS.glob("*.json"))
|
|
359
|
+
except OSError:
|
|
360
|
+
drop_ins = []
|
|
361
|
+
for target in drop_ins:
|
|
362
|
+
found |= _read(target)
|
|
363
|
+
return ("file", found) if _policy(found) else ("none", {})
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def lock_facts() -> dict:
|
|
367
|
+
"""What the managed settings hold of the plugin and its hooks, and whether its store is here.
|
|
368
|
+
|
|
369
|
+
Facts only, for the service to decide whether the machine is locked: where the managed
|
|
370
|
+
settings come from, what their `enabledPlugins` say of any plugin named groundcontrol, their
|
|
371
|
+
keys that turn hooks or sideloading off, and the store Claude Code knows, if any.
|
|
372
|
+
|
|
373
|
+
Company: assumes none.
|
|
374
|
+
"""
|
|
375
|
+
source, found = managed()
|
|
376
|
+
plugins = found.get("enabledPlugins")
|
|
377
|
+
plugins = plugins if isinstance(plugins, dict) else {}
|
|
378
|
+
store = read_store()
|
|
379
|
+
known = store is not None and _registered(store)
|
|
380
|
+
return {
|
|
381
|
+
"source": source,
|
|
382
|
+
"plugins": {
|
|
383
|
+
k: v for k, v in plugins.items() if k.split("@")[0] == PLUGIN and isinstance(v, bool)
|
|
384
|
+
},
|
|
385
|
+
**{name: found.get(key) for name, key in LOCK_KEYS.items()},
|
|
386
|
+
"store": {"name": store.name, "plugin": store.plugin} if known and store else None,
|
|
387
|
+
}
|
|
307
388
|
|
|
308
389
|
|
|
309
390
|
def config_dir() -> Path:
|
|
@@ -325,7 +406,7 @@ def hooks_off(root) -> list[str]:
|
|
|
325
406
|
|
|
326
407
|
`disableAllHooks` is read where Claude Code reads it first: the managed settings, then the
|
|
327
408
|
repo's local settings, its settings, and the person's; the managed settings'
|
|
328
|
-
`allowManagedHooksOnly` turns the plugin's hooks off too.
|
|
409
|
+
`allowManagedHooksOnly` turns the plugin's hooks off too, unless they force the plugin on.
|
|
329
410
|
|
|
330
411
|
Args:
|
|
331
412
|
root: The repo's top folder.
|
|
@@ -335,11 +416,16 @@ def hooks_off(root) -> list[str]:
|
|
|
335
416
|
|
|
336
417
|
Company: assumes none.
|
|
337
418
|
"""
|
|
338
|
-
|
|
339
|
-
if
|
|
340
|
-
|
|
419
|
+
source, policy = managed()
|
|
420
|
+
where = str(MANAGED_PROFILE if source == "profile" else MANAGED_SETTINGS)
|
|
421
|
+
forced = policy.get("enabledPlugins") if isinstance(policy.get("enabledPlugins"), dict) else {}
|
|
422
|
+
if policy.get("allowManagedHooksOnly") is True and not any(
|
|
423
|
+
v is True and k.split("@")[0] == PLUGIN for k, v in forced.items()
|
|
424
|
+
):
|
|
425
|
+
return [where]
|
|
426
|
+
if "disableAllHooks" in policy:
|
|
427
|
+
return [where] if policy["disableAllHooks"] is True else []
|
|
341
428
|
for target in (
|
|
342
|
-
MANAGED_SETTINGS,
|
|
343
429
|
Path(root) / ".claude" / "settings.local.json",
|
|
344
430
|
Path(root) / ".claude" / "settings.json",
|
|
345
431
|
config_dir() / "settings.json",
|
|
@@ -7,9 +7,13 @@ saying in one line what it does at each step; the person only approves in the br
|
|
|
7
7
|
(client/hooks.py); at a session's start (`hook session-start`, or `session-start`) the client
|
|
8
8
|
updates itself and the plugin, removes the old engineering plugin, finds the repo's company,
|
|
9
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
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
which company the repo here is of, or what is missing. `groundcontrol machines` lists the
|
|
11
|
+
company's connected machines and whether Claude Code's managed settings lock the plugin on in
|
|
12
|
+
each, and `groundcontrol policy` prints the policy for the company's IT to deploy; both for a
|
|
13
|
+
person with manage_machines. `groundcontrol remove` sends what the queue holds, then removes it
|
|
14
|
+
all from the machine, saying how many rows of the method log could not be sent. `send` and
|
|
15
|
+
`down` run in the background, started by the hooks: the queue's sender, and the environments a
|
|
16
|
+
session brought up taken down.
|
|
13
17
|
"""
|
|
14
18
|
|
|
15
19
|
from __future__ import annotations
|
|
@@ -142,6 +146,7 @@ def _connect(door: str, address: str, here: system.System) -> Service | None:
|
|
|
142
146
|
say(f"Stopped: {e}.")
|
|
143
147
|
return None
|
|
144
148
|
machine.remember_company(company_address, door)
|
|
149
|
+
queue.resume(company_address, connected=True)
|
|
145
150
|
say("Approved. The key is in the keychain, and in no file.")
|
|
146
151
|
return Service(company_address, key, TIMEOUT)
|
|
147
152
|
|
|
@@ -242,6 +247,22 @@ def remove() -> int:
|
|
|
242
247
|
except system.Unsupported as e:
|
|
243
248
|
say(f"Stopped: {e}.")
|
|
244
249
|
return 1
|
|
250
|
+
try:
|
|
251
|
+
queue.flush(_service_for, wait=TIMEOUT)
|
|
252
|
+
except ServiceError as e:
|
|
253
|
+
say(f"The queue could not all be sent: {e.detail}.")
|
|
254
|
+
if queue.waiting():
|
|
255
|
+
dropped, untold = queue.drop_all(_service_for)
|
|
256
|
+
if dropped - untold:
|
|
257
|
+
say(
|
|
258
|
+
f"{dropped - untold} rows of the method log could not be sent, and are dropped; "
|
|
259
|
+
"the service was told which."
|
|
260
|
+
)
|
|
261
|
+
if untold:
|
|
262
|
+
say(
|
|
263
|
+
f"{untold} rows of the method log could not be sent, and are dropped; the service "
|
|
264
|
+
"could not be told which, so features they belong to cannot pass their gate."
|
|
265
|
+
)
|
|
245
266
|
for address in machine.load()["companies"]:
|
|
246
267
|
called = machine.service(address, here)
|
|
247
268
|
if called is not None:
|
|
@@ -267,6 +288,73 @@ def remove() -> int:
|
|
|
267
288
|
return 0
|
|
268
289
|
|
|
269
290
|
|
|
291
|
+
def _company_here(root: Path) -> Service | None:
|
|
292
|
+
"""The company of the repo here, or the one company this machine is connected to."""
|
|
293
|
+
here = _system()
|
|
294
|
+
found = machine.find(root, here, known_only=True)
|
|
295
|
+
if isinstance(found, machine.Found):
|
|
296
|
+
return found.service
|
|
297
|
+
companies = machine.load()["companies"]
|
|
298
|
+
return machine.service(companies[0], here) if len(companies) == 1 else None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def machines(root: Path, unlocked: bool) -> int:
|
|
302
|
+
"""List the company's connected machines, and whether the plugin is locked on in each.
|
|
303
|
+
|
|
304
|
+
Company: the repo's here, or the one connected.
|
|
305
|
+
"""
|
|
306
|
+
try:
|
|
307
|
+
called = _company_here(root)
|
|
308
|
+
except system.Unsupported as e:
|
|
309
|
+
say(f"Stopped: {e}.")
|
|
310
|
+
return 1
|
|
311
|
+
if called is None:
|
|
312
|
+
say("Stopped: run it in a repo of the company, or connect this machine to one company.")
|
|
313
|
+
return 1
|
|
314
|
+
try:
|
|
315
|
+
found = called.machines(unlocked)
|
|
316
|
+
except ServiceError as e:
|
|
317
|
+
say(f"Stopped: {called.address}: {e.detail}")
|
|
318
|
+
return 1
|
|
319
|
+
if not found:
|
|
320
|
+
say("Every connected machine is locked." if unlocked else "No machine is connected.")
|
|
321
|
+
return 0
|
|
322
|
+
for m in found:
|
|
323
|
+
state = {True: "locked", False: "not locked", None: "never said"}[m["locked"]]
|
|
324
|
+
said = (m["lock_checked_at"] or "")[:16].replace("T", " ")
|
|
325
|
+
when = f", said {said}" if said else ""
|
|
326
|
+
say(f"{m['name']} ({m['person']}): {state}{when}")
|
|
327
|
+
return 0
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def policy(root: Path) -> int:
|
|
331
|
+
"""Print the policy for Claude Code's managed settings, for the company's IT to deploy.
|
|
332
|
+
|
|
333
|
+
Company: the repo's here, or the one connected.
|
|
334
|
+
"""
|
|
335
|
+
try:
|
|
336
|
+
called = _company_here(root)
|
|
337
|
+
except system.Unsupported as e:
|
|
338
|
+
say(f"Stopped: {e}.")
|
|
339
|
+
return 1
|
|
340
|
+
if called is None:
|
|
341
|
+
say("Stopped: run it in a repo of the company, or connect this machine to one company.")
|
|
342
|
+
return 1
|
|
343
|
+
try:
|
|
344
|
+
found = called.policy()
|
|
345
|
+
except ServiceError as e:
|
|
346
|
+
say(f"Stopped: {called.address}: {e.detail}")
|
|
347
|
+
return 1
|
|
348
|
+
print(json.dumps(found["settings"], indent=2))
|
|
349
|
+
print(
|
|
350
|
+
f"Deploy it as {found['file']}, or as a configuration profile of the domain "
|
|
351
|
+
f"{found['profile_domain']}, on every machine. `claude --bare` still starts Claude Code "
|
|
352
|
+
"without plugins or hooks; no policy stops it.",
|
|
353
|
+
file=sys.stderr,
|
|
354
|
+
)
|
|
355
|
+
return 0
|
|
356
|
+
|
|
357
|
+
|
|
270
358
|
def save_store(target: str) -> int:
|
|
271
359
|
"""Save the plugin's store as a zip, for a machine that installs it from an archive.
|
|
272
360
|
|
|
@@ -315,6 +403,14 @@ def _service_for(address: str) -> Service | None:
|
|
|
315
403
|
return None
|
|
316
404
|
|
|
317
405
|
|
|
406
|
+
def _notify(line: str) -> None:
|
|
407
|
+
"""Show the person a line from the background: the system's notification."""
|
|
408
|
+
try:
|
|
409
|
+
_system().notify(line)
|
|
410
|
+
except system.Unsupported:
|
|
411
|
+
pass
|
|
412
|
+
|
|
413
|
+
|
|
318
414
|
def method(argv: list[str], root: Path) -> int:
|
|
319
415
|
"""`gctl method <command>`: run one of the method's commands on the repo's company's service.
|
|
320
416
|
|
|
@@ -342,7 +438,7 @@ def method(argv: list[str], root: Path) -> int:
|
|
|
342
438
|
return 1
|
|
343
439
|
top = hooks._git(str(root), "rev-parse", "--show-toplevel") or str(root)
|
|
344
440
|
try:
|
|
345
|
-
queue.flush(_service_for, found.service.address)
|
|
441
|
+
queue.flush(_service_for, found.service.address, TIMEOUT)
|
|
346
442
|
done = found.service.method(found.repo, argv, str(root), claude.hooks_off(top))
|
|
347
443
|
except ServiceError as e:
|
|
348
444
|
print(f"refused: {found.service.address}: {e.detail}", file=sys.stderr)
|
|
@@ -373,6 +469,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
373
469
|
commands.add_parser("session-start", help="what the plugin's hook runs as a session starts")
|
|
374
470
|
hooked = commands.add_parser("hook", help="what the plugin's hooks run")
|
|
375
471
|
hooked.add_argument("event", choices=HOOKS)
|
|
472
|
+
hooked.add_argument(
|
|
473
|
+
"--wait", type=float, default=0, help="how long to go on asking, as the plugin gives it"
|
|
474
|
+
)
|
|
376
475
|
method_p = commands.add_parser(
|
|
377
476
|
"method", help="run one of the method's commands", add_help=False
|
|
378
477
|
)
|
|
@@ -381,6 +480,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
381
480
|
down_p = commands.add_parser("down", help="take down what sessions brought up under a folder")
|
|
382
481
|
down_p.add_argument("folder")
|
|
383
482
|
commands.add_parser("repo", help="say which company the repo here is of")
|
|
483
|
+
listed = commands.add_parser(
|
|
484
|
+
"machines", help="the company's machines, and whether the plugin is locked on in each"
|
|
485
|
+
)
|
|
486
|
+
listed.add_argument("--unlocked", action="store_true", help="only those not locked")
|
|
487
|
+
commands.add_parser("policy", help="the managed-settings policy for the company's IT")
|
|
384
488
|
commands.add_parser("remove", help="remove it all from this machine")
|
|
385
489
|
saved = commands.add_parser("store", help="save the plugin's store as an archive")
|
|
386
490
|
saved.add_argument("file")
|
|
@@ -400,12 +504,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
400
504
|
print(json.dumps({"systemMessage": " ".join(lines)}))
|
|
401
505
|
return 0
|
|
402
506
|
if args.command == "hook":
|
|
403
|
-
code, out = hooks.run(args.event, _event())
|
|
507
|
+
code, out = hooks.run(args.event, _event(), args.wait)
|
|
404
508
|
if out:
|
|
405
509
|
print(out)
|
|
406
510
|
return code
|
|
407
511
|
if args.command == "send":
|
|
408
|
-
queue.send_until_empty(_service_for)
|
|
512
|
+
queue.send_until_empty(_service_for, tell=_notify)
|
|
409
513
|
return 0
|
|
410
514
|
if args.command == "down":
|
|
411
515
|
health.down_all(args.folder)
|
|
@@ -414,6 +518,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
414
518
|
return repo(Path.cwd())
|
|
415
519
|
if args.command == "remove":
|
|
416
520
|
return remove()
|
|
521
|
+
if args.command == "machines":
|
|
522
|
+
return machines(Path.cwd(), args.unlocked)
|
|
523
|
+
if args.command == "policy":
|
|
524
|
+
return policy(Path.cwd())
|
|
417
525
|
return save_store(args.file)
|
|
418
526
|
except KeyboardInterrupt:
|
|
419
527
|
say("Stopped.")
|