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.
Files changed (34) hide show
  1. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/PKG-INFO +1 -1
  2. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/claude.py +96 -10
  3. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/cli.py +114 -6
  4. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/hooks.py +148 -83
  5. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/machine.py +10 -2
  6. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/__init__.py +4 -1
  7. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/git.py +63 -0
  8. ground_control_cli-0.5.0/probes/queue.py +31 -0
  9. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/request.py +4 -10
  10. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/pyproject.toml +1 -1
  11. ground_control_cli-0.5.0/queue.py +633 -0
  12. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/service.py +58 -7
  13. ground_control_cli-0.3.0/queue.py +0 -328
  14. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/.gitignore +0 -0
  15. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/__init__.py +0 -0
  16. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/actions.py +0 -0
  17. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/connect.py +0 -0
  18. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/code/__init__.py +0 -0
  19. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/code/bash.py +0 -0
  20. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/code/javascript.js +0 -0
  21. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/code/javascript.py +0 -0
  22. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/code/python.py +0 -0
  23. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/deploy.py +0 -0
  24. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/errors.py +0 -0
  25. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/gh.py +0 -0
  26. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/health.py +0 -0
  27. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/settings.py +0 -0
  28. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/tests.py +0 -0
  29. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/probes/workspace.py +0 -0
  30. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/repos.py +0 -0
  31. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/system/__init__.py +0 -0
  32. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/system/macos.py +0 -0
  33. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/tools.py +0 -0
  34. {ground_control_cli-0.3.0 → ground_control_cli-0.5.0}/update.py +0 -0
@@ -1,5 +1,5 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ground-control-cli
3
- Version: 0.3.0
3
+ Version: 0.5.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
@@ -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 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")
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
- managed = _read(MANAGED_SETTINGS)
339
- if managed.get("allowManagedHooksOnly") is True:
340
- return [str(MANAGED_SETTINGS)]
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 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.
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.")