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.
Files changed (33) hide show
  1. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/PKG-INFO +1 -1
  2. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/claude.py +96 -10
  3. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/cli.py +106 -6
  4. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/hooks.py +148 -83
  5. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/machine.py +10 -2
  6. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/__init__.py +2 -0
  7. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/git.py +63 -0
  8. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/pyproject.toml +1 -1
  9. ground_control_cli-0.4.0/queue.py +556 -0
  10. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/service.py +44 -7
  11. ground_control_cli-0.3.0/queue.py +0 -328
  12. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/.gitignore +0 -0
  13. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/__init__.py +0 -0
  14. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/actions.py +0 -0
  15. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/connect.py +0 -0
  16. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/code/__init__.py +0 -0
  17. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/code/bash.py +0 -0
  18. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/code/javascript.js +0 -0
  19. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/code/javascript.py +0 -0
  20. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/code/python.py +0 -0
  21. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/deploy.py +0 -0
  22. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/errors.py +0 -0
  23. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/gh.py +0 -0
  24. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/health.py +0 -0
  25. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/request.py +0 -0
  26. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/settings.py +0 -0
  27. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/tests.py +0 -0
  28. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/probes/workspace.py +0 -0
  29. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/repos.py +0 -0
  30. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/system/__init__.py +0 -0
  31. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/system/macos.py +0 -0
  32. {ground_control_cli-0.3.0 → ground_control_cli-0.4.0}/tools.py +0 -0
  33. {ground_control_cli-0.3.0 → ground_control_cli-0.4.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.4.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,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.")