110 lines
3.7 KiB
Python
Executable file
110 lines
3.7 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""claude-rc-envs — list / inspect / remove Claude Remote Control environments.
|
|
|
|
Account-wide view (the same list the claude.ai/code environment picker shows),
|
|
across every host. Reads the OAuth token from the macOS Keychain (Claude Code)
|
|
or ~/.claude/.credentials.json.
|
|
|
|
Usage:
|
|
claude-rc-envs # table: state, host, project, dir
|
|
claude-rc-envs --json # raw JSON
|
|
claude-rc-envs rm <env_id> # delete an environment (e.g. an orphan)
|
|
claude-rc-envs archive <id> # archive instead of delete
|
|
"""
|
|
import json
|
|
import os
|
|
import platform
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
API = "https://api.anthropic.com/v1/environments"
|
|
BETA = "environments-2025-11-01"
|
|
|
|
|
|
def token() -> str:
|
|
if platform.system() == "Darwin":
|
|
try:
|
|
out = subprocess.run(
|
|
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
if out.returncode == 0 and out.stdout.strip():
|
|
return json.loads(out.stdout)["claudeAiOauth"]["accessToken"]
|
|
except Exception:
|
|
pass
|
|
path = os.path.expanduser("~/.claude/.credentials.json")
|
|
if os.path.exists(path):
|
|
with open(path) as fh:
|
|
return json.load(fh)["claudeAiOauth"]["accessToken"]
|
|
sys.exit("claude-rc-envs: no Claude credentials found (Keychain or ~/.claude/.credentials.json)")
|
|
|
|
|
|
def call(method: str, url: str) -> dict:
|
|
req = urllib.request.Request(url, method=method, headers={
|
|
"Authorization": f"Bearer {token()}",
|
|
"anthropic-beta": BETA,
|
|
"anthropic-version": "2023-06-01",
|
|
"content-type": "application/json",
|
|
})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
body = resp.read()
|
|
return json.loads(body) if body else {}
|
|
except urllib.error.HTTPError as e:
|
|
sys.exit(f"claude-rc-envs: HTTP {e.code} {e.reason}: {e.read().decode()[:200]}")
|
|
except urllib.error.URLError as e:
|
|
sys.exit(f"claude-rc-envs: network error: {e.reason}")
|
|
|
|
|
|
def fetch() -> list:
|
|
return call("GET", API).get("data", [])
|
|
|
|
|
|
def row(e: dict):
|
|
c = e.get("config", {})
|
|
bridge = c.get("type") == "bridge"
|
|
host = c.get("machine_name") or ("cloud" if not bridge else "?")
|
|
name = e.get("name", "")
|
|
project = name.split(":")[1] if bridge and name.count(":") >= 1 else name
|
|
return e.get("state", "?"), host, project, e.get("id", ""), c.get("directory", "")
|
|
|
|
|
|
def list_envs():
|
|
rows = [row(e) for e in fetch()]
|
|
if not rows:
|
|
print("(no environments)")
|
|
return
|
|
wh = max(4, *(len(r[1]) for r in rows))
|
|
wp = max(7, *(len(r[2]) for r in rows))
|
|
print(f"{'STATE':8} {'HOST':{wh}} {'PROJECT':{wp}} {'ENV ID':20} DIR")
|
|
for state, host, project, env_id, directory in rows:
|
|
print(f"{state:8} {host:{wh}} {project:{wp}} {env_id:20} {directory}")
|
|
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
if not args:
|
|
return list_envs()
|
|
cmd = args[0]
|
|
if cmd in ("--json", "json"):
|
|
print(json.dumps(fetch(), indent=2))
|
|
elif cmd in ("rm", "delete", "--rm"):
|
|
if len(args) < 2:
|
|
sys.exit("usage: claude-rc-envs rm <env_id>")
|
|
call("DELETE", f"{API}/{args[1]}")
|
|
print(f"removed {args[1]}")
|
|
elif cmd in ("archive", "--archive"):
|
|
if len(args) < 2:
|
|
sys.exit("usage: claude-rc-envs archive <env_id>")
|
|
call("POST", f"{API}/{args[1]}/archive")
|
|
print(f"archived {args[1]}")
|
|
elif cmd in ("ls", "list", "status", "envs"):
|
|
list_envs()
|
|
else:
|
|
sys.exit(f"claude-rc-envs: unknown command '{cmd}' (try: ls | --json | rm <id> | archive <id>)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|