ground-control-cli 0.3.0__tar.gz → 0.4.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.4.0}/PKG-INFO +1 -1
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/claude.py +96 -10
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/cli.py +106 -6
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/hooks.py +148 -83
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/machine.py +10 -2
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/__init__.py +2 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/git.py +63 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/pyproject.toml +1 -1
- ground_control_cli-0.4.0/queue.py +556 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/service.py +44 -7
- ground_control_cli-0.3.0/queue.py +0 -328
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/.gitignore +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/__init__.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/actions.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/connect.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/code/__init__.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/code/bash.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/code/javascript.js +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/code/javascript.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/code/python.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/deploy.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/errors.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/gh.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/health.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/request.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/settings.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/tests.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/workspace.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/repos.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/system/__init__.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/system/macos.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/tools.py +0 -0
- {ground_control_cli-0.3.0 → ground_control_cli-0.4.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,14 @@ 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 = queue.drop_all()
|
|
256
|
+
if dropped:
|
|
257
|
+
say(f"{dropped} rows of the method log could not be sent, and are dropped.")
|
|
245
258
|
for address in machine.load()["companies"]:
|
|
246
259
|
called = machine.service(address, here)
|
|
247
260
|
if called is not None:
|
|
@@ -267,6 +280,73 @@ def remove() -> int:
|
|
|
267
280
|
return 0
|
|
268
281
|
|
|
269
282
|
|
|
283
|
+
def _company_here(root: Path) -> Service | None:
|
|
284
|
+
"""The company of the repo here, or the one company this machine is connected to."""
|
|
285
|
+
here = _system()
|
|
286
|
+
found = machine.find(root, here, known_only=True)
|
|
287
|
+
if isinstance(found, machine.Found):
|
|
288
|
+
return found.service
|
|
289
|
+
companies = machine.load()["companies"]
|
|
290
|
+
return machine.service(companies[0], here) if len(companies) == 1 else None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def machines(root: Path, unlocked: bool) -> int:
|
|
294
|
+
"""List the company's connected machines, and whether the plugin is locked on in each.
|
|
295
|
+
|
|
296
|
+
Company: the repo's here, or the one connected.
|
|
297
|
+
"""
|
|
298
|
+
try:
|
|
299
|
+
called = _company_here(root)
|
|
300
|
+
except system.Unsupported as e:
|
|
301
|
+
say(f"Stopped: {e}.")
|
|
302
|
+
return 1
|
|
303
|
+
if called is None:
|
|
304
|
+
say("Stopped: run it in a repo of the company, or connect this machine to one company.")
|
|
305
|
+
return 1
|
|
306
|
+
try:
|
|
307
|
+
found = called.machines(unlocked)
|
|
308
|
+
except ServiceError as e:
|
|
309
|
+
say(f"Stopped: {called.address}: {e.detail}")
|
|
310
|
+
return 1
|
|
311
|
+
if not found:
|
|
312
|
+
say("Every connected machine is locked." if unlocked else "No machine is connected.")
|
|
313
|
+
return 0
|
|
314
|
+
for m in found:
|
|
315
|
+
state = {True: "locked", False: "not locked", None: "never said"}[m["locked"]]
|
|
316
|
+
said = (m["lock_checked_at"] or "")[:16].replace("T", " ")
|
|
317
|
+
when = f", said {said}" if said else ""
|
|
318
|
+
say(f"{m['name']} ({m['person']}): {state}{when}")
|
|
319
|
+
return 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def policy(root: Path) -> int:
|
|
323
|
+
"""Print the policy for Claude Code's managed settings, for the company's IT to deploy.
|
|
324
|
+
|
|
325
|
+
Company: the repo's here, or the one connected.
|
|
326
|
+
"""
|
|
327
|
+
try:
|
|
328
|
+
called = _company_here(root)
|
|
329
|
+
except system.Unsupported as e:
|
|
330
|
+
say(f"Stopped: {e}.")
|
|
331
|
+
return 1
|
|
332
|
+
if called is None:
|
|
333
|
+
say("Stopped: run it in a repo of the company, or connect this machine to one company.")
|
|
334
|
+
return 1
|
|
335
|
+
try:
|
|
336
|
+
found = called.policy()
|
|
337
|
+
except ServiceError as e:
|
|
338
|
+
say(f"Stopped: {called.address}: {e.detail}")
|
|
339
|
+
return 1
|
|
340
|
+
print(json.dumps(found["settings"], indent=2))
|
|
341
|
+
print(
|
|
342
|
+
f"Deploy it as {found['file']}, or as a configuration profile of the domain "
|
|
343
|
+
f"{found['profile_domain']}, on every machine. `claude --bare` still starts Claude Code "
|
|
344
|
+
"without plugins or hooks; no policy stops it.",
|
|
345
|
+
file=sys.stderr,
|
|
346
|
+
)
|
|
347
|
+
return 0
|
|
348
|
+
|
|
349
|
+
|
|
270
350
|
def save_store(target: str) -> int:
|
|
271
351
|
"""Save the plugin's store as a zip, for a machine that installs it from an archive.
|
|
272
352
|
|
|
@@ -315,6 +395,14 @@ def _service_for(address: str) -> Service | None:
|
|
|
315
395
|
return None
|
|
316
396
|
|
|
317
397
|
|
|
398
|
+
def _notify(line: str) -> None:
|
|
399
|
+
"""Show the person a line from the background: the system's notification."""
|
|
400
|
+
try:
|
|
401
|
+
_system().notify(line)
|
|
402
|
+
except system.Unsupported:
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
|
|
318
406
|
def method(argv: list[str], root: Path) -> int:
|
|
319
407
|
"""`gctl method <command>`: run one of the method's commands on the repo's company's service.
|
|
320
408
|
|
|
@@ -342,7 +430,7 @@ def method(argv: list[str], root: Path) -> int:
|
|
|
342
430
|
return 1
|
|
343
431
|
top = hooks._git(str(root), "rev-parse", "--show-toplevel") or str(root)
|
|
344
432
|
try:
|
|
345
|
-
queue.flush(_service_for, found.service.address)
|
|
433
|
+
queue.flush(_service_for, found.service.address, TIMEOUT)
|
|
346
434
|
done = found.service.method(found.repo, argv, str(root), claude.hooks_off(top))
|
|
347
435
|
except ServiceError as e:
|
|
348
436
|
print(f"refused: {found.service.address}: {e.detail}", file=sys.stderr)
|
|
@@ -373,6 +461,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
373
461
|
commands.add_parser("session-start", help="what the plugin's hook runs as a session starts")
|
|
374
462
|
hooked = commands.add_parser("hook", help="what the plugin's hooks run")
|
|
375
463
|
hooked.add_argument("event", choices=HOOKS)
|
|
464
|
+
hooked.add_argument(
|
|
465
|
+
"--wait", type=float, default=0, help="how long to go on asking, as the plugin gives it"
|
|
466
|
+
)
|
|
376
467
|
method_p = commands.add_parser(
|
|
377
468
|
"method", help="run one of the method's commands", add_help=False
|
|
378
469
|
)
|
|
@@ -381,6 +472,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
381
472
|
down_p = commands.add_parser("down", help="take down what sessions brought up under a folder")
|
|
382
473
|
down_p.add_argument("folder")
|
|
383
474
|
commands.add_parser("repo", help="say which company the repo here is of")
|
|
475
|
+
listed = commands.add_parser(
|
|
476
|
+
"machines", help="the company's machines, and whether the plugin is locked on in each"
|
|
477
|
+
)
|
|
478
|
+
listed.add_argument("--unlocked", action="store_true", help="only those not locked")
|
|
479
|
+
commands.add_parser("policy", help="the managed-settings policy for the company's IT")
|
|
384
480
|
commands.add_parser("remove", help="remove it all from this machine")
|
|
385
481
|
saved = commands.add_parser("store", help="save the plugin's store as an archive")
|
|
386
482
|
saved.add_argument("file")
|
|
@@ -400,12 +496,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
400
496
|
print(json.dumps({"systemMessage": " ".join(lines)}))
|
|
401
497
|
return 0
|
|
402
498
|
if args.command == "hook":
|
|
403
|
-
code, out = hooks.run(args.event, _event())
|
|
499
|
+
code, out = hooks.run(args.event, _event(), args.wait)
|
|
404
500
|
if out:
|
|
405
501
|
print(out)
|
|
406
502
|
return code
|
|
407
503
|
if args.command == "send":
|
|
408
|
-
queue.send_until_empty(_service_for)
|
|
504
|
+
queue.send_until_empty(_service_for, tell=_notify)
|
|
409
505
|
return 0
|
|
410
506
|
if args.command == "down":
|
|
411
507
|
health.down_all(args.folder)
|
|
@@ -414,6 +510,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
414
510
|
return repo(Path.cwd())
|
|
415
511
|
if args.command == "remove":
|
|
416
512
|
return remove()
|
|
513
|
+
if args.command == "machines":
|
|
514
|
+
return machines(Path.cwd(), args.unlocked)
|
|
515
|
+
if args.command == "policy":
|
|
516
|
+
return policy(Path.cwd())
|
|
417
517
|
return save_store(args.file)
|
|
418
518
|
except KeyboardInterrupt:
|
|
419
519
|
say("Stopped.")
|