Files
stem-launcher/rvctl.py
T
2026-04-26 20:02:43 +02:00

1577 lines
58 KiB
Python

#!/usr/bin/env python3
"""rvctl command-line implementation."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import re
import shlex
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import quote, unquote, urlsplit, urlunsplit
def expand_path(raw_path: str, base_dir: Path) -> Path:
expanded = os.path.expandvars(os.path.expanduser(raw_path))
path = Path(expanded)
if not path.is_absolute():
path = base_dir / path
return path.resolve()
@dataclass
class GitConfig:
base_url: str
source_org: str
answer_org: str
source_remote: str
answer_remote: str
origin_remote: str
fallback_branch: str
@dataclass
class WorkspaceConfig:
config_path: Path
original_root: Path
original_series_root: Path
workspace_root: Path
series_root: Path
socket_root: Path
token_path: Path
tools_root_candidates: list[Path]
tools_root: Path
defaults: dict[str, str]
git: GitConfig
def load_config(config_path: Path) -> WorkspaceConfig:
raw = json.loads(config_path.read_text(encoding="utf-8"))
base_dir = config_path.parent
original_root = expand_path(raw.get("original_root", "~/dev/edu/repos/rv"), base_dir)
workspace_root = expand_path(raw.get("workspace_root", "~/dev/workspace/rv"), base_dir)
original_series_root = expand_path(raw.get("original_series_root", str(original_root / "series")), base_dir)
series_root = expand_path(raw.get("series_root", str(workspace_root / "series")), base_dir)
socket_root = expand_path(raw.get("socket_root", str(workspace_root / "sockets")), base_dir)
token_path = expand_path(raw.get("token_file", str(workspace_root / "tokens" / "tokens.json")), base_dir)
candidate_values = raw.get("tools_root_candidates")
if not candidate_values:
candidate_values = [
raw.get("tools_root", str(workspace_root / "tools" / "rv32i-hazard3-env")),
str(original_root / "rv32i-hazard3-env"),
]
tools_root_candidates = [expand_path(value, base_dir) for value in candidate_values]
tools_root = next((path for path in tools_root_candidates if path.exists()), tools_root_candidates[0])
defaults = raw.get("defaults", {})
git_raw = raw.get("git", {})
git = GitConfig(
base_url=git_raw.get("base_url", "http://77.90.8.171:3001").rstrip("/"),
source_org=git_raw.get("source_org", "edu-inf"),
answer_org=git_raw.get("answer_org", "zsl-inf"),
source_remote=git_raw.get("source_remote", "r1"),
answer_remote=git_raw.get("answer_remote", "a1"),
origin_remote=git_raw.get("origin_remote", "origin"),
fallback_branch=git_raw.get("fallback_branch", "build"),
)
return WorkspaceConfig(
config_path=config_path,
original_root=original_root,
original_series_root=original_series_root,
workspace_root=workspace_root,
series_root=series_root,
socket_root=socket_root,
token_path=token_path,
tools_root_candidates=tools_root_candidates,
tools_root=tools_root,
defaults=defaults,
git=git,
)
def series_directories(config: WorkspaceConfig) -> list[Path]:
if not config.series_root.is_dir():
raise SystemExit(f"Missing series root: {config.series_root}")
return sorted(path for path in config.series_root.iterdir() if path.is_dir())
def card_directories(series_path: Path) -> list[Path]:
return sorted(path for path in series_path.iterdir() if path.is_dir())
def read_card_title(card_path: Path) -> str:
readme_path = card_path / "README.md"
if not readme_path.is_file():
return ""
for line in readme_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped.startswith("#"):
return stripped.lstrip("#").strip()
return ""
def resolve_selector(
config: WorkspaceConfig,
series_arg: str | None,
card_arg: str | None,
) -> tuple[str, str]:
default_series = config.defaults.get("series")
default_card = config.defaults.get("card")
if series_arg and "/" in series_arg:
if card_arg:
raise SystemExit("Pass either '<series> <card>' or '<series/card>', not both forms at once.")
series_value, card_value = series_arg.split("/", 1)
return series_value, card_value
if series_arg and not card_arg:
if (config.series_root / series_arg).is_dir():
if not default_card:
raise SystemExit("Missing default card in config.")
return series_arg, default_card
if default_series and (config.series_root / default_series / series_arg).is_dir():
return default_series, series_arg
series_value = series_arg or default_series
card_value = card_arg or default_card
if not series_value or not card_value:
raise SystemExit("Missing series/card selector and no defaults are configured.")
return series_value, card_value
def resolve_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path:
card_path = config.series_root / series_name / card_name
if not card_path.is_dir():
raise SystemExit(f"Missing card path: {card_path}")
return card_path
def print_series(config: WorkspaceConfig) -> None:
for series_path in series_directories(config):
card_count = len(card_directories(series_path))
print(f"{series_path.name}\t{card_count}\t{series_path}")
def print_cards(config: WorkspaceConfig, series_arg: str | None) -> None:
series_name = series_arg or config.defaults.get("series")
if not series_name:
raise SystemExit("Missing series name and no default series is configured.")
series_path = config.series_root / series_name
if not series_path.is_dir():
raise SystemExit(f"Missing series path: {series_path}")
for card_path in card_directories(series_path):
title = read_card_title(card_path)
if title:
print(f"{card_path.name}\t{title}\t{card_path}")
else:
print(f"{card_path.name}\t{card_path}")
def slug_value(raw_value: str, field_name: str) -> str:
slug = re.sub(r"[^a-z0-9._-]+", "-", raw_value.strip().lower()).strip("-")
if not slug:
raise SystemExit(f"Empty {field_name} after slug conversion: {raw_value!r}")
return slug
def git_capture(repo_path: Path, git_args: list[str]) -> str | None:
result = subprocess.run(
["git", "-C", str(repo_path)] + git_args,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
check=False,
)
if result.returncode != 0:
return None
return result.stdout.strip()
def git_repo_root(repo_path: Path) -> Path | None:
repo_root = git_capture(repo_path, ["rev-parse", "--show-toplevel"])
if not repo_root:
return None
return Path(repo_root)
def default_port_for_scheme(scheme: str) -> int | None:
if scheme == "http":
return 80
if scheme == "https":
return 443
return None
def endpoint_from_split(split_url) -> tuple[str, int | None]:
host_name = split_url.hostname
if not host_name:
raise SystemExit(f"Missing hostname in URL: {split_url.geturl()}")
port_value = split_url.port or default_port_for_scheme(split_url.scheme)
default_port = default_port_for_scheme(split_url.scheme)
if port_value is not None and port_value != default_port:
endpoint = f"{split_url.scheme}://{host_name}:{port_value}"
else:
endpoint = f"{split_url.scheme}://{host_name}"
return endpoint, port_value
def server_type_from_split(config: WorkspaceConfig, split_url) -> str:
endpoint, _ = endpoint_from_split(split_url)
base_split = urlsplit(config.git.base_url)
base_endpoint, _ = endpoint_from_split(base_split)
if endpoint == base_endpoint:
return "gitea"
host_name = (split_url.hostname or "").lower()
if host_name == "github.com" or host_name.endswith(".github.com"):
return "github"
if "gitlab" in host_name:
return "gitlab"
if "gitea" in host_name:
return "gitea"
return "unknown"
def server_info_from_url(config: WorkspaceConfig, remote_url: str) -> dict | None:
split_url = urlsplit(remote_url)
if split_url.scheme not in {"http", "https"}:
return None
if split_url.hostname is None:
return None
endpoint, port_value = endpoint_from_split(split_url)
return {
"endpoint": endpoint,
"type": server_type_from_split(config, split_url),
"scheme": split_url.scheme,
"host": split_url.hostname,
"port": port_value,
}
def empty_token_store() -> dict:
return {"version": 2, "servers": {}}
def next_token_name(tokens: dict[str, str]) -> str:
index = 1
while f"t{index}" in tokens:
index += 1
return f"t{index}"
def ensure_server_entry(token_data: dict, server_info: dict) -> dict:
servers = token_data.setdefault("servers", {})
endpoint = server_info["endpoint"]
server_entry = servers.setdefault(
endpoint,
{
"type": server_info["type"],
"scheme": server_info["scheme"],
"host": server_info["host"],
"port": server_info["port"],
"users": {},
},
)
server_entry.setdefault("users", {})
for field_name in ["type", "scheme", "host", "port"]:
if field_name not in server_entry or server_entry[field_name] in {"", None, "unknown"}:
server_entry[field_name] = server_info[field_name]
return server_entry
def copy_legacy_users(token_data: dict, server_info: dict, raw_users: dict) -> dict:
server_entry = ensure_server_entry(token_data, server_info)
server_users = server_entry.setdefault("users", {})
for user_name, user_entry in raw_users.items():
if not isinstance(user_entry, dict):
continue
raw_tokens = user_entry.get("tokens", {})
if not isinstance(raw_tokens, dict):
continue
target_user = server_users.setdefault(str(user_name), {"tokens": {}})
target_tokens = target_user.setdefault("tokens", {})
for token_name, token_value in raw_tokens.items():
if not isinstance(token_value, str) or not token_value:
continue
if token_value in target_tokens.values():
continue
normalized_name = str(token_name)
if normalized_name in target_tokens and target_tokens[normalized_name] != token_value:
normalized_name = next_token_name(target_tokens)
target_tokens[normalized_name] = token_value
return token_data
def normalize_token_store(config: WorkspaceConfig, raw_data: dict) -> dict:
if "servers" in raw_data and isinstance(raw_data["servers"], dict):
token_data = empty_token_store()
token_data["version"] = raw_data.get("version", 2)
for endpoint, server_entry in raw_data["servers"].items():
if not isinstance(server_entry, dict):
continue
base_server_info = {
"endpoint": str(endpoint),
"type": server_entry.get("type", "unknown"),
"scheme": server_entry.get("scheme", ""),
"host": server_entry.get("host", ""),
"port": server_entry.get("port"),
}
target_server = ensure_server_entry(token_data, base_server_info)
copy_legacy_users(token_data, base_server_info, server_entry.get("users", {}))
target_server["users"] = token_data["servers"][str(endpoint)]["users"]
return token_data
token_data = empty_token_store()
if "users" in raw_data and isinstance(raw_data["users"], dict):
base_server_info = server_info_from_url(config, config.git.base_url)
if base_server_info is None:
raise SystemExit(f"Invalid git base URL: {config.git.base_url}")
copy_legacy_users(token_data, base_server_info, raw_data["users"])
return token_data
def load_token_store(config: WorkspaceConfig) -> dict:
if not config.token_path.exists():
return empty_token_store()
raw_data = json.loads(config.token_path.read_text(encoding="utf-8"))
if not isinstance(raw_data, dict):
raise SystemExit(f"Invalid token store format: {config.token_path}")
normalized_data = normalize_token_store(config, raw_data)
if raw_data != normalized_data:
write_token_store(config, normalized_data)
return normalized_data
def write_token_store(config: WorkspaceConfig, token_data: dict) -> None:
config.token_path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(config.token_path.parent, 0o700)
temp_path = config.token_path.with_suffix(config.token_path.suffix + ".tmp")
temp_path.write_text(json.dumps(token_data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
os.chmod(temp_path, 0o600)
temp_path.replace(config.token_path)
os.chmod(config.token_path, 0o600)
def register_token(token_data: dict, server_info: dict, user_name: str, token_value: str) -> bool:
server_entry = ensure_server_entry(token_data, server_info)
users = server_entry.setdefault("users", {})
user_entry = users.setdefault(user_name, {"tokens": {}})
tokens = user_entry.setdefault("tokens", {})
for existing_token in tokens.values():
if existing_token == token_value:
return False
tokens[next_token_name(tokens)] = token_value
return True
def remote_urls(repo_path: Path) -> list[str]:
return [remote_url for _, remote_url in remote_name_urls(repo_path)]
def remote_name_urls(repo_path: Path) -> list[tuple[str, str]]:
remote_output = git_capture(repo_path, ["remote"])
if not remote_output:
return []
urls: list[tuple[str, str]] = []
seen: set[tuple[str, str]] = set()
for remote_name in remote_output.splitlines():
url_output = git_capture(repo_path, ["remote", "get-url", "--all", remote_name])
if not url_output:
continue
for remote_url in url_output.splitlines():
key = (remote_name, remote_url)
if key in seen:
continue
seen.add(key)
urls.append(key)
return urls
def remote_credentials(remote_url: str) -> tuple[str, str] | None:
split_url = urlsplit(remote_url)
if split_url.scheme not in {"http", "https"}:
return None
if split_url.username is None or split_url.password is None:
return None
return unquote(split_url.username), unquote(split_url.password)
def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict:
repo_root = git_repo_root(repo_path)
if repo_root is None:
return {
"repo_root": None,
"remotes": 0,
"urls": 0,
"scanned": 0,
"auth_urls": 0,
"plain_urls": 0,
"unsupported_urls": 0,
"found": 0,
"added": 0,
"servers": 0,
"remote_rows": [],
"changed": False,
}
found_credentials: list[tuple[int, dict, str, str]] = []
remote_entries = remote_name_urls(repo_root)
remote_names = {remote_name for remote_name, _ in remote_entries}
touched_servers: set[str] = set()
remote_rows: list[dict[str, str]] = []
auth_urls = 0
plain_urls = 0
unsupported_urls = 0
for remote_name, remote_url in remote_entries:
server_info = server_info_from_url(config, remote_url)
if server_info is None:
unsupported_urls += 1
remote_rows.append(
{
"remote": remote_name,
"endpoint": "",
"type": "unsupported",
"url_kind": "unsupported",
"user": "",
"result": "ignored",
}
)
continue
touched_servers.add(server_info["endpoint"])
credentials = remote_credentials(remote_url)
if credentials is None:
plain_urls += 1
remote_rows.append(
{
"remote": remote_name,
"endpoint": server_info["endpoint"],
"type": server_info["type"],
"url_kind": "plain",
"user": "",
"result": "no_credentials",
}
)
continue
auth_urls += 1
user_name, token_value = credentials
row_index = len(remote_rows)
remote_rows.append(
{
"remote": remote_name,
"endpoint": server_info["endpoint"],
"type": server_info["type"],
"url_kind": "auth",
"user": user_name,
"result": "found",
}
)
found_credentials.append((row_index, server_info, user_name, token_value))
token_data = load_token_store(config)
added = 0
for row_index, server_info, user_name, token_value in found_credentials:
if register_token(token_data, server_info, user_name, token_value):
added += 1
remote_rows[row_index]["result"] = "added"
else:
remote_rows[row_index]["result"] = "existing"
if found_credentials and (added or not config.token_path.exists()):
write_token_store(config, token_data)
return {
"repo_root": repo_root,
"remotes": len(remote_names),
"urls": len(remote_entries),
"scanned": auth_urls + plain_urls,
"auth_urls": auth_urls,
"plain_urls": plain_urls,
"unsupported_urls": unsupported_urls,
"found": len(found_credentials),
"added": added,
"servers": len(touched_servers),
"remote_rows": remote_rows,
"changed": added > 0,
}
def count_server_tokens(server_entry: dict) -> int:
return sum(len(user_entry.get("tokens", {})) for user_entry in server_entry.get("users", {}).values())
def mask_secret(secret_value: str) -> str:
if len(secret_value) <= 4:
return "*" * len(secret_value)
return f"{secret_value[:2]}{'*' * (len(secret_value) - 4)}{secret_value[-2:]}"
def token_store_servers(token_data: dict) -> list[tuple[str, dict]]:
servers = token_data.get("servers", {})
return sorted(servers.items())
def token_pairs_from_users(users_data: dict) -> set[tuple[str, str]]:
token_pairs: set[tuple[str, str]] = set()
for user_name, user_entry in users_data.items():
if not isinstance(user_entry, dict):
continue
for token_value in user_entry.get("tokens", {}).values():
if isinstance(token_value, str):
token_pairs.add((str(user_name), token_value))
return token_pairs
def count_users(users_data: dict) -> int:
return len(users_data)
def collect_repo_server_entries(config: WorkspaceConfig, repo_path: Path) -> tuple[Path | None, dict[str, dict]]:
repo_root = git_repo_root(repo_path)
if repo_root is None:
return None, {}
repo_servers: dict[str, dict] = {}
for remote_name, remote_url in remote_name_urls(repo_root):
server_info = server_info_from_url(config, remote_url)
if server_info is None:
continue
endpoint = server_info["endpoint"]
entry = repo_servers.setdefault(
endpoint,
{
"type": server_info["type"],
"scheme": server_info["scheme"],
"host": server_info["host"],
"port": server_info["port"],
"remotes": set(),
"urls": 0,
"auth_urls": 0,
"plain_urls": 0,
"users": {},
},
)
entry["remotes"].add(remote_name)
entry["urls"] += 1
for field_name in ["type", "scheme", "host", "port"]:
if field_name not in entry or entry[field_name] in {"", None, "unknown"}:
entry[field_name] = server_info[field_name]
credentials = remote_credentials(remote_url)
if credentials is None:
entry["plain_urls"] += 1
continue
entry["auth_urls"] += 1
user_name, token_value = credentials
user_entry = entry["users"].setdefault(user_name, {"tokens": {}})
tokens = user_entry.setdefault("tokens", {})
if token_value not in tokens.values():
tokens[next_token_name(tokens)] = token_value
return repo_root, repo_servers
def compare_server_entries(repo_entry: dict | None, store_entry: dict | None) -> tuple[str, int, int]:
if repo_entry is None and store_entry is None:
return "missing", 0, 0
if repo_entry is not None and store_entry is None:
return "repo_only", count_server_tokens(repo_entry), 0
if repo_entry is None and store_entry is not None:
return "store_only", 0, count_server_tokens(store_entry)
repo_pairs = token_pairs_from_users(repo_entry.get("users", {}))
store_pairs = token_pairs_from_users(store_entry.get("users", {}))
repo_only_pairs = repo_pairs - store_pairs
store_only_pairs = store_pairs - repo_pairs
if not repo_only_pairs and not store_only_pairs:
return "in_sync", 0, 0
if repo_only_pairs and not store_only_pairs:
return "repo_ahead", len(repo_only_pairs), 0
if store_only_pairs and not repo_only_pairs:
return "store_ahead", 0, len(store_only_pairs)
return "diverged", len(repo_only_pairs), len(store_only_pairs)
def token_name_rows(users_data: dict) -> list[tuple[str, str]]:
rows: list[tuple[str, str]] = []
for user_name in sorted(users_data):
user_entry = users_data[user_name]
if not isinstance(user_entry, dict):
continue
tokens = user_entry.get("tokens", {})
if not tokens:
rows.append((str(user_name), ""))
continue
for token_name in sorted(tokens):
rows.append((str(user_name), str(token_name)))
return rows
def repo_result_for_entry(repo_entry: dict | None) -> tuple[str, str]:
if repo_entry is None:
return "missing", "missing"
if repo_entry.get("auth_urls", 0):
return "auth", "present"
if repo_entry.get("plain_urls", 0):
return "plain", "no_credentials"
return "empty", "no_credentials"
def print_token_context(
repo_root: Path | None,
config: WorkspaceConfig,
repo_servers: dict[str, dict],
store_servers: dict[str, dict],
endpoint_count: int,
) -> None:
print("context")
print("item\tvalue")
print(f"repo_root\t{repo_root or ''}")
print(f"token_path\t{config.token_path}")
print(f"repo_endpoints\t{len(repo_servers)}")
print(f"tokens_endpoints\t{len(store_servers)}")
print(f"items\t{endpoint_count}")
def print_status_counts(status_counts: dict[str, int]) -> None:
print("status")
print("item\tvalue")
for status_name in ["in_sync", "repo_only", "store_only", "repo_ahead", "store_ahead", "diverged"]:
print(f"{status_name}\t{status_counts[status_name]}")
TOKEN_ITEM_COLUMNS = [
("item", 4),
("source", 11),
("id", 10),
("credential", 10),
("user", 10),
("has_token", 9),
("sync", 5),
("endpoint", 32),
]
def fit_cell(value: str, width: int) -> str:
if len(value) <= width:
return value.ljust(width)
if width <= 3:
return value[:width]
return f"{value[:width - 3]}..."
def print_token_item_table(rows: list[dict[str, str]]) -> None:
print("items")
print(" ".join(fit_cell(name, width) for name, width in TOKEN_ITEM_COLUMNS))
print(" ".join("-" * width for _, width in TOKEN_ITEM_COLUMNS))
for row in rows:
print(" ".join(fit_cell(row.get(name, ""), width) for name, width in TOKEN_ITEM_COLUMNS))
def sync_flag(status_name: str) -> str:
return "yes" if status_name == "in_sync" else "no"
def print_token_item_rows(
endpoint_names: list[str],
repo_servers: dict[str, dict],
store_servers: dict[str, dict],
scan_rows: list[dict[str, str]] | None = None,
) -> dict[str, int]:
status_names = ["in_sync", "repo_only", "store_only", "repo_ahead", "store_ahead", "diverged"]
status_counts = {status_name: 0 for status_name in status_names}
scan_rows_by_endpoint: dict[str, list[dict[str, str]]] = {}
for row in scan_rows or []:
endpoint = row.get("endpoint", "")
if endpoint:
scan_rows_by_endpoint.setdefault(endpoint, []).append(row)
item_rows: list[dict[str, str]] = []
for index, endpoint in enumerate(endpoint_names, start=1):
item_id = str(index)
repo_entry = repo_servers.get(endpoint)
store_entry = store_servers.get(endpoint)
status_name, _, _ = compare_server_entries(repo_entry, store_entry)
if status_name in status_counts:
status_counts[status_name] += 1
repo_rows = scan_rows_by_endpoint.get(endpoint, [])
if repo_rows:
for row in repo_rows:
token_label = "remote" if row["url_kind"] == "auth" else ""
item_rows.append(
{
"item": item_id,
"source": "repo",
"id": row["remote"],
"credential": row["url_kind"],
"user": row["user"],
"has_token": "yes" if token_label else "no",
"sync": sync_flag(status_name),
"endpoint": endpoint,
}
)
elif repo_entry is None:
item_rows.append(
{
"item": item_id,
"source": "repo",
"credential": "missing",
"has_token": "no",
"sync": sync_flag(status_name),
"endpoint": endpoint,
}
)
else:
repo_kind, repo_result = repo_result_for_entry(repo_entry)
remote_names = ",".join(sorted(repo_entry.get("remotes", set())))
repo_users = token_name_rows(repo_entry.get("users", {}))
if repo_users:
for user_name, _ in repo_users:
token_label = "remote" if repo_kind == "auth" else ""
item_rows.append(
{
"item": item_id,
"source": "repo",
"id": remote_names,
"credential": repo_kind,
"user": user_name,
"has_token": "yes" if token_label else "no",
"sync": sync_flag(status_name),
"endpoint": endpoint,
}
)
else:
item_rows.append(
{
"item": item_id,
"source": "repo",
"id": remote_names,
"credential": repo_kind,
"has_token": "yes" if repo_result == "present" else "no",
"sync": sync_flag(status_name),
"endpoint": endpoint,
}
)
if store_entry is None:
item_rows.append(
{
"item": item_id,
"source": "tokens.json",
"credential": "missing",
"has_token": "no",
"sync": sync_flag(status_name),
"endpoint": endpoint,
}
)
continue
store_users = token_name_rows(store_entry.get("users", {}))
if not store_users:
item_rows.append(
{
"item": item_id,
"source": "tokens.json",
"credential": "store",
"has_token": "no",
"sync": sync_flag(status_name),
"endpoint": endpoint,
}
)
continue
for user_name, token_name in store_users:
item_rows.append(
{
"item": item_id,
"source": "tokens.json",
"credential": "store",
"user": user_name,
"id": token_name,
"has_token": "yes" if token_name else "no",
"sync": sync_flag(status_name),
"endpoint": endpoint,
}
)
print_token_item_table(item_rows)
return status_counts
def resolve_server_from_store(token_data: dict, endpoint: str | None) -> tuple[str, dict]:
servers = token_data.get("servers", {})
if endpoint:
server_entry = servers.get(endpoint)
if server_entry is None:
raise SystemExit(f"Missing server endpoint in token store: {endpoint}")
return endpoint, server_entry
if not servers:
raise SystemExit("Token store is empty.")
if len(servers) > 1:
available = ", ".join(sorted(servers))
raise SystemExit(f"Multiple servers in token store. Pass --server. Available: {available}")
endpoint_value = next(iter(sorted(servers)))
return endpoint_value, servers[endpoint_value]
def resolve_user_from_server(server_entry: dict, user_name: str | None) -> tuple[str, dict]:
users = server_entry.get("users", {})
if user_name:
user_entry = users.get(user_name)
if user_entry is None:
raise SystemExit(f"Missing user in token store: {user_name}")
return user_name, user_entry
if not users:
raise SystemExit("No users found in the selected server entry.")
if len(users) > 1:
available = ", ".join(sorted(users))
raise SystemExit(f"Multiple users in selected server entry. Pass --user. Available: {available}")
user_value = next(iter(sorted(users)))
return user_value, users[user_value]
def resolve_token_from_user(user_entry: dict, token_name: str | None) -> tuple[str, str]:
tokens = user_entry.get("tokens", {})
if token_name:
token_value = tokens.get(token_name)
if token_value is None:
raise SystemExit(f"Missing token name in token store: {token_name}")
return token_name, token_value
if not tokens:
raise SystemExit("No tokens found for the selected user.")
if len(tokens) > 1:
available = ", ".join(sorted(tokens))
raise SystemExit(f"Multiple tokens for the selected user. Pass --token-name. Available: {available}")
token_key = next(iter(sorted(tokens)))
return token_key, tokens[token_key]
def sanitize_remote_url(remote_url: str) -> tuple[str, bool]:
split_url = urlsplit(remote_url)
if split_url.scheme not in {"http", "https"} or split_url.hostname is None:
raise SystemExit(f"Only http/https remote URLs are supported: {remote_url}")
_, port_value = endpoint_from_split(split_url)
default_port = default_port_for_scheme(split_url.scheme)
netloc = split_url.hostname
if port_value is not None and port_value != default_port:
netloc = f"{netloc}:{port_value}"
had_credentials = split_url.username is not None or split_url.password is not None
sanitized_url = urlunsplit((split_url.scheme, netloc, split_url.path, split_url.query, split_url.fragment))
return sanitized_url, had_credentials
def credentialed_remote_url(remote_url: str, user_name: str, token_value: str) -> str:
sanitized_url, _ = sanitize_remote_url(remote_url)
split_url = urlsplit(sanitized_url)
_, port_value = endpoint_from_split(split_url)
default_port = default_port_for_scheme(split_url.scheme)
host_part = split_url.hostname or ""
if port_value is not None and port_value != default_port:
host_part = f"{host_part}:{port_value}"
netloc = f"{quote(user_name, safe='')}:{quote(token_value, safe='')}@{host_part}"
return urlunsplit((split_url.scheme, netloc, split_url.path, split_url.query, split_url.fragment))
def resolve_repo_argument(repo_arg: str | None) -> Path:
return Path(repo_arg).expanduser().resolve() if repo_arg else Path.cwd().resolve()
def run_tokens_scan(config: WorkspaceConfig, args: argparse.Namespace) -> None:
repo_path = resolve_repo_argument(args.repo)
report = sync_tokens_from_repo(config, repo_path)
repo_root, repo_servers = collect_repo_server_entries(config, repo_path)
token_data = load_token_store(config)
store_servers = token_data.get("servers", {})
endpoint_names = sorted(set(repo_servers) | set(store_servers))
if args.verbose:
print_token_context(repo_root, config, repo_servers, store_servers, len(endpoint_names))
print()
print("scan")
print("item\tvalue")
print(f"remotes\t{report['remotes']}")
print(f"urls\t{report['urls']}")
print(f"scanned\t{report['scanned']}")
print(f"auth_urls\t{report['auth_urls']}")
print(f"plain_urls\t{report['plain_urls']}")
print(f"unsupported_urls\t{report['unsupported_urls']}")
print(f"found\t{report['found']}")
print(f"added\t{report['added']}")
print(f"servers\t{report['servers']}")
print()
print_token_item_rows(endpoint_names, repo_servers, store_servers, report["remote_rows"])
def run_tokens_read(config: WorkspaceConfig, args: argparse.Namespace) -> None:
token_data = load_token_store(config)
servers = token_store_servers(token_data)
if args.server:
server_entry = token_data.get("servers", {}).get(args.server)
if server_entry is None:
raise SystemExit(f"Missing server endpoint in token store: {args.server}")
servers = [(args.server, server_entry)]
if not servers:
print(f"token_path\t{config.token_path}")
print("servers\t0")
return
print(f"token_path\t{config.token_path}")
for index, (endpoint, server_entry) in enumerate(servers):
if index:
print()
print(f"endpoint\t{endpoint}")
print(f"type\t{server_entry.get('type', 'unknown')}")
print(f"scheme\t{server_entry.get('scheme', '')}")
print(f"host\t{server_entry.get('host', '')}")
print(f"port\t{server_entry.get('port', '')}")
print(f"users\t{len(server_entry.get('users', {}))}")
print(f"tokens\t{count_server_tokens(server_entry)}")
for user_name in sorted(server_entry.get("users", {})):
user_entry = server_entry["users"][user_name]
print(f"user\t{user_name}")
for token_name in sorted(user_entry.get("tokens", {})):
token_value = user_entry["tokens"][token_name]
secret_value = token_value if args.show_secrets else mask_secret(token_value)
print(f"token\t{token_name}\t{secret_value}")
def run_tokens_stats(config: WorkspaceConfig, args: argparse.Namespace) -> None:
repo_root, repo_servers = collect_repo_server_entries(config, resolve_repo_argument(args.repo))
token_data = load_token_store(config)
store_servers = token_data.get("servers", {})
endpoint_names = sorted(set(repo_servers) | set(store_servers))
if args.server:
if args.server not in set(endpoint_names):
raise SystemExit(f"Missing server endpoint in repo/store: {args.server}")
endpoint_names = [args.server]
print_token_context(repo_root, config, repo_servers, store_servers, len(endpoint_names))
print()
status_counts = print_token_item_rows(endpoint_names, repo_servers, store_servers)
print()
print_status_counts(status_counts)
def run_tokens_write(config: WorkspaceConfig, args: argparse.Namespace) -> None:
repo_path = resolve_repo_argument(args.repo)
repo_root = git_repo_root(repo_path)
if repo_root is None:
raise SystemExit(f"Missing git repo at path: {repo_path}")
if not args.remote:
raise SystemExit("Pass --remote for tokens write.")
token_data = load_token_store(config)
existing_remote_url = git_capture(repo_root, ["remote", "get-url", args.remote])
target_url = args.url or existing_remote_url
if not target_url:
raise SystemExit("Missing target remote URL. Provide --url or create the remote first.")
target_server_info = server_info_from_url(config, target_url)
if target_server_info is None:
raise SystemExit(f"Only http/https remote URLs are supported: {target_url}")
server_endpoint, server_entry = resolve_server_from_store(token_data, args.server or target_server_info["endpoint"])
user_name, user_entry = resolve_user_from_server(server_entry, args.user)
token_name, token_value = resolve_token_from_user(user_entry, args.token_name)
final_url = credentialed_remote_url(target_url, user_name, token_value)
status = "added" if existing_remote_url is None else "updated"
existing_credentials = remote_credentials(existing_remote_url) if existing_remote_url else None
if existing_credentials == (user_name, token_value):
status = "unchanged"
elif existing_credentials is not None and not args.replace:
raise SystemExit(
f"Remote '{args.remote}' already has different credentials. "
"Pass --replace to overwrite them."
)
print(f"repo_root\t{repo_root}")
print(f"remote\t{args.remote}")
print(f"server\t{server_endpoint}")
print(f"user\t{user_name}")
print(f"token_name\t{token_name}")
print(f"status\t{status}")
print(f"url\t{sanitize_remote_url(final_url)[0]}")
if args.dry_run or status == "unchanged":
return
set_git_remote(repo_root, args.remote, final_url)
def run_tokens_update(config: WorkspaceConfig, args: argparse.Namespace) -> None:
if args.from_source == "remotes":
run_tokens_scan(config, args)
return
if args.from_source == "store":
run_tokens_write(config, args)
return
raise SystemExit(f"Unsupported tokens update source: {args.from_source}")
def git_current_branch(config: WorkspaceConfig, repo_path: Path) -> str:
branch_name = git_capture(repo_path, ["branch", "--show-current"])
if branch_name:
return branch_name
head_ref = git_capture(repo_path, ["symbolic-ref", "--short", f"refs/remotes/{config.git.origin_remote}/HEAD"])
if head_ref and "/" in head_ref:
return head_ref.split("/", 1)[1]
return config.git.fallback_branch
def fallback_source_repo_name(series_name: str, card_name: str) -> str:
return "-".join([slug_value(series_name, "series"), slug_value(card_name, "card")])
def fallback_source_url(config: WorkspaceConfig, series_name: str, card_name: str) -> str:
repo_name = fallback_source_repo_name(series_name, card_name)
return f"{config.git.base_url}/{config.git.source_org}/{repo_name}.git"
def discover_source_url(config: WorkspaceConfig, repo_path: Path, series_name: str, card_name: str) -> str:
source_url = git_capture(repo_path, ["remote", "get-url", config.git.origin_remote])
if source_url:
return source_url
source_url = git_capture(repo_path, ["remote", "get-url", config.git.source_remote])
if source_url:
return source_url
return fallback_source_url(config, series_name, card_name)
def repo_name_from_url(remote_url: str) -> str:
repo_name = remote_url.rstrip("/").rsplit("/", 1)[-1]
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]
return slug_value(repo_name, "source repo")
def build_answer_repo_name(source_repo_name: str, class_name: str, date_value: str) -> str:
return "-".join([source_repo_name, slug_value(class_name, "class"), date_value])
def build_answer_url(config: WorkspaceConfig, answer_repo_name: str) -> str:
return f"{config.git.base_url}/{config.git.answer_org}/{answer_repo_name}.git"
def set_git_remote(repo_path: Path, remote_name: str, remote_url: str) -> str:
existing_url = git_capture(repo_path, ["remote", "get-url", remote_name])
if existing_url == remote_url:
return "unchanged"
if existing_url:
subprocess.run(
["git", "-C", str(repo_path), "remote", "set-url", remote_name, remote_url],
check=True,
)
return "updated"
subprocess.run(
["git", "-C", str(repo_path), "remote", "add", remote_name, remote_url],
check=True,
)
return "added"
def print_submission_plan(config: WorkspaceConfig, args: argparse.Namespace) -> None:
series_name, card_name = resolve_selector(config, args.series, args.card)
selector = f"{series_name}/{card_name}"
card_path = resolve_card_path(config, series_name, card_name)
sync_tokens_from_repo(config, card_path)
class_name = slug_value(args.class_name, "class")
branch_name = slug_value(args.branch or args.nick, "branch")
source_url = args.source_url or discover_source_url(config, card_path, series_name, card_name)
source_repo_name = repo_name_from_url(source_url)
answer_repo_name = build_answer_repo_name(source_repo_name, class_name, args.date)
answer_url = build_answer_url(config, answer_repo_name)
source_branch = git_current_branch(config, card_path)
print(f"selector\t{selector}")
print(f"card_path\t{card_path}")
print(f"source_repo\t{config.git.source_org}/{source_repo_name}")
print(f"source_remote\t{config.git.source_remote}")
print(f"source_url\t{source_url}")
print(f"source_branch\t{source_branch}")
print(f"answer_remote\t{config.git.answer_remote}")
print(f"answer_repo\t{config.git.answer_org}/{answer_repo_name}")
print(f"answer_url\t{answer_url}")
print(f"student_branch\t{branch_name}")
if args.apply:
source_status = set_git_remote(card_path, config.git.source_remote, source_url)
answer_status = set_git_remote(card_path, config.git.answer_remote, answer_url)
print(f"apply_{config.git.source_remote}\t{source_status}")
print(f"apply_{config.git.answer_remote}\t{answer_status}")
print("commands")
print(f" git -C {shlex.quote(str(card_path))} remote add {config.git.source_remote} {shlex.quote(source_url)}")
print(f" git -C {shlex.quote(str(card_path))} remote add {config.git.answer_remote} {shlex.quote(answer_url)}")
print(f" git -C {shlex.quote(str(card_path))} fetch {config.git.source_remote} {shlex.quote(source_branch)}")
print(f" git -C {shlex.quote(str(card_path))} switch -c {shlex.quote(branch_name)}")
print(f" git -C {shlex.quote(str(card_path))} push -u {config.git.answer_remote} {shlex.quote(branch_name)}")
def tmux_target_exists(session_name: str) -> bool:
result = subprocess.run(
["tmux", "has-session", "-t", session_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return result.returncode == 0
def build_container_command(
config: WorkspaceConfig,
selector: str,
card_path: Path,
instance: str,
) -> str:
tool_root = config.tools_root
rv_script = tool_root / "rv"
if not rv_script.is_file():
candidates = "\n".join(f" - {path}" for path in config.tools_root_candidates)
raise SystemExit(f"Missing rv launcher. Checked:\n{candidates}")
env_parts = [
f"WORKSPACE_ROOT={shlex.quote(str(config.workspace_root))}",
f"RV_REPO_PATH={shlex.quote(str(card_path))}",
f"RV_CARD={shlex.quote(selector)}",
f"RV_INSTANCE={shlex.quote(instance)}",
]
return " && ".join(
[
f"cd {shlex.quote(str(tool_root))}",
" ".join(env_parts + ["bash", "./rv", "shell"]),
]
)
def run_tmux_container(config: WorkspaceConfig, args: argparse.Namespace) -> None:
series_name, card_name = resolve_selector(config, args.series, args.card)
selector = f"{series_name}/{card_name}"
card_path = resolve_card_path(config, series_name, card_name)
session_name = args.session or config.defaults.get("tmux_session", "rv-workspace")
window_name = args.window or config.defaults.get("tmux_window", "rv")
instance = args.instance or config.defaults.get("instance", "shell")
if tmux_target_exists(session_name):
raise SystemExit(
f"tmux session already exists: {session_name}\n"
"Use another --session name or remove the existing session first."
)
shell_command = build_container_command(config, selector, card_path, instance)
tmux_command = [
"tmux",
"new-session",
"-d",
"-s",
session_name,
"-n",
window_name,
f"bash -lc {shlex.quote(shell_command)}",
]
if args.dry_run:
print("Selector:", selector)
print("Card path:", card_path)
print("Tools root:", config.tools_root)
print("Pane 0 command:")
print(shell_command)
print("tmux command:")
print(shlex.join(tmux_command))
return
subprocess.run(tmux_command, check=True)
if args.attach:
subprocess.run(["tmux", "attach", "-t", session_name], check=True)
return
print(f"Created tmux session: {session_name}")
print(f"Window: {window_name}")
print("Pane 0: container shell")
print(f"Attach with: tmux attach -t {session_name}")
def print_config(config: WorkspaceConfig) -> None:
print(f"config_path\t{config.config_path}")
print(f"original_root\t{config.original_root}")
print(f"original_series_root\t{config.original_series_root}")
print(f"workspace_root\t{config.workspace_root}")
print(f"series_root\t{config.series_root}")
print(f"socket_root\t{config.socket_root}")
print(f"token_path\t{config.token_path}")
print(f"tools_root\t{config.tools_root}")
print(f"git_base_url\t{config.git.base_url}")
print(f"git_source_org\t{config.git.source_org}")
print(f"git_answer_org\t{config.git.answer_org}")
print(f"git_source_remote\t{config.git.source_remote}")
print(f"git_answer_remote\t{config.git.answer_remote}")
print(f"git_origin_remote\t{config.git.origin_remote}")
print(f"git_fallback_branch\t{config.git.fallback_branch}")
print("tools_root_candidates")
for path in config.tools_root_candidates:
print(f" {path}")
def add_repo_option(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--repo",
help="Path inside a target git repo. Default: current working directory.",
)
def add_store_selection_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--server", help="Server endpoint from tokens.json.")
parser.add_argument("--user", help="User name inside the selected server entry.")
parser.add_argument("--token-name", help="Token name inside the selected user entry, for example 't1'.")
def print_table(headers: list[str], rows: list[list[str]]) -> None:
widths = [len(header) for header in headers]
for row in rows:
for index, value in enumerate(row):
widths[index] = max(widths[index], len(value))
print(" ".join(header.ljust(widths[index]) for index, header in enumerate(headers)))
print(" ".join("-" * width for width in widths))
for row in rows:
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
def print_config_summary(config_path: Path) -> None:
try:
config = load_config(config_path)
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print("Config:")
print_table(
["field", "value"],
[
["config", str(config_path)],
["status", f"unavailable: {error}"],
],
)
return
print("Config:")
print_table(
["field", "value"],
[
["config", str(config.config_path)],
["workspace", str(config.workspace_root)],
["series", str(config.series_root)],
["tokens", str(config.token_path)],
],
)
def print_main_overview(config_path: Path) -> None:
print("RVCTL - launcher workspace RV")
print()
print("Usage:")
print(" ./rvctl <command> [options]")
print(" ./rvctl tokens <command> [options]")
print(" ./rvctl <command> --help")
print()
print("Commands:")
print_table(
["command", "arguments", "purpose"],
[
["show-config", "", "show resolved paths and Git defaults"],
["list-series", "", "list series from workspace"],
["list-cards", "[series]", "list cards in a selected series"],
["tmux-container", "[series] [card]", "start tmux with container in pane 0"],
["submission", "[series] [card] --class K --nick N", "prepare answer repo and student branch"],
["tokens", "scan|read|stats|write|update", "manage tokens.json and Git remote credentials"],
],
)
print()
print("Typical flow:")
print_table(
["step", "command", "result"],
[
["1", "./rvctl tokens scan", "pair repo remotes with tokens.json entries"],
["2", "./rvctl list-series", "choose a series"],
["3", "./rvctl list-cards inf", "choose a card"],
["4", "./rvctl tmux-container inf <card>", "start the working container"],
["5", "./rvctl submission inf <card> --class 4i --nick u1", "prepare answer remote and branch"],
],
)
print()
print_config_summary(config_path)
print()
print("Docs:")
print(" doc/rvctl.md")
print(" doc/tokens.md")
print(" doc/workspace.md")
def print_tokens_overview() -> None:
print("RVCTL tokens - token store and Git remote credentials")
print()
print("Usage:")
print(" ./rvctl tokens <command> [options]")
print(" ./rvctl tokens <command> --help")
print()
print("Commands:")
print_table(
["command", "common options", "direction", "purpose"],
[
["scan", "[--repo PATH]", "repo + tokens.json", "pair endpoints and show where token refs are stored"],
["read", "[--server ENDPOINT]", "tokens.json", "show servers, users and token names"],
["stats", "[--repo PATH]", "repo + tokens.json", "compare remote URLs with local token store"],
["write", "--remote R [--replace]", "tokens.json -> repo", "write selected token into a remote URL"],
["update", "--from remotes|store", "selected", "run scan or write with explicit direction"],
],
)
print()
print("Examples:")
print_table(
["case", "command"],
[
["scan current repo", "./rvctl tokens scan"],
["scan selected card", "./rvctl tokens scan --repo ~/dev/workspace/rv/series/inf/03"],
["compare state", "./rvctl tokens stats --repo ~/dev/workspace/rv/series/inf/03"],
["write auth to r1", "./rvctl tokens write --repo PATH --remote r1 --server URL --user u1 --token-name t1"],
],
)
def extract_config_path(argv: list[str]) -> tuple[Path, list[str]]:
config_path = Path(__file__).with_name("workspace.json")
remaining: list[str] = []
index = 0
while index < len(argv):
arg = argv[index]
if arg == "--config":
if index + 1 >= len(argv):
remaining.append(arg)
index += 1
continue
config_path = Path(argv[index + 1])
index += 2
continue
if arg.startswith("--config="):
config_path = Path(arg.split("=", 1)[1])
index += 1
continue
remaining.append(arg)
index += 1
return config_path, remaining
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="rvctl",
description="rvctl for rv cards and tools.",
)
parser.add_argument(
"--config",
default=Path(__file__).with_name("workspace.json"),
type=Path,
help="Path to workspace.json",
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("show-config", help="Print resolved workspace paths.")
subparsers.add_parser("list-series", help="List series directories from series_root.")
cards_parser = subparsers.add_parser("list-cards", help="List cards in a series.")
cards_parser.add_argument("series", nargs="?", help="Series id, for example 'inf'.")
tmux_parser = subparsers.add_parser(
"tmux-container",
help="Create a tmux session with pane 0 running the container shell for a selected card.",
)
tmux_parser.add_argument("series", nargs="?", help="Series id or full selector like 'inf/03'.")
tmux_parser.add_argument("card", nargs="?", help="Card id, for example '03'.")
tmux_parser.add_argument("--session", help="tmux session name.")
tmux_parser.add_argument("--window", help="tmux window name.")
tmux_parser.add_argument("--instance", help="RV_INSTANCE passed to the tool launcher.")
tmux_parser.add_argument("--attach", action="store_true", help="Attach to the session after creation.")
tmux_parser.add_argument("--dry-run", action="store_true", help="Print commands instead of running tmux.")
submission_parser = subparsers.add_parser(
"submission",
help="Print or apply source/answer git remotes for a selected card.",
)
submission_parser.add_argument("series", nargs="?", help="Series id or full selector like 'inf/03'.")
submission_parser.add_argument("card", nargs="?", help="Card id, for example '03'.")
submission_parser.add_argument("--class", dest="class_name", required=True, help="Class id, for example '4i'.")
submission_parser.add_argument("--nick", required=True, help="Student nick used as the branch name.")
submission_parser.add_argument(
"--branch",
help="Override the submission branch name. Defaults to the value of --nick.",
)
submission_parser.add_argument(
"--date",
default=dt.date.today().isoformat(),
help="Lesson date used in the answer repo name. Default: today in YYYY-MM-DD.",
)
submission_parser.add_argument(
"--source-url",
help="Override the source repo URL. By default launcher reads the current origin from the card repo.",
)
submission_parser.add_argument(
"--apply",
action="store_true",
help="Write remotes into the selected card repo instead of only printing the plan.",
)
tokens_parser = subparsers.add_parser("tokens", help="Manage local token store and repo remotes.")
tokens_subparsers = tokens_parser.add_subparsers(dest="tokens_command", required=True)
tokens_scan_parser = tokens_subparsers.add_parser(
"scan",
help="Scan repo remotes and tokens.json, then print paired endpoint rows.",
)
add_repo_option(tokens_scan_parser)
tokens_scan_parser.add_argument("--verbose", action="store_true", help="Print context and scan counters before item rows.")
tokens_read_parser = tokens_subparsers.add_parser(
"read",
help="Read tokens.json and print stored servers, users and token names.",
)
tokens_read_parser.add_argument("--server", help="Filter output to one server endpoint.")
tokens_read_parser.add_argument("--show-secrets", action="store_true", help="Print full token values.")
tokens_stats_parser = tokens_subparsers.add_parser(
"stats",
help="Print per-server token statistics from tokens.json.",
)
add_repo_option(tokens_stats_parser)
tokens_stats_parser.add_argument("--server", help="Filter output to one server endpoint.")
tokens_write_parser = tokens_subparsers.add_parser(
"write",
help="Write credentials from tokens.json into a repo remote URL.",
)
add_repo_option(tokens_write_parser)
tokens_write_parser.add_argument("--remote", help="Remote name to update or create.")
tokens_write_parser.add_argument("--url", help="Target remote URL if the remote does not exist yet.")
add_store_selection_options(tokens_write_parser)
tokens_write_parser.add_argument("--replace", action="store_true", help="Replace different credentials in the remote URL.")
tokens_write_parser.add_argument("--dry-run", action="store_true", help="Print the planned update without writing it.")
tokens_update_parser = tokens_subparsers.add_parser(
"update",
help="Synchronize token data in a selected direction.",
)
tokens_update_parser.add_argument(
"--from",
dest="from_source",
required=True,
choices=["remotes", "store"],
help="Synchronization direction source: 'remotes' or 'store'.",
)
add_repo_option(tokens_update_parser)
tokens_update_parser.add_argument("--remote", help="Remote name used when --from store.")
tokens_update_parser.add_argument("--url", help="Target remote URL used when --from store.")
add_store_selection_options(tokens_update_parser)
tokens_update_parser.add_argument("--replace", action="store_true", help="Replace different credentials when writing to remotes.")
tokens_update_parser.add_argument("--dry-run", action="store_true", help="Print the planned update without writing it.")
return parser
def main() -> int:
config_path, command_args = extract_config_path(sys.argv[1:])
if not command_args or command_args == ["help"]:
print_main_overview(config_path)
return 0
if command_args in (["tokens"], ["help", "tokens"], ["tokens", "help"]):
print_tokens_overview()
return 0
parser = build_parser()
args = parser.parse_args()
config = load_config(args.config)
sync_tokens_from_repo(config, Path.cwd())
if args.command == "show-config":
print_config(config)
return 0
if args.command == "list-series":
print_series(config)
return 0
if args.command == "list-cards":
print_cards(config, args.series)
return 0
if args.command == "tmux-container":
run_tmux_container(config, args)
return 0
if args.command == "submission":
print_submission_plan(config, args)
return 0
if args.command == "tokens":
if args.tokens_command == "scan":
run_tokens_scan(config, args)
return 0
if args.tokens_command == "read":
run_tokens_read(config, args)
return 0
if args.tokens_command == "stats":
run_tokens_stats(config, args)
return 0
if args.tokens_command == "write":
run_tokens_write(config, args)
return 0
if args.tokens_command == "update":
run_tokens_update(config, args)
return 0
parser.error(f"Unsupported tokens command: {args.tokens_command}")
return 2
parser.error(f"Unsupported command: {args.command}")
return 2
if __name__ == "__main__":
sys.exit(main())