Files
stem-launcher/rvctl.py
T
2026-07-17 17:07:08 +02:00

6286 lines
252 KiB
Python
Executable File

#!/usr/bin/env python3
"""STEM workspace launcher (``stemctl`` with ``rvctl`` compatibility)."""
from __future__ import annotations
import argparse
import base64
import datetime as dt
import hashlib
import json
import os
import re
import shlex
import shutil
import stat
import subprocess
import sys
import time
from dataclasses import dataclass, replace
from pathlib import Path
from urllib import error as urlerror
from urllib import request as urlrequest
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
workspace_info_path: Path
workspace_info_url: str
workspace_info: dict | None
series_root: Path
socket_root: Path
token_path: Path
tools_root_candidates: list[Path]
tools_root: Path
env_tool_url: str
defaults: dict[str, str]
git: GitConfig
@dataclass(frozen=True)
class TaskEntry:
name: str
path: Path
@dataclass(frozen=True)
class CardInfo:
series_id: str
card_id: str
repo: str
branch: str
title: str
source_org: str
answer_org: str
source_remote: str
answer_remote: str
fallback_branch: str
source_path: Path | None = None
@dataclass(frozen=True)
class SessionTarget:
series: str
card: str
card_path: Path
task: str
profile: str
target: str
instance: str
SCOPE_FIELDS = [
("a", "activitypub"),
("A", "admin"),
("i", "issue"),
("m", "misc"),
("n", "notification"),
("o", "organization"),
("p", "package"),
("r", "repository"),
("u", "user"),
]
SCOPE_CATEGORY_ALIASES = {
"organization": ["organization", "org"],
"repository": ["repository", "repo"],
}
ORG_PERMISSION_FIELDS = [
("o", "owner"),
("a", "admin"),
("w", "write"),
("r", "read"),
("c", "create"),
]
REPO_PERMISSION_FIELDS = [
("o", "owner"),
("a", "admin"),
("w", "write"),
("r", "read"),
]
ENV_PROFILES = {
"native-amd64": {
"service": "native-amd64",
"image": "localhost/stem/dev-native-amd64:local",
"purpose": "native AMD64 C/C++ tests, sanitizers and GDB/LLDB",
},
"hazard3-sim": {
"service": "hazard3-sim",
"image": "localhost/stem/dev-hazard3-sim:local",
"purpose": "Hazard3 RTL, RV32 bare metal/FreeRTOS and GDB",
},
"rp2350": {
"service": "rp2350",
"image": "localhost/stem/dev-rp2350:local",
"purpose": "physical RP2350 RISC-V or ARM build, deploy and debug",
},
}
ENV_PROFILE_ALIASES = {
"host": "native-amd64",
"native": "native-amd64",
"amd64": "native-amd64",
"rv32i": "hazard3-sim",
"riscv": "hazard3-sim",
"riscv32": "hazard3-sim",
"hazard3": "hazard3-sim",
}
ENV_PROFILE_CHOICES = sorted(set(ENV_PROFILES) | set(ENV_PROFILE_ALIASES))
PROFILE_DEFAULT_TARGETS = {
"native-amd64": "native",
"hazard3-sim": "hazard3-baremetal",
"rp2350": "rp2350-rv",
}
PROFILE_TARGETS = {
"native-amd64": frozenset({"native"}),
"hazard3-sim": frozenset({"hazard3-baremetal", "hazard3-freertos"}),
"rp2350": frozenset({"rp2350-rv", "rp2350-arm"}),
}
def default_runtime_socket_root(workspace_root: Path) -> Path:
xdg_runtime = os.environ.get("XDG_RUNTIME_DIR", "").strip()
if xdg_runtime:
return Path(xdg_runtime).expanduser().resolve() / "stem"
run_user = Path(f"/run/user/{os.getuid()}")
if run_user.is_dir():
return run_user / "stem"
return workspace_root / ".runtime" / "stem"
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/stem"), base_dir)
configured_workspace = os.environ.get("STEM_WORKSPACE_ROOT") or raw.get(
"workspace_root", "~/dev/workspace/stem"
)
workspace_root = expand_path(str(configured_workspace), base_dir)
legacy_workspace_root = expand_path(
str(raw.get("legacy_workspace_root", "~/dev/workspace/rv")), base_dir
)
if (
"STEM_WORKSPACE_ROOT" not in os.environ
and not workspace_root.exists()
and legacy_workspace_root.exists()
):
workspace_root = legacy_workspace_root
git_raw = raw.get("git", {})
base_url = git_raw.get("base_url", "http://77.90.8.171:3001").rstrip("/")
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)
configured_socket_root = os.environ.get("STEM_SOCKET_ROOT") or raw.get("socket_root")
socket_root = (
expand_path(str(configured_socket_root), base_dir)
if configured_socket_root
else default_runtime_socket_root(workspace_root)
)
token_path = expand_path(raw.get("token_file", str(workspace_root / "tokens" / "tokens.json")), base_dir)
workspace_info_path = expand_path(
raw.get("workspace_info_path", str(workspace_root / "meta" / "workspace-info" / "workspace.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-student-env")),
str(workspace_root / "tools" / "rv32i-hazard3-env"),
str(Path.home() / "dev" / "workspace" / "stem" / "tools" / "rv32i-hazard3-student-env"),
str(Path.home() / "dev" / "workspace" / "rv" / "tools" / "rv32i-hazard3-student-env"),
str(original_root / "rv32i-hazard3-student-env"),
str(original_root / "rv32i-hazard3-env"),
]
tools_root_candidates = [expand_path(value, base_dir) for value in candidate_values]
adjacent_tools = (base_dir.parent / "rv32i-hazard3-student-env").resolve()
if adjacent_tools.is_dir() and adjacent_tools not in tools_root_candidates:
tools_root_candidates.insert(0, adjacent_tools)
tools_root = next((path for path in tools_root_candidates if path.exists()), tools_root_candidates[0])
env_tool_url = raw.get("env_tool_url", f"{base_url}/edu-tools/rv32i-hazard3-student-env.git")
defaults = raw.get("defaults", {})
workspace_info_url = raw.get("workspace_info_url", f"{base_url}/edu-workspace/workspace-info.git")
git = GitConfig(
base_url=base_url,
source_org=git_raw.get("source_org", "edu-inf"),
answer_org=git_raw.get("answer_org", "c2025-1a-inf"),
source_remote=git_raw.get("source_remote", "r1"),
answer_remote=git_raw.get("answer_remote", "r1a"),
origin_remote=git_raw.get("origin_remote", "origin"),
fallback_branch=git_raw.get("fallback_branch", "build"),
)
workspace_info = json.loads(workspace_info_path.read_text(encoding="utf-8")) if workspace_info_path.is_file() else None
return WorkspaceConfig(
config_path=config_path,
original_root=original_root,
original_series_root=original_series_root,
workspace_root=workspace_root,
workspace_info_path=workspace_info_path,
workspace_info_url=workspace_info_url,
workspace_info=workspace_info,
series_root=series_root,
socket_root=socket_root,
token_path=token_path,
tools_root_candidates=tools_root_candidates,
tools_root=tools_root,
env_tool_url=env_tool_url,
defaults=defaults,
git=git,
)
def write_workspace_config(config: WorkspaceConfig, raw_config: dict) -> None:
config.config_path.parent.mkdir(parents=True, exist_ok=True)
temp_path = config.config_path.with_suffix(config.config_path.suffix + ".tmp")
temp_path.write_text(json.dumps(raw_config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
temp_path.replace(config.config_path)
def update_config_defaults(config: WorkspaceConfig, updates: dict[str, str]) -> dict[str, str]:
raw_config = json.loads(config.config_path.read_text(encoding="utf-8"))
defaults = raw_config.get("defaults")
if not isinstance(defaults, dict):
defaults = {}
raw_config["defaults"] = defaults
result: dict[str, str] = {}
changed = False
for key, value in updates.items():
old_value = str(defaults.get(key, ""))
if old_value != value:
defaults[key] = value
changed = True
config.defaults[key] = value
result[f"old_{key}"] = old_value
result[key] = value
if changed:
write_workspace_config(config, raw_config)
result["status"] = "updated"
else:
result["status"] = "unchanged"
result["config"] = str(config.config_path)
return result
def workspace_info_root(config: WorkspaceConfig) -> Path:
return config.workspace_info_path.parent
def read_workspace_info_file(config: WorkspaceConfig, raw_path: str) -> dict:
info_path = expand_path(raw_path, workspace_info_root(config))
return json.loads(info_path.read_text(encoding="utf-8"))
def workspace_series_manifests(config: WorkspaceConfig) -> dict[str, dict]:
if not config.workspace_info:
return {}
manifests: dict[str, dict] = {}
for entry in config.workspace_info.get("series", []):
if not isinstance(entry, dict):
continue
series_id = str(entry.get("id", "")).strip()
if not series_id:
continue
manifest = dict(entry)
file_name = entry.get("file")
if isinstance(file_name, str) and file_name:
manifest = read_workspace_info_file(config, file_name)
manifest.setdefault("id", series_id)
manifests[series_id] = manifest
return manifests
def workspace_series_manifest(config: WorkspaceConfig, series_name: str) -> dict | None:
return workspace_series_manifests(config).get(series_name)
def series_exists(config: WorkspaceConfig, series_name: str) -> bool:
if workspace_series_manifest(config, series_name) is not None:
return True
return (config.series_root / series_name).is_dir() or (config.original_series_root / series_name).is_dir()
def default_card_for_series(config: WorkspaceConfig, series_name: str) -> str | None:
manifest = workspace_series_manifest(config, series_name)
if manifest and isinstance(manifest.get("default_card"), str):
return manifest["default_card"]
return config.defaults.get("card")
def config_for_card(config: WorkspaceConfig, card_info: CardInfo) -> WorkspaceConfig:
return replace(
config,
git=replace(
config.git,
source_org=card_info.source_org,
answer_org=card_info.answer_org,
source_remote=card_info.source_remote,
answer_remote=card_info.answer_remote,
fallback_branch=card_info.fallback_branch,
),
)
def card_infos_from_manifest(config: WorkspaceConfig, series_name: str) -> list[CardInfo]:
manifest = workspace_series_manifest(config, series_name)
if not manifest:
return []
source_org = str(manifest.get("source_org", config.git.source_org))
answer_org = str(manifest.get("answer_org", config.git.answer_org))
source_remote = str(manifest.get("source_remote", config.git.source_remote))
answer_remote = str(manifest.get("answer_remote", config.git.answer_remote))
fallback_branch = str(manifest.get("fallback_branch", config.git.fallback_branch))
cards = []
for raw_card in manifest.get("cards", []):
if not isinstance(raw_card, dict):
continue
card_id = str(raw_card.get("id", "")).strip()
if not card_id:
continue
repo_name = str(raw_card.get("repo", card_id)).strip()
branch_name = str(raw_card.get("branch", fallback_branch)).strip() or fallback_branch
title = str(raw_card.get("title", "")).strip()
source_path = config.original_series_root / series_name / repo_name
cards.append(
CardInfo(
series_id=series_name,
card_id=card_id,
repo=repo_name,
branch=branch_name,
title=title,
source_org=source_org,
answer_org=answer_org,
source_remote=source_remote,
answer_remote=answer_remote,
fallback_branch=fallback_branch,
source_path=source_path if source_path.is_dir() else None,
)
)
return cards
def card_infos_from_source_dirs(config: WorkspaceConfig, series_name: str) -> list[CardInfo]:
return [
CardInfo(
series_id=series_name,
card_id=card_label_from_repo_name(path.name),
repo=path.name,
branch=git_current_branch(config, path),
title=read_card_title(path),
source_org=config.git.source_org,
answer_org=config.git.answer_org,
source_remote=config.git.source_remote,
answer_remote=config.git.answer_remote,
fallback_branch=config.git.fallback_branch,
source_path=path,
)
for path in source_card_directories(config, series_name)
]
def source_card_infos(config: WorkspaceConfig, series_name: str) -> list[CardInfo]:
manifest_cards = card_infos_from_manifest(config, series_name)
if manifest_cards:
return manifest_cards
return card_infos_from_source_dirs(config, series_name)
def series_directories(config: WorkspaceConfig) -> list[Path]:
config.series_root.mkdir(parents=True, exist_ok=True)
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 source_series_directories(config: WorkspaceConfig) -> list[Path]:
if not config.original_series_root.is_dir():
return []
return sorted(path for path in config.original_series_root.iterdir() if path.is_dir())
def source_card_directories(config: WorkspaceConfig, series_name: str) -> list[Path]:
source_series_path = config.original_series_root / series_name
if not source_series_path.is_dir():
return []
return sorted(path for path in source_series_path.iterdir() if path.is_dir())
def card_label_from_repo_name(repo_name: str) -> str:
if repo_name.startswith("lab-"):
repo_name = repo_name[4:]
marker = "-strlen-"
if marker in repo_name:
return repo_name.split(marker, 1)[1].split("-", 1)[0]
return repo_name
def card_info_matches(card_info: CardInfo, normalized: str) -> bool:
candidates = {card_info.card_id, card_info.repo, card_label_from_repo_name(card_info.repo)}
return normalized in candidates or any(normalized in candidate for candidate in candidates)
def resolve_card_info(config: WorkspaceConfig, series_name: str, card_name: str) -> CardInfo:
cards = source_card_infos(config, series_name)
if not cards:
source_hint = config.original_series_root / series_name
if config.workspace_info_path.is_file():
source_hint = config.workspace_info_path
raise SystemExit(f"Missing source series: {source_hint}")
normalized = slug_value(card_name, "card")
matches = [card_info for card_info in cards if card_info_matches(card_info, normalized)]
if not matches:
available = ", ".join(card.card_id for card in cards)
raise SystemExit(f"Missing card '{card_name}' in series '{series_name}'. Available: {available}")
if len(matches) > 1:
available = ", ".join(card.repo for card in matches)
raise SystemExit(f"Ambiguous card '{card_name}' in series '{series_name}'. Matches: {available}")
return matches[0]
def resolve_source_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path:
manifest_card = resolve_card_info(config, series_name, card_name)
if manifest_card.source_path:
return manifest_card.source_path
return config.original_series_root / series_name / manifest_card.repo
def resolve_source_card_path_from_dirs(config: WorkspaceConfig, series_name: str, card_name: str) -> Path:
cards = source_card_directories(config, series_name)
if not cards:
raise SystemExit(f"Missing source series: {config.original_series_root / series_name}")
normalized = slug_value(card_name, "card")
matches = []
for card_path in cards:
repo_name = card_path.name
label = card_label_from_repo_name(repo_name)
candidates = {repo_name, label}
if normalized in candidates or any(normalized in candidate for candidate in candidates):
matches.append(card_path)
if not matches:
available = ", ".join(card_label_from_repo_name(path.name) for path in cards)
raise SystemExit(f"Missing card '{card_name}' in series '{series_name}'. Available: {available}")
if len(matches) > 1:
available = ", ".join(path.name for path in matches)
raise SystemExit(f"Ambiguous card '{card_name}' in series '{series_name}'. Matches: {available}")
return matches[0]
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 series_exists(config, series_arg):
series_default_card = default_card_for_series(config, series_arg)
if not series_default_card:
raise SystemExit("Missing default card in config.")
return series_arg, series_default_card
if default_series and (config.series_root / default_series / series_arg).is_dir():
return default_series, series_arg
if default_series:
try:
source_card_path = resolve_source_card_path(config, default_series, series_arg)
if workspace_card_path(config, default_series, source_card_path).is_dir():
return default_series, series_arg
except SystemExit:
pass
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 card_path.is_dir():
return card_path
card_info = resolve_card_info(config, series_name, card_name)
resolved_path = workspace_card_path_for_info(config, card_info)
if resolved_path.is_dir():
return resolved_path
raise SystemExit(f"Missing card path: {resolved_path}")
def print_series(config: WorkspaceConfig) -> None:
config.series_root.mkdir(parents=True, exist_ok=True)
manifest_by_name = workspace_series_manifests(config)
source_by_name = {path.name: path for path in source_series_directories(config)}
workspace_by_name = {path.name: path for path in series_directories(config)}
for series_name in sorted(set(manifest_by_name) | set(source_by_name) | set(workspace_by_name)):
manifest = manifest_by_name.get(series_name)
workspace_path = workspace_by_name.get(series_name)
source_count = len(source_card_infos(config, series_name)) if (manifest or series_name in source_by_name) else 0
workspace_count = len(card_directories(workspace_path)) if workspace_path else 0
print(f"{series_name}\t{source_count}\t{workspace_count}\t{workspace_path or ''}")
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.")
config.series_root.mkdir(parents=True, exist_ok=True)
source_cards = {card.repo: card for card in source_card_infos(config, series_name)}
workspace_series_path = config.series_root / series_name
workspace_cards = {path.name: path for path in card_directories(workspace_series_path)} if workspace_series_path.is_dir() else {}
if not source_cards and not workspace_cards:
raise SystemExit(f"Missing series: {series_name}")
for repo_name in sorted(set(source_cards) | set(workspace_cards)):
source_card = source_cards.get(repo_name)
workspace_path = workspace_cards.get(repo_name)
title = read_card_title(workspace_path) if workspace_path else ""
if not title and source_card:
title = source_card.title
if not title and source_card and source_card.source_path:
title = read_card_title(source_card.source_path)
label = source_card.card_id if source_card else card_label_from_repo_name(repo_name)
status = "workspace" if workspace_path else "source"
if title:
print(f"{label}\t{repo_name}\t{status}\t{title}")
else:
print(f"{label}\t{repo_name}\t{status}")
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 branch_value(raw_value: str, field_name: str = "branch") -> str:
branch_name = re.sub(r"[^A-Za-z0-9._-]+", "-", raw_value.strip()).strip("-")
if not branch_name:
raise SystemExit(f"Empty {field_name} after branch conversion: {raw_value!r}")
return branch_name
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 repo_parts_from_url(remote_url: str) -> tuple[str, str]:
split_url = urlsplit(remote_url)
repo_path = unquote(split_url.path).strip("/")
if repo_path.endswith(".git"):
repo_path = repo_path[:-4]
path_parts = [part for part in repo_path.split("/") if part]
if len(path_parts) < 2:
return "", ""
return path_parts[0], path_parts[1]
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)
org_name, repo_name = repo_parts_from_url(remote_url)
return {
"endpoint": endpoint,
"type": server_type_from_split(config, split_url),
"scheme": split_url.scheme,
"host": split_url.hostname,
"port": port_value,
"org": org_name,
"repo": repo_name,
}
def empty_token_store() -> dict:
return {"version": 3, "tokens": []}
def next_token_name(tokens: dict[str, str]) -> str:
index = 1
while f"t{index}" in tokens:
index += 1
return f"t{index}"
def server_record_from_info(server_info: dict) -> dict:
return {
"type": str(server_info.get("type", "unknown")),
"endpoint": str(server_info.get("endpoint", "")),
"scheme": str(server_info.get("scheme", "")),
"host": str(server_info.get("host", "")),
"port": server_info.get("port"),
}
def normalize_server_record(config: WorkspaceConfig, raw_server: dict, endpoint: str | None = None) -> dict | None:
if not isinstance(raw_server, dict):
return None
endpoint_value = str(raw_server.get("endpoint") or endpoint or "")
if not endpoint_value:
return None
split_endpoint = urlsplit(endpoint_value)
host_value = raw_server.get("host") or split_endpoint.hostname or ""
scheme_value = raw_server.get("scheme") or split_endpoint.scheme
port_value = raw_server.get("port")
if port_value is None and split_endpoint.port is not None:
port_value = split_endpoint.port
server_type = raw_server.get("type") or "unknown"
if server_type == "unknown" and scheme_value and host_value:
server_url = endpoint_value
server_type = server_type_from_split(config, urlsplit(server_url))
return {
"type": str(server_type),
"endpoint": endpoint_value,
"scheme": str(scheme_value),
"host": str(host_value),
"port": port_value,
}
def normalize_token_entry(raw_token_entry) -> str | dict | None:
if isinstance(raw_token_entry, str):
return raw_token_entry if raw_token_entry else None
if not isinstance(raw_token_entry, dict):
return None
token_value = raw_token_entry.get("value")
if not isinstance(token_value, str) or not token_value:
return None
token_entry = {"value": token_value}
expires_at = raw_token_entry.get("expires_at")
if isinstance(expires_at, str) and expires_at:
token_entry["expires_at"] = expires_at
for field_name in ["remote", "org", "repo"]:
field_value = raw_token_entry.get(field_name)
if isinstance(field_value, str) and field_value:
token_entry[field_name] = field_value
return token_entry
def token_entry_value(token_entry) -> str:
if isinstance(token_entry, str):
return token_entry
if isinstance(token_entry, dict) and isinstance(token_entry.get("value"), str):
return token_entry["value"]
return ""
def token_entry_expires_at(token_entry) -> str:
if isinstance(token_entry, dict) and isinstance(token_entry.get("expires_at"), str):
return token_entry["expires_at"]
return ""
def token_entry_field(token_entry, field_name: str) -> str:
if isinstance(token_entry, dict) and isinstance(token_entry.get(field_name), str):
return token_entry[field_name]
return ""
def token_value_exists(tokens: dict, token_value: str) -> bool:
return any(token_entry_value(existing_token) == token_value for existing_token in tokens.values())
def normalize_remote_record(raw_remote: dict) -> dict | None:
if not isinstance(raw_remote, dict):
return None
remote_name = raw_remote.get("name") or raw_remote.get("remote")
if not isinstance(remote_name, str) or not remote_name:
return None
remote_record = {"name": remote_name, "org": "", "repo": ""}
for field_name in ["org", "repo"]:
field_value = raw_remote.get(field_name)
if isinstance(field_value, str) and field_value:
remote_record[field_name] = field_value
org_perm = normalize_permission_map(raw_remote.get("org_perm"), ORG_PERMISSION_FIELDS, "+?!-")
if org_perm is not None:
remote_record["org_perm"] = org_perm
repo_perm = normalize_permission_map(raw_remote.get("repo_perm"), REPO_PERMISSION_FIELDS, "+?!-")
if repo_perm is not None:
remote_record["repo_perm"] = repo_perm
return remote_record
def valid_mask(raw_value: str, allowed: str, width: int) -> bool:
return len(raw_value) == width and all(char in allowed for char in raw_value)
def permission_map_from_mask(
raw_value: str,
fields: list[tuple[str, str]],
allowed: str,
) -> dict[str, str] | None:
if not valid_mask(raw_value, allowed, len(fields)):
return None
return {key: value for (key, _), value in zip(fields, raw_value)}
def normalize_permission_map(
raw_value,
fields: list[tuple[str, str]],
allowed: str,
unknown: str = "?",
) -> dict[str, str] | None:
if isinstance(raw_value, str):
return permission_map_from_mask(raw_value, fields, allowed)
if not isinstance(raw_value, dict):
return None
normalized: dict[str, str] = {}
found = False
for key, long_name in fields:
field_value = raw_value.get(key, raw_value.get(long_name))
if isinstance(field_value, str) and len(field_value) == 1 and field_value in allowed:
normalized[key] = field_value
found = True
else:
normalized[key] = unknown
return normalized if found else None
def permission_map_to_mask(
raw_value,
fields: list[tuple[str, str]],
allowed: str,
unknown: str = "?",
) -> str:
if isinstance(raw_value, str) and valid_mask(raw_value, allowed, len(fields)):
return raw_value
if not isinstance(raw_value, dict):
return ""
values = []
for key, long_name in fields:
field_value = raw_value.get(key, raw_value.get(long_name))
if isinstance(field_value, str) and len(field_value) == 1 and field_value in allowed:
values.append(field_value)
else:
values.append(unknown)
return "".join(values)
def normalize_flat_token(
config: WorkspaceConfig,
raw_token: dict,
remote_id: str,
token_value: str,
user_name: str,
org_name: str,
repo_name: str,
org_perm=None,
repo_perm=None,
) -> dict | None:
server_record = normalize_server_record(config, raw_token.get("server", {}))
if server_record is None:
return None
token_record = {
"id": remote_id,
"value": token_value,
"server": server_record,
"org": org_name,
"repo": repo_name,
}
if user_name:
token_record["user"] = user_name
for field_name in ["valid", "expires_at"]:
field_value = raw_token.get(field_name)
if isinstance(field_value, str) and field_value:
token_record[field_name] = field_value
scope_record = normalize_permission_map(raw_token.get("scope"), SCOPE_FIELDS, "rw?!-")
if scope_record is not None:
token_record["scope"] = scope_record
org_record = normalize_permission_map(org_perm if org_perm is not None else raw_token.get("org_perm"), ORG_PERMISSION_FIELDS, "+?!-")
if org_record is not None:
token_record["org_perm"] = org_record
repo_record = normalize_permission_map(repo_perm if repo_perm is not None else raw_token.get("repo_perm"), REPO_PERMISSION_FIELDS, "+?!-")
if repo_record is not None:
token_record["repo_perm"] = repo_record
return token_record
def normalize_v3_tokens(config: WorkspaceConfig, raw_token: dict) -> list[dict]:
if not isinstance(raw_token, dict):
return []
remote_id = raw_token.get("id") or raw_token.get("remote")
legacy_token_id = raw_token.get("token_id") or raw_token.get("token_ref")
token_value = raw_token.get("value")
if token_value is None:
token_value = raw_token.get("token")
if not isinstance(token_value, str):
return []
user_name = raw_token.get("user")
if not isinstance(user_name, str):
user_name = ""
if isinstance(remote_id, str) and remote_id:
token_record = normalize_flat_token(
config,
raw_token,
remote_id,
token_value,
user_name,
str(raw_token.get("org", "")),
str(raw_token.get("repo", "")),
)
return [token_record] if token_record is not None else []
raw_remotes = raw_token.get("remotes", [])
if isinstance(raw_remotes, dict):
raw_remotes = [
{"name": remote_name, **remote_value}
for remote_name, remote_value in raw_remotes.items()
if isinstance(remote_value, dict)
]
if not isinstance(raw_remotes, list):
raw_remotes = []
normalized_records: list[dict] = []
if isinstance(raw_remotes, list):
for raw_remote in raw_remotes:
remote_record = normalize_remote_record(raw_remote)
if remote_record is None:
continue
legacy_user_name = user_name
if not legacy_user_name and isinstance(legacy_token_id, str):
legacy_user_name = legacy_token_id
token_record = normalize_flat_token(
config,
raw_token,
remote_record["name"],
token_value,
legacy_user_name,
remote_record.get("org", ""),
remote_record.get("repo", ""),
remote_record.get("org_perm"),
remote_record.get("repo_perm"),
)
if token_record is not None:
normalized_records.append(token_record)
if not normalized_records and isinstance(legacy_token_id, str) and legacy_token_id:
token_record = normalize_flat_token(
config,
raw_token,
legacy_token_id,
token_value,
user_name,
str(raw_token.get("org", "")),
str(raw_token.get("repo", "")),
)
if token_record is not None:
normalized_records.append(token_record)
return normalized_records
def append_token_record(token_data: dict, token_record: dict) -> None:
tokens = token_data.setdefault("tokens", [])
token_key = (
token_record.get("server", {}).get("endpoint", ""),
token_record.get("id", ""),
)
for existing_record in tokens:
existing_key = (
existing_record.get("server", {}).get("endpoint", ""),
existing_record.get("id", ""),
)
if existing_key == token_key:
existing_record.update(token_record)
return
tokens.append(token_record)
def parse_datetime(raw_value: str) -> dt.datetime | None:
if not raw_value:
return None
try:
normalized_value = raw_value.replace("Z", "+00:00")
return dt.datetime.fromisoformat(normalized_value)
except ValueError:
return None
def is_expired(raw_value: str) -> bool:
expires_at = parse_datetime(raw_value)
if expires_at is None:
return False
if expires_at.tzinfo is None:
now = dt.datetime.now()
else:
now = dt.datetime.now(expires_at.tzinfo)
return expires_at <= now
def valid_label(api_accepts_token: bool, expires_at: str) -> str:
if not api_accepts_token or (expires_at and is_expired(expires_at)):
return "invalid"
if expires_at:
return expires_at
return "forever"
def copy_legacy_users(token_data: dict, server_info: dict, raw_users: dict) -> dict:
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
for token_name, raw_token_entry in raw_tokens.items():
token_entry = normalize_token_entry(raw_token_entry)
if token_entry is None:
continue
token_value = token_entry_value(token_entry)
remote_name = token_entry_field(token_entry, "remote") or str(token_name)
token_record = {
"id": remote_name,
"value": token_value,
"server": server_record_from_info(server_info),
"user": str(user_name),
"org": token_entry_field(token_entry, "org"),
"repo": token_entry_field(token_entry, "repo"),
}
expires_at = token_entry_expires_at(token_entry)
if expires_at:
token_record["expires_at"] = expires_at
append_token_record(token_data, token_record)
return token_data
def normalize_token_store(config: WorkspaceConfig, raw_data: dict) -> dict:
if raw_data.get("version") == 3 and isinstance(raw_data.get("tokens"), list):
token_data = empty_token_store()
for raw_token in raw_data["tokens"]:
for token_record in normalize_v3_tokens(config, raw_token):
append_token_record(token_data, token_record)
return token_data
if "servers" in raw_data and isinstance(raw_data["servers"], dict):
token_data = empty_token_store()
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"),
}
copy_legacy_users(token_data, base_server_info, server_entry.get("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, write_normalized: bool = True) -> 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 write_normalized and 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) + "\n", encoding="utf-8")
os.chmod(temp_path, 0o600)
temp_path.replace(config.token_path)
os.chmod(config.token_path, 0o600)
def token_server_endpoint(token_record: dict) -> str:
server_record = token_record.get("server", {})
if not isinstance(server_record, dict):
return ""
return str(server_record.get("endpoint", ""))
def token_record_id(token_record: dict) -> str:
return str(token_record.get("id", ""))
def find_token_record(token_data: dict, endpoint: str, remote_id: str) -> dict | None:
for token_record in token_data.get("tokens", []):
if token_server_endpoint(token_record) == endpoint and token_record_id(token_record) == remote_id:
return token_record
return None
def apply_authz_to_token(token_record: dict, authz: dict) -> bool:
changed = False
for source_name, target_name in [
("user", "user"),
("valid", "valid"),
]:
source_value = authz.get(source_name)
if isinstance(source_value, str) and source_value and token_record.get(target_name) != source_value:
token_record[target_name] = source_value
changed = True
scope_value = authz.get("scope_mask")
if isinstance(scope_value, str):
scope_record = permission_map_from_mask(scope_value, SCOPE_FIELDS, "rw?!-")
if scope_record is not None and token_record.get("scope") != scope_record:
token_record["scope"] = scope_record
changed = True
for source_name, target_name in [
("org_mask", "org_perm"),
("repo_mask", "repo_perm"),
]:
source_value = authz.get(source_name)
if not isinstance(source_value, str):
continue
fields = ORG_PERMISSION_FIELDS if target_name == "org_perm" else REPO_PERMISSION_FIELDS
permission_record = permission_map_from_mask(source_value, fields, "+?!-")
if permission_record is not None and token_record.get(target_name) != permission_record:
token_record[target_name] = permission_record
changed = True
return changed
def unknown_permission_map(fields: list[tuple[str, str]]) -> dict[str, str]:
return {short_name: "?" for short_name, _ in fields}
def reset_token_api_metadata(token_record: dict) -> bool:
changed = False
if token_record.get("valid") != "?":
token_record["valid"] = "?"
changed = True
if token_record.pop("expires_at", None) is not None:
changed = True
for field_name, fields in [
("scope", SCOPE_FIELDS),
("org_perm", ORG_PERMISSION_FIELDS),
("repo_perm", REPO_PERMISSION_FIELDS),
]:
unknown_record = unknown_permission_map(fields)
if token_record.get(field_name) != unknown_record:
token_record[field_name] = unknown_record
changed = True
return changed
def register_token(
token_data: dict,
server_info: dict,
remote_name: str,
user_name: str,
token_value: str,
hydrate: bool = True,
reset_api_metadata: bool = False,
) -> str:
endpoint = server_info["endpoint"]
token_record = find_token_record(token_data, endpoint, remote_name)
if token_record is None:
token_record = {
"id": remote_name,
"value": token_value,
"server": server_record_from_info(server_info),
"user": user_name,
"org": str(server_info.get("org", "")),
"repo": str(server_info.get("repo", "")),
}
token_data.setdefault("tokens", []).append(token_record)
result = "added"
else:
result = "existing"
if token_record.get("value") != token_value:
token_record["value"] = token_value
result = "updated"
if token_record.get("user") != user_name:
token_record["user"] = user_name
result = "updated"
if token_record.get("server") != server_record_from_info(server_info):
token_record["server"] = server_record_from_info(server_info)
result = "updated"
for source_name, target_name in [("org", "org"), ("repo", "repo")]:
source_value = server_info.get(source_name)
if isinstance(source_value, str) and token_record.get(target_name) != source_value:
token_record[target_name] = source_value
result = "updated"
if reset_api_metadata:
if reset_token_api_metadata(token_record) and result == "existing":
result = "updated"
if hydrate:
authz = load_gitea_authz(
endpoint,
remote_name,
token_value,
token_record.get("expires_at", ""),
token_record.get("org", ""),
token_record.get("repo", ""),
)
if apply_authz_to_token(token_record, authz) and result == "existing":
result = "updated"
return result
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 scan_repo_remotes(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,
"updated": 0,
"servers": 0,
"remote_rows": [],
"found_credentials": [],
"changed": False,
}
found_credentials: list[tuple[int, dict, str, 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",
"org": "",
"repo": "",
"url_kind": "unsupported",
"url": safe_remote_url_label(remote_url),
"token_id": "",
"token_value": "",
"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"],
"org": server_info["org"],
"repo": server_info["repo"],
"url_kind": "plain",
"url": safe_remote_url_label(remote_url),
"token_id": "",
"token_value": "",
"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"],
"org": server_info["org"],
"repo": server_info["repo"],
"url_kind": "auth",
"url": safe_remote_url_label(remote_url),
"token_id": remote_name,
"user": user_name,
"token_value": token_value,
"result": "found",
}
)
found_credentials.append((row_index, server_info, remote_name, user_name, token_value))
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": 0,
"updated": 0,
"servers": len(touched_servers),
"remote_rows": remote_rows,
"found_credentials": found_credentials,
"changed": False,
}
def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict:
report = scan_repo_remotes(config, repo_path)
found_credentials = report.get("found_credentials", [])
if not found_credentials:
return report
token_data = load_token_store(config)
added = 0
updated = 0
for row_index, server_info, remote_name, user_name, token_value in found_credentials:
result = register_token(token_data, server_info, remote_name, user_name, token_value)
if result == "added":
added += 1
elif result == "updated":
updated += 1
report["remote_rows"][row_index]["result"] = result
changed = added > 0 or updated > 0
if changed or not config.token_path.exists():
write_token_store(config, token_data)
report["added"] = added
report["updated"] = updated
report["changed"] = changed
return report
def sync_token_from_remote(config: WorkspaceConfig, repo_path: Path, remote_name: str, dry_run: bool = False) -> dict:
repo_root = git_repo_root(repo_path)
if repo_root is None:
raise SystemExit(f"Missing git repo at path: {repo_path}")
remote_url = git_capture(repo_root, ["remote", "get-url", remote_name])
if not remote_url:
raise SystemExit(f"Missing git remote: {remote_name}")
server_info = server_info_from_url(config, remote_url)
if server_info is None:
raise SystemExit(f"Only http/https remote URLs are supported: {remote_url}")
credentials = remote_credentials(remote_url)
if credentials is None:
raise SystemExit(f"Remote '{remote_name}' has no credentials.")
user_name, token_value = credentials
result = "dry-run"
if not dry_run:
token_data = load_token_store(config)
result = register_token(
token_data,
server_info,
remote_name,
user_name,
token_value,
hydrate=False,
reset_api_metadata=True,
)
write_token_store(config, token_data)
return {
"repo_root": str(repo_root),
"remote": remote_name,
"server": server_info["endpoint"],
"user": user_name,
"org": server_info.get("org", ""),
"repo": server_info.get("repo", ""),
"status": result,
}
def hydrate_token_from_api(config: WorkspaceConfig, remote_name: str, endpoint: str | None, dry_run: bool = False) -> dict:
token_data = load_token_store(config, write_normalized=not dry_run)
token_record = resolve_token_from_store(token_data, endpoint, remote_name, None)
token_value = str(token_record.get("value", ""))
if not token_value:
raise SystemExit(f"Token value is empty in tokens.json: {token_server_endpoint(token_record)}:{remote_name}")
authz = load_gitea_authz(
token_server_endpoint(token_record),
remote_name,
token_value,
token_record.get("expires_at", ""),
token_record.get("org", ""),
token_record.get("repo", ""),
)
changed = apply_authz_to_token(token_record, authz)
if changed and not dry_run:
write_token_store(config, token_data)
return {
"remote": remote_name,
"server": token_server_endpoint(token_record),
"user": str(token_record.get("user", "")),
"valid": str(token_record.get("valid", "")),
"status": "dry-run" if dry_run else ("updated" if changed else "unchanged"),
}
def count_server_tokens(server_entry: dict) -> int:
return len(server_entry.get("tokens", []))
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 store_servers_from_tokens(token_data: dict) -> dict[str, dict]:
servers: dict[str, dict] = {}
for token_record in token_data.get("tokens", []):
server_record = token_record.get("server", {})
if not isinstance(server_record, dict):
continue
endpoint = str(server_record.get("endpoint", ""))
if not endpoint:
continue
server_entry = servers.setdefault(
endpoint,
{
"type": server_record.get("type", "unknown"),
"scheme": server_record.get("scheme", ""),
"host": server_record.get("host", ""),
"port": server_record.get("port"),
"tokens": [],
},
)
for field_name in ["type", "scheme", "host", "port"]:
if server_entry.get(field_name) in {"", None, "unknown"} and server_record.get(field_name) not in {"", None}:
server_entry[field_name] = server_record.get(field_name)
token_id = token_record_id(token_record)
if not token_id:
continue
server_entry["tokens"].append(
{
"endpoint": endpoint,
"token_id": token_id,
"token_value": str(token_record.get("value", "")),
"user": str(token_record.get("user", "")),
"valid": str(token_record.get("valid", "")),
"scope_mask": permission_map_to_mask(token_record.get("scope"), SCOPE_FIELDS, "rw?!-"),
"expires_at": str(token_record.get("expires_at", "")),
"remote": token_id,
"owner": str(token_record.get("org", "")),
"repo_name": str(token_record.get("repo", "")),
"org_mask": permission_map_to_mask(token_record.get("org_perm"), ORG_PERMISSION_FIELDS, "+?!-"),
"repo_mask": permission_map_to_mask(token_record.get("repo_perm"), REPO_PERMISSION_FIELDS, "+?!-"),
}
)
return servers
def token_store_servers(token_data: dict) -> list[tuple[str, dict]]:
return sorted(store_servers_from_tokens(token_data).items())
def token_pairs_from_entry(server_entry: dict) -> set[tuple[str, str, str, str, str]]:
token_pairs: set[tuple[str, str, str, str, str]] = set()
for row in server_entry.get("tokens", []):
token_id = row.get("token_id", "")
token_value = row.get("token_value", "")
if token_id and token_value:
token_pairs.add(
(
str(row.get("remote", "")),
str(row.get("user", "")),
str(token_value),
str(row.get("org", row.get("owner", ""))),
str(row.get("repo", row.get("repo_name", ""))),
)
)
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,
"tokens": [],
"plain_remotes": [],
},
)
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
entry["plain_remotes"].append(
{
"remote": remote_name,
"org": server_info["org"],
"repo": server_info["repo"],
}
)
continue
entry["auth_urls"] += 1
user_name, token_value = credentials
entry["tokens"].append(
{
"remote": remote_name,
"org": server_info["org"],
"repo": server_info["repo"],
"token_id": remote_name,
"user": user_name,
"token_value": 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_entry(repo_entry)
store_pairs = token_pairs_from_entry(store_entry)
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 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"tokens\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_REF_WIDTH = 9
TOKEN_COLUMNS = [
("item", "item", 4),
("server", "server", 6),
("proto", "proto", 5),
("host", "host", 18),
("owner", "org", 9),
("repo_name", "repo", 11),
("user", "user", 4),
("remote", "remote", 6),
("token_ref", "token_ref", TOKEN_REF_WIDTH),
("token", "token", 12),
("valid", "valid", 19),
("scope_mask", "scope", 9),
("org_mask", "org", 6),
("repo_mask", "repo", 6),
]
TOKEN_LIST_COLUMNS = [
("item", "item", 4),
("source", "source", 6),
("kind", "kind", 5),
("server", "server", 6),
("proto", "proto", 5),
("host", "host", 18),
("owner", "org", 9),
("repo_name", "repo", 11),
("user", "user", 4),
("remote", "remote", 6),
("token", "token", 12),
("valid", "valid", 19),
("scope_mask", "scope", 9),
("org_mask", "org", 6),
("repo_mask", "repo", 6),
]
TOKEN_SCAN_COLUMNS = [
("item", "item", 4),
("remote", "remote", 6),
("kind", "kind", 11),
("server", "server", 6),
("proto", "proto", 5),
("host", "host", 18),
("owner", "org", 9),
("repo_name", "repo", 11),
("user", "user", 4),
("token", "token", 12),
("result", "result", 14),
("url", "url", 38),
]
TOKEN_SEPARATOR_OVERRIDES = {
"scope_mask": "aAimnopru",
"org_mask": "oawrc-",
"repo_mask": "oawr--",
}
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_fixed_table(title: str, columns: list[tuple[str, str, int]], rows: list[dict[str, str]]) -> None:
print(title)
print(" ".join(fit_cell(label, width) for _, label, width in columns).rstrip())
print(" ".join(fit_cell(TOKEN_SEPARATOR_OVERRIDES.get(name, "-" * width), width) for name, _, width in columns).rstrip())
for row in rows:
print(" ".join(fit_cell(row.get(name, ""), width) for name, _, width in columns).rstrip())
def short_secret(secret_value: str) -> str:
if len(secret_value) <= 12:
return mask_secret(secret_value)
return f"{secret_value[:5]}...{secret_value[-4:]}"
def format_token_ref(token_name: str, marker: str) -> str:
name_width = TOKEN_REF_WIDTH - 2
return f"{fit_cell(token_name, name_width)}{marker:>2}"
def users_label(server_entry: dict | None) -> str:
if server_entry is None:
return ""
return ",".join(sorted(str(user_name) for user_name in server_entry.get("users", {})))
def endpoint_column_values(endpoint: str, repo_entry: dict | None, store_entry: dict | None) -> dict[str, str]:
values = {
"server": "",
"scheme": "",
"host": "",
"port": "",
}
for server_entry in [repo_entry, store_entry]:
if server_entry is None:
continue
values["server"] = values["server"] or str(server_entry.get("type", ""))
values["scheme"] = values["scheme"] or str(server_entry.get("scheme", ""))
values["host"] = values["host"] or str(server_entry.get("host", ""))
port_value = server_entry.get("port", "")
values["port"] = values["port"] or ("" if port_value is None else str(port_value))
split_endpoint = urlsplit(endpoint)
values["scheme"] = values["scheme"] or split_endpoint.scheme
values["host"] = values["host"] or (split_endpoint.hostname or "")
if not values["port"] and split_endpoint.port is not None:
values["port"] = str(split_endpoint.port)
if values["host"] and values["port"]:
values["host"] = f"{values['host']}:{values['port']}"
return values
def token_store_rows(store_servers: dict[str, dict]) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
for endpoint, server_entry in sorted(store_servers.items()):
for row in server_entry.get("tokens", []):
token_value = row.get("token_value", "")
token_id = row.get("token_id", "")
if token_id:
rows.append(
{
"endpoint": endpoint,
"user": str(row.get("user", "")),
"token_id": str(token_id),
"remote": str(row.get("remote", "")),
"owner": str(row.get("owner", "")),
"repo_name": str(row.get("repo_name", "")),
"token_value": str(token_value),
"expires_at": str(row.get("expires_at", "")),
"valid": str(row.get("valid", "")),
"scope_mask": str(row.get("scope_mask", "")),
"org_mask": str(row.get("org_mask", "")),
"repo_mask": str(row.get("repo_mask", "")),
}
)
return rows
def repo_token_rows(
endpoint_names: list[str],
repo_servers: dict[str, dict],
scan_rows: list[dict[str, str]] | None,
) -> list[dict[str, str]]:
if scan_rows is not None:
return [
{
"endpoint": row["endpoint"],
"remote": row["remote"],
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
"token_id": row.get("token_id", ""),
"user": row.get("user", ""),
"token_value": row.get("token_value", ""),
}
for row in scan_rows
if row.get("endpoint") and row.get("url_kind") == "auth" and row.get("token_value")
]
rows: list[dict[str, str]] = []
for endpoint in endpoint_names:
repo_entry = repo_servers.get(endpoint)
if repo_entry is None:
continue
for row in repo_entry.get("tokens", []):
rows.append(
{
"endpoint": endpoint,
"remote": row.get("remote", ""),
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
"token_id": row.get("token_id", ""),
"user": row.get("user", ""),
"token_value": row.get("token_value", ""),
}
)
return rows
def list_store_rows(store_servers: dict[str, dict], server_filter: str | None = None) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
endpoints = sorted(store_servers)
for endpoint in endpoints:
if server_filter and endpoint != server_filter:
continue
server_entry = store_servers[endpoint]
endpoint_values = endpoint_column_values(endpoint, None, server_entry)
for row in sorted(server_entry.get("tokens", []), key=lambda item: item.get("token_id", "")):
token_value = str(row.get("token_value", ""))
rows.append(
{
"item": str(len(rows) + 1),
"source": "store",
"kind": "auth" if token_value else "empty",
"server": endpoint_values["server"],
"proto": endpoint_values["scheme"],
"host": endpoint_values["host"],
"owner": str(row.get("owner", "")),
"repo_name": str(row.get("repo_name", "")),
"user": str(row.get("user", "")),
"remote": str(row.get("remote", "")),
"token": short_secret(token_value) if token_value else "",
"valid": str(row.get("valid", "")),
"scope_mask": str(row.get("scope_mask", "")),
"org_mask": str(row.get("org_mask", "")),
"repo_mask": str(row.get("repo_mask", "")),
}
)
return rows
def list_remote_rows(repo_servers: dict[str, dict], server_filter: str | None = None) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
endpoints = sorted(repo_servers)
for endpoint in endpoints:
if server_filter and endpoint != server_filter:
continue
server_entry = repo_servers[endpoint]
endpoint_values = endpoint_column_values(endpoint, server_entry, None)
remote_rows = []
for row in server_entry.get("tokens", []):
remote_rows.append(
{
"kind": "auth",
"remote": row.get("remote", ""),
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
"user": row.get("user", ""),
"token_value": row.get("token_value", ""),
}
)
for row in server_entry.get("plain_remotes", []):
remote_rows.append(
{
"kind": "plain",
"remote": row.get("remote", ""),
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
"user": "",
"token_value": "",
}
)
for row in sorted(remote_rows, key=lambda item: (item.get("remote", ""), item.get("kind", ""))):
token_value = str(row.get("token_value", ""))
rows.append(
{
"item": str(len(rows) + 1),
"source": "remote",
"kind": str(row.get("kind", "")),
"server": endpoint_values["server"],
"proto": endpoint_values["scheme"],
"host": endpoint_values["host"],
"owner": str(row.get("owner", "")),
"repo_name": str(row.get("repo_name", "")),
"user": str(row.get("user", "")),
"remote": str(row.get("remote", "")),
"token": short_secret(token_value) if token_value else "",
"valid": "?",
"scope_mask": "?????????",
"org_mask": "?????",
"repo_mask": "????",
}
)
return rows
def scan_remote_rows(report: dict, server_filter: str | None = None) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
for row in report.get("remote_rows", []):
endpoint = str(row.get("endpoint", ""))
if server_filter and endpoint != server_filter:
continue
endpoint_values = endpoint_column_values(endpoint, None, None) if endpoint else {}
token_value = str(row.get("token_value", ""))
rows.append(
{
"item": str(len(rows) + 1),
"remote": str(row.get("remote", "")),
"kind": str(row.get("url_kind", "")),
"server": str(row.get("type", "")),
"proto": endpoint_values.get("scheme", ""),
"host": endpoint_values.get("host", ""),
"owner": str(row.get("org", "")),
"repo_name": str(row.get("repo", "")),
"user": str(row.get("user", "")),
"token": short_secret(token_value) if token_value else "",
"result": str(row.get("result", "")),
"url": str(row.get("url", "")),
}
)
return rows
def fallback_project_by_endpoint(
endpoint_names: list[str],
repo_servers: dict[str, dict],
scan_rows: list[dict[str, str]] | None,
) -> dict[str, dict[str, str]]:
fallback: dict[str, dict[str, str]] = {}
for row in scan_rows or []:
endpoint = row.get("endpoint", "")
if endpoint and endpoint not in fallback:
fallback[endpoint] = {
"remote": row.get("remote", ""),
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
}
for endpoint in endpoint_names:
if endpoint in fallback:
continue
repo_entry = repo_servers.get(endpoint)
if repo_entry is None:
continue
for row in repo_entry.get("tokens", []) + repo_entry.get("plain_remotes", []):
fallback[endpoint] = {
"remote": row.get("remote", ""),
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
}
break
return fallback
def api_get_json(api_url: str, token_value: str, user_name: str = "", basic: bool = False) -> object:
headers = {"Accept": "application/json"}
if basic:
raw_credentials = f"{user_name}:{token_value}".encode("utf-8")
headers["Authorization"] = "Basic " + base64.b64encode(raw_credentials).decode("ascii")
else:
headers["Authorization"] = f"token {token_value}"
request = urlrequest.Request(api_url, headers=headers)
with urlrequest.urlopen(request, timeout=5) as response:
return json.loads(response.read().decode("utf-8"))
def scope_mask(scopes: list[str]) -> str:
scope_set = {scope.strip().lower() for scope in scopes if scope.strip()}
if "all" in scope_set:
return "w" * len(SCOPE_FIELDS)
values = []
for _, category in SCOPE_FIELDS:
category_names = SCOPE_CATEGORY_ALIASES.get(category, [category])
if any(f"write:{category_name}" in scope_set for category_name in category_names):
values.append("w")
elif any(f"read:{category_name}" in scope_set for category_name in category_names):
values.append("r")
else:
values.append("-")
return "".join(values)
def org_permission_mask(org_permissions: dict) -> str:
return "".join(
[
"+" if org_permissions.get("is_owner") else "-",
"+" if org_permissions.get("is_admin") else "-",
"+" if org_permissions.get("can_write") else "-",
"+" if org_permissions.get("can_read") else "-",
"+" if org_permissions.get("can_create_repository") else "-",
]
)
def repo_permission_mask(repo_permissions: dict) -> str:
permission = str(repo_permissions.get("permission", "none")).lower()
levels = {
"owner": "++++",
"admin": "-+++",
"write": "--++",
"read": "---+",
}
return levels.get(permission, "----")
def load_gitea_authz(
endpoint: str,
token_id: str,
token_value: str,
expires_at: str,
owner: str,
repo_name: str,
) -> dict:
api_base = endpoint.rstrip("/") + "/api/v1"
result = {
"ok": False,
"valid": "!",
"scope_mask": "!!!!!!!!!",
"org_mask": "!!!!!",
"repo_mask": "!!!!",
"user": "",
}
try:
user_info = api_get_json(f"{api_base}/user", token_value)
user_name = str(user_info.get("login", "")) if isinstance(user_info, dict) else ""
result["user"] = user_name
result["valid"] = valid_label(True, expires_at)
if user_name:
try:
tokens_url = f"{api_base}/users/{quote(user_name, safe='')}/tokens"
token_list = api_get_json(tokens_url, token_value, user_name=user_name, basic=True)
token_last8 = token_value[-8:]
token_entry = next(
(
entry
for entry in token_list
if isinstance(entry, dict) and entry.get("token_last_eight") == token_last8
),
None,
)
scopes = token_entry.get("scopes", []) if isinstance(token_entry, dict) else []
if not isinstance(scopes, list):
scopes = []
result["scope_mask"] = scope_mask([str(scope) for scope in scopes])
except (OSError, ValueError, urlerror.URLError):
result["scope_mask"] = "?????????"
if user_name and owner:
org_url = f"{api_base}/users/{quote(user_name, safe='')}/orgs/{quote(owner, safe='')}/permissions"
org_permissions = api_get_json(org_url, token_value)
if isinstance(org_permissions, dict):
result["org_mask"] = org_permission_mask(org_permissions)
if user_name and owner and repo_name:
repo_url = (
f"{api_base}/repos/{quote(owner, safe='')}/{quote(repo_name, safe='')}"
f"/collaborators/{quote(user_name, safe='')}/permission"
)
repo_permissions = api_get_json(repo_url, token_value)
if isinstance(repo_permissions, dict):
result["repo_mask"] = repo_permission_mask(repo_permissions)
result["ok"] = True
return result
except urlerror.HTTPError as error:
result["valid"] = "invalid" if error.code in {401, 403} else "!"
return result
except (OSError, ValueError, urlerror.URLError):
return result
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}
for endpoint in endpoint_names:
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
store_rows = token_store_rows(store_servers)
store_by_remote: dict[tuple[str, str], dict[str, str]] = {}
for row in store_rows:
if row["remote"]:
store_by_remote.setdefault((row["endpoint"], row["remote"]), row)
endpoint_values_by_name = {
endpoint: endpoint_column_values(endpoint, repo_servers.get(endpoint), store_servers.get(endpoint))
for endpoint in endpoint_names
}
project_fallback = fallback_project_by_endpoint(endpoint_names, repo_servers, scan_rows)
used_store_keys: set[tuple[str, str, str]] = set()
token_rows: list[dict[str, str]] = []
def append_token_row(
endpoint: str,
user_name: str,
remote_name: str,
token_id: str,
token_value: str,
marker: str,
owner: str,
repo_name: str,
store_row: dict | None = None,
) -> None:
endpoint_values = endpoint_values_by_name.get(endpoint, endpoint_column_values(endpoint, None, None))
valid_value = store_row.get("valid", "?") if store_row else "?"
scope_value = store_row.get("scope_mask", "?????????") if store_row else "?????????"
org_value = store_row.get("org_mask", "?????") if store_row else "?????"
repo_value = store_row.get("repo_mask", "????") if store_row else "????"
token_rows.append(
{
"item": str(len(token_rows) + 1),
"server": endpoint_values["server"],
"proto": endpoint_values["scheme"],
"host": endpoint_values["host"],
"owner": owner,
"repo_name": repo_name,
"user": user_name,
"remote": remote_name,
"token_ref": format_token_ref(token_id, marker),
"token": short_secret(token_value),
"valid": valid_value or "?",
"scope_mask": scope_value or "?????????",
"org_mask": org_value or "?????",
"repo_mask": repo_value or "????",
}
)
for row in repo_token_rows(endpoint_names, repo_servers, scan_rows):
endpoint = row["endpoint"]
token_value = row["token_value"]
store_row = store_by_remote.get((endpoint, row["remote"]))
token_id = row["token_id"]
expires_at = store_row.get("expires_at", "") if store_row else ""
marker = "R"
synced_store_row = None
if store_row:
repo_fields_match = (
store_row["token_id"] == row["token_id"]
and store_row["user"] == row.get("user", "")
and store_row["token_value"] == token_value
and store_row["owner"] == row["owner"]
and store_row["repo_name"] == row["repo_name"]
)
if repo_fields_match:
used_store_keys.add((endpoint, store_row["token_id"], store_row["remote"]))
synced_store_row = store_row
marker = "!" if store_row.get("valid") in {"!", "invalid"} else "*"
append_token_row(
endpoint,
synced_store_row.get("user", "") if synced_store_row else row.get("user", ""),
row["remote"],
token_id,
token_value,
marker,
row["owner"],
row["repo_name"],
synced_store_row,
)
for row in store_rows:
store_key = (row["endpoint"], row["token_id"], row["remote"])
if store_key in used_store_keys:
continue
fallback = project_fallback.get(row["endpoint"], {})
append_token_row(
row["endpoint"],
row["user"],
row["remote"],
row["token_id"],
row["token_value"],
"S",
row["owner"] or fallback.get("owner", ""),
row["repo_name"] or fallback.get("repo_name", ""),
)
print_fixed_table("tokens", TOKEN_COLUMNS, token_rows)
return status_counts
def resolve_token_from_store(
token_data: dict,
endpoint: str | None,
remote_id: str | None,
user_name: str | None,
) -> dict:
candidates = []
for token_record in token_data.get("tokens", []):
token_endpoint = token_server_endpoint(token_record)
if endpoint and token_endpoint != endpoint:
continue
if remote_id and token_record_id(token_record) != remote_id:
continue
if user_name and token_record.get("user") != user_name:
continue
candidates.append(token_record)
if not candidates:
raise SystemExit("No matching token in tokens.json.")
if len(candidates) > 1:
available = ", ".join(
sorted(
f"{token_server_endpoint(token_record)}:{token_record_id(token_record)}"
for token_record in candidates
)
)
raise SystemExit(f"Multiple matching tokens. Pass --server/--token-name or --remote. Available: {available}")
return candidates[0]
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 remote_url_from_token_record(token_record: dict) -> str:
endpoint = token_server_endpoint(token_record).rstrip("/")
owner = str(token_record.get("org", "")).strip("/")
repo_name = str(token_record.get("repo", "")).strip("/")
if not endpoint or not owner or not repo_name:
return ""
if not repo_name.endswith(".git"):
repo_name = f"{repo_name}.git"
return f"{endpoint}/{quote(owner, safe='')}/{quote(repo_name, safe='')}"
def remote_urls_point_to_same_repo(left_url: str, right_url: str) -> bool:
try:
left_sanitized, _ = sanitize_remote_url(left_url)
right_sanitized, _ = sanitize_remote_url(right_url)
except SystemExit:
return False
return left_sanitized.rstrip("/") == right_sanitized.rstrip("/")
def find_origin_rename_candidate(
config: WorkspaceConfig,
repo_root: Path,
target_remote: str,
target_url: str,
) -> tuple[str, str] | None:
origin_remote = config.git.origin_remote
if not origin_remote or origin_remote == target_remote:
return None
if git_capture(repo_root, ["remote", "get-url", target_remote]) is not None:
return None
origin_url = git_capture(repo_root, ["remote", "get-url", origin_remote])
if not origin_url:
return None
if not remote_urls_point_to_same_repo(origin_url, target_url):
return None
return origin_remote, origin_url
def resolve_repo_argument(repo_arg: str | None) -> Path:
return Path(repo_arg).expanduser().resolve() if repo_arg else Path.cwd().resolve()
def resolve_launcher_repo_argument(repo_arg: str | None) -> Path:
return Path(repo_arg).expanduser().resolve() if repo_arg else Path(__file__).resolve().parent
def safe_remote_url_label(remote_url: str | None) -> str:
if not remote_url:
return ""
split_url = urlsplit(remote_url)
if split_url.scheme in {"http", "https"} and split_url.hostname is not None:
return sanitize_remote_url(remote_url)[0]
return remote_url
def run_tokens_scan(config: WorkspaceConfig, args: argparse.Namespace) -> None:
repo_path = resolve_launcher_repo_argument(args.repo)
report = scan_repo_remotes(config, repo_path)
print_fixed_table("remotes", TOKEN_SCAN_COLUMNS, scan_remote_rows(report, args.server))
def run_tokens_compare(config: WorkspaceConfig, args: argparse.Namespace) -> None:
repo_path = resolve_launcher_repo_argument(args.repo)
token_data = load_token_store(config, write_normalized=False)
store_servers = store_servers_from_tokens(token_data)
report = scan_repo_remotes(config, repo_path)
repo_root, repo_servers = collect_repo_server_entries(config, repo_path)
endpoint_names = sorted(set(repo_servers) | set(store_servers))
print_token_item_rows(endpoint_names, repo_servers, store_servers, report["remote_rows"])
def run_tokens_list(config: WorkspaceConfig, args: argparse.Namespace) -> None:
rows: list[dict[str, str]] = []
if args.target in {"store", "both"}:
token_data = load_token_store(config, write_normalized=False)
store_servers = store_servers_from_tokens(token_data)
rows.extend(list_store_rows(store_servers, args.server))
if args.target in {"remote", "both"}:
repo_root, repo_servers = collect_repo_server_entries(config, resolve_launcher_repo_argument(args.repo))
if repo_root is None:
raise SystemExit(f"Missing git repo at path: {resolve_launcher_repo_argument(args.repo)}")
rows.extend(list_remote_rows(repo_servers, args.server))
for index, row in enumerate(rows, start=1):
row["item"] = str(index)
print_fixed_table("tokens", TOKEN_LIST_COLUMNS, rows)
def run_tokens_read(config: WorkspaceConfig, args: argparse.Namespace) -> None:
token_data = load_token_store(config, write_normalized=False)
servers = token_store_servers(token_data)
if args.server:
server_entry = dict(store_servers_from_tokens(token_data)).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"tokens\t{count_server_tokens(server_entry)}")
for row in sorted(server_entry.get("tokens", []), key=lambda item: item.get("token_id", "")):
token_value = row.get("token_value", "")
secret_value = token_value if args.show_secrets else mask_secret(token_value)
print(f"id\t{row.get('token_id', '')}\t{secret_value}")
for field_name in ["user", "valid", "scope_mask", "expires_at"]:
field_value = row.get(field_name, "")
if field_value:
print(f"{field_name}\t{row.get('token_id', '')}\t{field_value}")
if row.get("remote"):
print(
"remote\t"
f"{row.get('token_id', '')}\t{row.get('remote', '')}\t"
f"{row.get('owner', '')}\t{row.get('repo_name', '')}"
)
for field_name in ["org_mask", "repo_mask"]:
field_value = row.get(field_name, "")
if field_value:
print(f"{field_name}\t{row.get('token_id', '')}\t{field_value}")
def run_tokens_stats(config: WorkspaceConfig, args: argparse.Namespace) -> None:
repo_root, repo_servers = collect_repo_server_entries(config, resolve_launcher_repo_argument(args.repo))
token_data = load_token_store(config, write_normalized=False)
store_servers = store_servers_from_tokens(token_data)
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 print_token_action_result(result: dict[str, str]) -> None:
for field_name in ["repo_root", "token_path", "remote", "server", "user", "org", "repo", "valid", "url", "removed", "status"]:
field_value = result.get(field_name)
if field_value is not None:
print(f"{field_name}\t{field_value}")
def remove_token_from_store(
config: WorkspaceConfig,
remote_id: str,
endpoint: str | None = None,
dry_run: bool = False,
) -> dict[str, str]:
token_data = load_token_store(config, write_normalized=False)
matches = []
for index, token_record in enumerate(token_data.get("tokens", [])):
if token_record_id(token_record) != remote_id:
continue
if endpoint and token_server_endpoint(token_record) != endpoint:
continue
matches.append((index, token_record))
if not matches:
return {
"token_path": str(config.token_path),
"remote": remote_id,
"server": endpoint or "",
"removed": "0",
"status": "missing",
}
if len(matches) > 1 and not endpoint:
available = ", ".join(sorted(token_server_endpoint(token_record) for _, token_record in matches))
raise SystemExit(f"Multiple tokens with id '{remote_id}'. Pass --server. Available: {available}")
removed_indexes = {index for index, _ in matches}
first_record = matches[0][1]
if not dry_run:
token_data["tokens"] = [
token_record
for index, token_record in enumerate(token_data.get("tokens", []))
if index not in removed_indexes
]
write_token_store(config, token_data)
return {
"token_path": str(config.token_path),
"remote": remote_id,
"server": token_server_endpoint(first_record),
"user": str(first_record.get("user", "")),
"org": str(first_record.get("org", "")),
"repo": str(first_record.get("repo", "")),
"removed": str(len(matches)),
"status": "dry-run" if dry_run else "removed",
}
def remove_git_remote(repo_path: Path, remote_id: str, dry_run: bool = False) -> dict[str, str]:
repo_root = git_repo_root(repo_path)
if repo_root is None:
raise SystemExit(f"Missing git repo at path: {repo_path}")
remote_url = git_capture(repo_root, ["remote", "get-url", remote_id])
if not remote_url:
return {
"repo_root": str(repo_root),
"remote": remote_id,
"removed": "0",
"status": "missing",
}
if not dry_run:
subprocess.run(
["git", "-C", str(repo_root), "remote", "remove", remote_id],
check=True,
)
return {
"repo_root": str(repo_root),
"remote": remote_id,
"url": safe_remote_url_label(remote_url),
"removed": "1",
"status": "dry-run" if dry_run else "removed",
}
def run_tokens_remove(config: WorkspaceConfig, args: argparse.Namespace) -> None:
if args.target == "store":
result = remove_token_from_store(config, args.remote_id, args.server, args.dry_run)
print_token_action_result(result)
return
if args.target == "remote":
result = remove_git_remote(resolve_launcher_repo_argument(args.repo), args.remote_id, args.dry_run)
print_token_action_result(result)
return
if args.target == "both":
repo_path = resolve_launcher_repo_argument(args.repo)
if not args.dry_run:
remove_token_from_store(config, args.remote_id, args.server, dry_run=True)
remote_result = remove_git_remote(repo_path, args.remote_id, args.dry_run)
print_token_action_result(remote_result)
print()
store_result = remove_token_from_store(config, args.remote_id, args.server, args.dry_run)
print_token_action_result(store_result)
return
raise SystemExit(f"Unsupported tokens remove target: {args.target}")
def run_tokens_sync(config: WorkspaceConfig, args: argparse.Namespace) -> None:
repo_path = resolve_launcher_repo_argument(args.repo)
if args.direction == "remote":
result = sync_token_from_remote(config, repo_path, args.remote_id, args.dry_run)
print_token_action_result(result)
return
if args.direction == "store":
args.repo = str(repo_path)
args.remote = args.remote_id
args.token_name = args.remote_id
args.auto_rename_origin = True
run_tokens_write(config, args)
return
raise SystemExit(f"Unsupported tokens sync direction: {args.direction}")
def run_tokens_add(config: WorkspaceConfig, args: argparse.Namespace) -> None:
server_url = (args.server or config.git.base_url).rstrip("/")
server_info = server_info_from_url(config, server_url)
if server_info is None:
raise SystemExit(f"Only http/https server URLs are supported: {server_url}")
token_data = load_token_store(config)
remote_id = args.token_id
if args.remote and args.remote != remote_id:
raise SystemExit("Token id must match --remote because tokens.json is keyed 1:1 by git remote name.")
existing_token = find_token_record(token_data, server_info["endpoint"], remote_id)
if existing_token is not None:
raise SystemExit(f"Token already exists in tokens.json: {server_info['endpoint']}:{remote_id}")
token_record = {
"id": remote_id,
"value": args.value or "",
"server": server_record_from_info(server_info),
"org": args.org or "",
"repo": args.repo_name or "",
}
if args.user:
token_record["user"] = args.user
print(f"token_path\t{config.token_path}")
print(f"server\t{server_info['endpoint']}")
print(f"id\t{remote_id}")
print(f"value\t{'set' if token_record['value'] else 'empty'}")
print(f"remote\t{remote_id}")
print(f"status\t{'dry-run' if args.dry_run else 'added'}")
if args.dry_run:
return
token_data.setdefault("tokens", []).append(token_record)
write_token_store(config, token_data)
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
target_endpoint = None
if target_url:
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}")
target_endpoint = target_server_info["endpoint"]
token_record = resolve_token_from_store(
token_data,
args.server or target_endpoint,
args.token_name or args.remote,
args.user,
)
server_endpoint = token_server_endpoint(token_record)
remote_id = token_record_id(token_record)
user_name = str(token_record.get("user", ""))
token_value = str(token_record.get("value", ""))
if not token_value:
raise SystemExit(f"Token value is empty in tokens.json: {server_endpoint}:{remote_id}")
if not user_name:
raise SystemExit(f"Token user is empty in tokens.json: {server_endpoint}:{remote_id}")
if not target_url:
target_url = remote_url_from_token_record(token_record)
if not target_url:
raise SystemExit(
f"Missing target remote URL and tokens.json has no org/repo for: {server_endpoint}:{remote_id}"
)
rename_candidate = None
if getattr(args, "auto_rename_origin", False) and existing_remote_url is None:
rename_candidate = find_origin_rename_candidate(config, repo_root, args.remote, target_url)
if rename_candidate:
_, existing_remote_url = rename_candidate
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 = "renamed" if rename_candidate else "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}")
if rename_candidate:
print(f"renamed_from\t{rename_candidate[0]}")
print(f"server\t{server_endpoint}")
print(f"user\t{user_name}")
print(f"id\t{remote_id}")
print(f"status\t{status}")
print(f"url\t{sanitize_remote_url(final_url)[0]}")
if args.dry_run or status == "unchanged":
return
if rename_candidate:
subprocess.run(
["git", "-C", str(repo_root), "remote", "rename", rename_candidate[0], args.remote],
check=True,
)
set_git_remote(repo_root, args.remote, final_url)
def run_tokens_update(config: WorkspaceConfig, args: argparse.Namespace) -> None:
if args.remote_id:
result = hydrate_token_from_api(config, args.remote_id, args.server, args.dry_run)
print_token_action_result(result)
return
if args.from_source == "remotes":
if args.dry_run:
run_tokens_scan(config, args)
return
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, write_normalized=False)
store_servers = store_servers_from_tokens(token_data)
endpoint_names = sorted(set(repo_servers) | set(store_servers))
print_token_item_rows(endpoint_names, repo_servers, store_servers, report["remote_rows"])
return
if args.from_source == "store":
run_tokens_write(config, args)
return
raise SystemExit("Pass REMOTE_ID for API metadata refresh or use legacy --from remotes|store.")
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 source_repo_name
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 answer_url_for_card(config: WorkspaceConfig, repo_name: str) -> str:
return f"{config.git.base_url}/{config.git.answer_org}/{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 source_url_for_card(config: WorkspaceConfig, series_name: str, source_card_path: Path) -> str:
source_url = git_capture(source_card_path, ["remote", "get-url", config.git.source_remote])
if source_url:
return source_url
source_url = git_capture(source_card_path, ["remote", "get-url", config.git.origin_remote])
if source_url:
return source_url
return f"{config.git.base_url}/{config.git.source_org}/{source_card_path.name}.git"
def source_url_for_card_info(config: WorkspaceConfig, card_info: CardInfo) -> str:
card_config = config_for_card(config, card_info)
if card_info.source_path:
return source_url_for_card(card_config, card_info.series_id, card_info.source_path)
return f"{card_config.git.base_url}/{card_info.source_org}/{card_info.repo}.git"
def ensure_answer_token_from_source(
config: WorkspaceConfig,
source_remote: str,
answer_remote: str,
answer_url: str,
dry_run: bool = False,
) -> tuple[str, dict]:
token_data = load_token_store(config, write_normalized=not dry_run)
answer_server_info = server_info_from_url(config, answer_url)
if answer_server_info is None:
raise SystemExit(f"Only http/https remote URLs are supported: {answer_url}")
source_token = resolve_token_from_store(token_data, answer_server_info["endpoint"], source_remote, None)
if dry_run:
return "dry-run", source_token
status = register_token(
token_data,
answer_server_info,
answer_remote,
str(source_token.get("user", "")),
str(source_token.get("value", "")),
hydrate=False,
reset_api_metadata=True,
)
write_token_store(config, token_data)
return status, source_token
def workspace_card_path(config: WorkspaceConfig, series_name: str, source_card_path: Path) -> Path:
return config.series_root / series_name / source_card_path.name
def workspace_card_path_for_info(config: WorkspaceConfig, card_info: CardInfo) -> Path:
return config.series_root / card_info.series_id / card_info.repo
def resolve_workspace_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path:
card_info = resolve_card_info(config, series_name, card_name)
card_path = workspace_card_path_for_info(config, card_info)
if not card_path.is_dir():
raise SystemExit(f"Missing workspace card: {card_path}. Run: ./rvctl series cards fetch {series_name} {card_name}")
return card_path
def fetch_card_to_workspace(config: WorkspaceConfig, series_name: str, card_name: str, dry_run: bool = False) -> dict[str, str]:
card_info = resolve_card_info(config, series_name, card_name)
card_config = config_for_card(config, card_info)
repo_name = card_info.repo
target_path = workspace_card_path_for_info(config, card_info)
source_url = source_url_for_card_info(config, card_info)
answer_url = answer_url_for_card(card_config, repo_name)
source_branch = card_info.branch
if not dry_run:
target_path.parent.mkdir(parents=True, exist_ok=True)
if target_path.exists() and git_repo_root(target_path) is None:
raise SystemExit(f"Workspace card path exists but is not a git repo: {target_path}")
cloned = False
if not target_path.exists():
subprocess.run(["git", "clone", source_url, str(target_path)], check=True)
cloned = True
origin_url = git_capture(target_path, ["remote", "get-url", card_config.git.origin_remote])
if origin_url and git_capture(target_path, ["remote", "get-url", card_config.git.source_remote]) is None:
subprocess.run(
["git", "-C", str(target_path), "remote", "rename", card_config.git.origin_remote, card_config.git.source_remote],
check=True,
)
set_git_remote(target_path, card_config.git.source_remote, source_url)
subprocess.run(["git", "-C", str(target_path), "fetch", card_config.git.source_remote, source_branch], check=True)
current_branch = git_current_branch(card_config, target_path)
if cloned or current_branch == source_branch:
if current_branch != source_branch:
local_branch_exists = subprocess.run(
["git", "-C", str(target_path), "rev-parse", "--verify", "--quiet", source_branch],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0
if local_branch_exists:
subprocess.run(["git", "-C", str(target_path), "switch", source_branch], check=True)
else:
subprocess.run(
["git", "-C", str(target_path), "switch", "--track", "-c", source_branch, f"{card_config.git.source_remote}/{source_branch}"],
check=True,
)
subprocess.run(["git", "-C", str(target_path), "pull", "--ff-only", card_config.git.source_remote, source_branch], check=True)
token_status, source_token = ensure_answer_token_from_source(
card_config,
card_config.git.source_remote,
card_config.git.answer_remote,
answer_url,
dry_run=dry_run,
)
answer_remote_status = "dry-run"
if not dry_run:
answer_remote_url = credentialed_remote_url(
answer_url,
str(source_token.get("user", "")),
str(source_token.get("value", "")),
)
answer_remote_status = set_git_remote(target_path, card_config.git.answer_remote, answer_remote_url)
return {
"series": series_name,
"card": card_info.card_id,
"repo": repo_name,
"source": source_url,
"workspace": str(target_path),
"source_remote": card_config.git.source_remote,
"source_branch": source_branch,
"answer_remote": card_config.git.answer_remote,
"answer_org": card_config.git.answer_org,
"answer_repo": repo_name,
"answer_url": answer_url,
"token": card_config.git.answer_remote,
"token_status": token_status,
"answer_remote_status": answer_remote_status,
"status": "dry-run" if dry_run else "ready",
}
def print_key_values(values: dict[str, str]) -> None:
for key, value in values.items():
print(f"{key}\t{value}")
def task_name_from_path(path: Path) -> str:
if path.is_file():
return path.stem
return path.name
def task_entries(card_path: Path) -> list[TaskEntry]:
entries: dict[str, TaskEntry] = {}
task_root = card_path / "src" / "tasks"
if task_root.is_dir():
for path in sorted(task_root.iterdir()):
name = task_name_from_path(path)
if (path.is_file() or path.is_dir()) and name.startswith("task"):
entries[name] = TaskEntry(name=name, path=path)
for path in sorted(card_path.iterdir()):
name = task_name_from_path(path)
if (path.is_file() or path.is_dir()) and name.startswith("task"):
entries.setdefault(name, TaskEntry(name=name, path=path))
return [entries[name] for name in sorted(entries)]
def task_names(card_path: Path) -> list[str]:
return [entry.name for entry in task_entries(card_path)]
def task_branch_part(task_name: str) -> str:
match = re.search(r"(?i)task[-_]?(\d+)", task_name)
if match is None:
match = re.fullmatch(r"(?i)t(\d+)", task_name.strip())
if match is None:
match = re.search(r"(\d+)", task_name)
if match is None:
raise SystemExit(f"Task name does not contain a task number: {task_name}")
return f"T{int(match.group(1))}"
def resolve_submission_task(config: WorkspaceConfig, card_path: Path, task_arg: str | None) -> str:
task_selector = task_arg or config.defaults.get("task")
if not task_selector:
raise SystemExit("Missing task selector. Pass --task or set defaults.task in workspace.json.")
names = task_names(card_path)
if not names:
return task_selector
matches = [name for name in names if name == task_selector]
if not matches:
try:
selector_part = task_branch_part(task_selector)
except SystemExit:
selector_part = ""
number_matches = []
for name in names:
try:
if selector_part and task_branch_part(name) == selector_part:
number_matches.append(name)
except SystemExit:
pass
matches = number_matches
if not matches:
matches = [name for name in names if task_selector in name]
if not matches:
raise SystemExit(f"Missing task '{task_selector}' in card: {card_path}")
if len(matches) > 1:
raise SystemExit(f"Ambiguous task '{task_selector}'. Matches: {', '.join(matches)}")
return matches[0]
def store_user_for_submission(
config: WorkspaceConfig,
source_url: str,
answer_url: str,
required: bool = True,
) -> str:
token_data = load_token_store(config)
endpoints = []
for remote_url in [source_url, answer_url, config.git.base_url]:
server_info = server_info_from_url(config, remote_url)
if server_info is None:
continue
endpoint = server_info["endpoint"]
if endpoint not in endpoints:
endpoints.append(endpoint)
for endpoint in endpoints:
for remote_id in [config.git.source_remote, config.git.answer_remote]:
token_record = find_token_record(token_data, endpoint, remote_id)
if token_record and token_record.get("user"):
return slug_value(str(token_record["user"]), "token user")
users = {
str(token_record.get("user", ""))
for token_record in token_data.get("tokens", [])
if token_server_endpoint(token_record) in endpoints and token_record.get("user")
}
if len(users) == 1:
return slug_value(next(iter(users)), "token user")
if required:
raise SystemExit("Missing --nick and no unique user in tokens.json for the answer branch.")
return ""
def submission_branch_name(user_name: str, task_name: str) -> str:
return branch_value(f"{slug_value(user_name, 'student user')}{task_branch_part(task_name)}a")
def set_branch_upstream(repo_path: Path, branch_name: str, remote_name: str, dry_run: bool = False) -> str:
expected_merge = f"refs/heads/{branch_name}"
current_remote = git_capture(repo_path, ["config", "--get", f"branch.{branch_name}.remote"])
current_merge = git_capture(repo_path, ["config", "--get", f"branch.{branch_name}.merge"])
if current_remote == remote_name and current_merge == expected_merge:
return "unchanged"
if dry_run:
return "dry-run"
subprocess.run(["git", "-C", str(repo_path), "config", f"branch.{branch_name}.remote", remote_name], check=True)
subprocess.run(["git", "-C", str(repo_path), "config", f"branch.{branch_name}.merge", expected_merge], check=True)
return "updated" if current_remote or current_merge else "set"
def print_card_tasks(config: WorkspaceConfig, series_name: str, card_name: str) -> None:
card_path = resolve_workspace_card_path(config, series_name, card_name)
for entry in task_entries(card_path):
print(f"{series_name}\t{card_label_from_repo_name(card_path.name)}\t{entry.name}\t{entry.path}")
def print_card_task(config: WorkspaceConfig, series_name: str, card_name: str, task_name: str) -> None:
card_path = resolve_workspace_card_path(config, series_name, card_name)
matches = [entry for entry in task_entries(card_path) if entry.name == task_name or task_name in entry.name]
if not matches:
raise SystemExit(f"Missing task '{task_name}' in card: {card_path}")
if len(matches) > 1:
raise SystemExit(f"Ambiguous task '{task_name}'. Matches: {', '.join(entry.name for entry in matches)}")
task_entry = matches[0]
print(f"series\t{series_name}")
print(f"card\t{card_label_from_repo_name(card_path.name)}")
print(f"task\t{task_entry.name}")
print(f"card_path\t{card_path}")
print(f"task_path\t{task_entry.path}")
def switch_card_task(
config: WorkspaceConfig,
series_name: str,
card_name: str,
task_selector: str,
branch_override: str | None = None,
dry_run: bool = False,
) -> dict[str, str]:
card_info = resolve_card_info(config, series_name, card_name)
card_config = config_for_card(config, card_info)
card_path = resolve_workspace_card_path(config, series_name, card_name)
task_name = resolve_submission_task(config, card_path, task_selector)
source_url = discover_source_url(card_config, card_path, series_name, card_name)
source_repo_name = repo_name_from_url(source_url)
answer_url = build_answer_url(card_config, source_repo_name)
student_user = store_user_for_submission(card_config, source_url, answer_url, required=branch_override is None)
branch_name = branch_value(branch_override) if branch_override else submission_branch_name(student_user, task_name)
current_branch = git_current_branch(card_config, card_path)
branch_exists = subprocess.run(
["git", "-C", str(card_path), "rev-parse", "--verify", "--quiet", branch_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0
token_status = "dry-run"
answer_remote_status = "dry-run"
if not dry_run:
token_status, source_token = ensure_answer_token_from_source(
card_config,
card_config.git.source_remote,
card_config.git.answer_remote,
answer_url,
)
answer_remote_status = set_git_remote(
card_path,
card_config.git.answer_remote,
credentialed_remote_url(
answer_url,
str(source_token.get("user", "")),
str(source_token.get("value", "")),
),
)
if dry_run:
status = "dry-run"
elif current_branch == branch_name:
status = "unchanged"
elif branch_exists:
subprocess.run(["git", "-C", str(card_path), "switch", branch_name], check=True)
status = "switched"
else:
subprocess.run(["git", "-C", str(card_path), "switch", "-c", branch_name], check=True)
status = "created"
upstream_status = set_branch_upstream(card_path, branch_name, card_config.git.answer_remote, dry_run=dry_run)
return {
"series": series_name,
"card": card_info.card_id,
"task": task_name,
"student_user": student_user,
"branch": branch_name,
"answer_remote": card_config.git.answer_remote,
"answer_url": answer_url,
"token_status": token_status,
"answer_remote_status": answer_remote_status,
"upstream": f"{card_config.git.answer_remote}/{branch_name}",
"upstream_status": upstream_status,
"workspace": str(card_path),
"status": status,
}
def print_series_details(config: WorkspaceConfig, series_name: str) -> None:
config.series_root.mkdir(parents=True, exist_ok=True)
source_path = config.original_series_root / series_name
workspace_path = config.series_root / series_name
cards = source_card_infos(config, series_name)
if not cards and not workspace_path.is_dir():
raise SystemExit(f"Missing series: {series_name}")
print(f"series\t{series_name}")
print(f"source_path\t{source_path if source_path.is_dir() else ''}")
print(f"manifest\t{config.workspace_info_path if workspace_series_manifest(config, series_name) else ''}")
print(f"workspace_path\t{workspace_path}")
print(f"cards_count\t{len(cards)}")
print(f"cards\t{' '.join(card.card_id for card in cards)}")
def print_card_details(config: WorkspaceConfig, series_name: str, card_name: str) -> None:
card_info = resolve_card_info(config, series_name, card_name)
card_config = config_for_card(config, card_info)
target_path = workspace_card_path_for_info(config, card_info)
repo_name = card_info.repo
print(f"series\t{series_name}")
print(f"card\t{card_info.card_id}")
print(f"repo\t{repo_name}")
print(f"source_path\t{card_info.source_path or ''}")
print(f"workspace_path\t{target_path}")
print(f"source_remote\t{card_config.git.source_remote}")
print(f"source_url\t{source_url_for_card_info(config, card_info)}")
print(f"source_branch\t{card_info.branch}")
print(f"answer_remote\t{card_config.git.answer_remote}")
print(f"answer_url\t{answer_url_for_card(card_config, repo_name)}")
if target_path.is_dir():
print(f"tasks\t{' '.join(task_names(target_path))}")
def available_series_hint(config: WorkspaceConfig) -> str:
names = sorted(
set(workspace_series_manifests(config))
| {path.name for path in source_series_directories(config)}
| {path.name for path in series_directories(config)}
)
return ", ".join(names)
def use_series_default(config: WorkspaceConfig, series_name: str) -> dict[str, str]:
series_id = slug_value(series_name, "series")
if not series_exists(config, series_id):
hint = available_series_hint(config)
suffix = f" Available: {hint}" if hint else ""
raise SystemExit(f"Missing series '{series_name}'.{suffix}")
return update_config_defaults(config, {"series": series_id})
def use_card_default(config: WorkspaceConfig, selector: list[str]) -> dict[str, str]:
if len(selector) == 1:
series_name = config.defaults.get("series")
if not series_name:
raise SystemExit("Missing default series. Use: ./rvctl series use inf")
card_name = selector[0]
updates_series = False
elif len(selector) == 2:
series_name = slug_value(selector[0], "series")
card_name = selector[1]
updates_series = True
else:
raise SystemExit("Use: ./rvctl card use <card> or ./rvctl card use <series> <card>")
if not series_exists(config, series_name):
hint = available_series_hint(config)
suffix = f" Available: {hint}" if hint else ""
raise SystemExit(f"Missing series '{series_name}'.{suffix}")
card_info = resolve_card_info(config, series_name, card_name)
updates = {"card": card_info.card_id}
if updates_series:
updates = {"series": series_name, "card": card_info.card_id}
return update_config_defaults(config, updates)
def run_series(config: WorkspaceConfig, args: argparse.Namespace) -> None:
if args.series_command == "list":
print_series(config)
return
if args.series_command == "use":
print_key_values(use_series_default(config, args.series))
return
if args.series_command == "show":
print_series_details(config, args.series)
return
if args.series_command == "fetch":
for card_info in source_card_infos(config, args.series):
result = fetch_card_to_workspace(config, args.series, card_info.card_id, dry_run=args.dry_run)
print_key_values(result)
print()
return
if args.series_command != "cards":
raise SystemExit(f"Unsupported series command: {args.series_command}")
if args.cards_command == "list":
print_cards(config, args.series)
return
if args.cards_command == "show":
print_card_details(config, args.series, args.card)
return
if args.cards_command == "fetch":
print_key_values(fetch_card_to_workspace(config, args.series, args.card, dry_run=args.dry_run))
return
if args.cards_command == "submission":
print_submission_plan(config, args)
return
if args.cards_command != "tasks":
raise SystemExit(f"Unsupported series cards command: {args.cards_command}")
if args.tasks_command == "list":
print_card_tasks(config, args.series, args.card)
return
if args.tasks_command == "show":
print_card_task(config, args.series, args.card, args.task)
return
if args.tasks_command == "switch":
print_key_values(switch_card_task(config, args.series, args.card, args.task, args.branch, args.dry_run))
return
raise SystemExit(f"Unsupported series cards tasks command: {args.tasks_command}")
def run_card(config: WorkspaceConfig, args: argparse.Namespace) -> None:
if args.card_command == "use":
print_key_values(use_card_default(config, args.selector))
return
raise SystemExit(f"Unsupported card command: {args.card_command}")
def resolve_task_command_selector(
config: WorkspaceConfig,
values: list[str],
needs_task: bool = False,
) -> tuple[str, str, str | None]:
if not needs_task:
if len(values) > 2:
raise SystemExit("Pass at most '<series> <card>' for tasks list.")
series_arg = values[0] if values else None
card_arg = values[1] if len(values) == 2 else None
series_name, card_name = resolve_selector(config, series_arg, card_arg)
return series_name, card_name, None
if not values:
raise SystemExit("Missing task. Use: ./rvctl tasks switch 4")
if len(values) > 3:
raise SystemExit("Pass '<task>', '<card> <task>' or '<series> <card> <task>'.")
if len(values) == 1:
series_name, card_name = resolve_selector(config, None, None)
return series_name, card_name, values[0]
if len(values) == 2:
series_name, card_name = resolve_selector(config, values[0], None)
return series_name, card_name, values[1]
series_name, card_name = resolve_selector(config, values[0], values[1])
return series_name, card_name, values[2]
def run_tasks(config: WorkspaceConfig, args: argparse.Namespace) -> None:
if args.tasks_command == "list":
series_name, card_name, _ = resolve_task_command_selector(config, args.selector)
print_card_tasks(config, series_name, card_name)
return
if args.tasks_command == "show":
series_name, card_name, task_name = resolve_task_command_selector(config, args.selector, needs_task=True)
print_card_task(config, series_name, card_name, task_name or "")
return
if args.tasks_command == "switch":
series_name, card_name, task_name = resolve_task_command_selector(config, args.selector, needs_task=True)
print_key_values(switch_card_task(config, series_name, card_name, task_name or "", args.branch, args.dry_run))
return
raise SystemExit(f"Unsupported tasks command: {args.tasks_command}")
def print_submission_plan(config: WorkspaceConfig, args: argparse.Namespace) -> None:
series_name, card_name = resolve_selector(config, args.series, args.card)
card_info = resolve_card_info(config, series_name, card_name)
card_config = config_for_card(config, card_info)
selector = f"{series_name}/{card_name}"
card_path = resolve_card_path(config, series_name, card_name)
sync_tokens_from_repo(card_config, card_path)
class_name = slug_value(args.class_name, "class")
source_url = args.source_url or discover_source_url(card_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(card_config, answer_repo_name)
source_branch = git_current_branch(card_config, card_path)
task_name = resolve_submission_task(config, card_path, args.task)
student_user = (
slug_value(args.nick, "student user")
if args.nick
else store_user_for_submission(card_config, source_url, answer_url, required=args.branch is None)
)
branch_name = branch_value(args.branch) if args.branch else submission_branch_name(student_user, task_name)
print(f"selector\t{selector}")
print(f"card_path\t{card_path}")
print(f"task\t{task_name}")
print(f"source_repo\t{card_config.git.source_org}/{source_repo_name}")
print(f"source_remote\t{card_config.git.source_remote}")
print(f"source_url\t{source_url}")
print(f"source_branch\t{source_branch}")
print(f"answer_remote\t{card_config.git.answer_remote}")
print(f"answer_repo\t{card_config.git.answer_org}/{answer_repo_name}")
print(f"answer_url\t{answer_url}")
print(f"student_user\t{student_user}")
print(f"student_branch\t{branch_name}")
if args.apply:
source_status = set_git_remote(card_path, card_config.git.source_remote, source_url)
token_status, source_token = ensure_answer_token_from_source(
card_config,
card_config.git.source_remote,
card_config.git.answer_remote,
answer_url,
)
answer_status = set_git_remote(
card_path,
card_config.git.answer_remote,
credentialed_remote_url(
answer_url,
str(source_token.get("user", "")),
str(source_token.get("value", "")),
),
)
print(f"apply_{card_config.git.source_remote}\t{source_status}")
print(f"apply_token_{card_config.git.answer_remote}\t{token_status}")
print(f"apply_{card_config.git.answer_remote}\t{answer_status}")
print("commands")
print(f" git -C {shlex.quote(str(card_path))} remote add {card_config.git.source_remote} {shlex.quote(source_url)}")
print(f" git -C {shlex.quote(str(card_path))} remote add {card_config.git.answer_remote} {shlex.quote(answer_url)}")
print(f" git -C {shlex.quote(str(card_path))} fetch {card_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 {card_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 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)
profile = normalize_env_profile(args.profile)
task_name = resolve_submission_task(config, card_path, None)
instance = args.instance or default_env_instance(profile, series_name, card_name, task_name, "shell")
window_name = args.window or config.defaults.get("tmux_window", "rv")
session_name = args.session or slug_value(f"stem-{instance}", "tmux session")
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."
)
# Compatibility command: keep the outer host tmux session, but delegate
# container creation/mounts/labels entirely to the canonical rootless
# ./stem runtime. Do not maintain a second Docker Compose lifecycle here.
shell_command, details = env_action_command(
config,
profile,
"shell",
series_name=series_name,
card_name=card_name,
task_name=task_name,
instance=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("Runtime: rootless stem/Podman")
print("Instance:", instance)
print("Pane 0 command:")
print(shell_command)
print("tmux command:")
print(shlex.join(tmux_command))
print_env_plan(details)
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 normalize_env_profile(profile: str) -> str:
normalized = ENV_PROFILE_ALIASES.get(profile, profile)
if normalized not in ENV_PROFILES:
available = ", ".join(sorted(ENV_PROFILES))
raise SystemExit(f"Unsupported environment profile '{profile}'. Available: {available}")
return normalized
def env_tool_command_path(config: WorkspaceConfig) -> Path:
stem_script = config.tools_root / "stem"
return stem_script if stem_script.is_file() else config.tools_root / "rv"
def ensure_env_tool(config: WorkspaceConfig) -> Path:
env_script = env_tool_command_path(config)
if env_script.is_file():
return env_script
candidates = "\n".join(f" - {path}" for path in config.tools_root_candidates)
raise SystemExit(
"Missing environment launcher script 'stem' (or compatible 'rv').\n"
f"Checked:\n{candidates}\n"
"Run: ./stemctl env sync"
)
def env_sync_target(config: WorkspaceConfig) -> Path:
return config.workspace_root / "tools" / "rv32i-hazard3-student-env"
def sync_env_tool(config: WorkspaceConfig, dry_run: bool = False) -> dict[str, str]:
target = env_sync_target(config)
if dry_run:
command = "pull" if (target / ".git").is_dir() else "clone"
return {
"env_tool_path": str(target),
"env_tool_url": config.env_tool_url,
"command": command,
"status": "dry-run",
}
target.parent.mkdir(parents=True, exist_ok=True)
if (target / ".git").is_dir():
subprocess.run(["git", "-C", str(target), "pull", "--ff-only"], check=True)
config.tools_root = target
return {
"env_tool_path": str(target),
"env_tool_url": config.env_tool_url,
"command": "pull",
"status": "ready",
}
if target.exists():
raise SystemExit(f"Environment tool path exists but is not a git repo: {target}")
subprocess.run(["git", "clone", config.env_tool_url, str(target)], check=True)
config.tools_root = target
return {
"env_tool_path": str(target),
"env_tool_url": config.env_tool_url,
"command": "clone",
"status": "ready",
}
def task_selector_like(value: str) -> bool:
return bool(re.fullmatch(r"(?i)(task[-_]?)?\d+.*|t\d+", value.strip()))
def resolve_env_target(
config: WorkspaceConfig,
values: list[str],
task_override: str | None = None,
) -> tuple[str, str, str]:
if len(values) > 3:
raise SystemExit("Pass '<task>', '<card> [task]' or '<series> <card> [task]'.")
if not values:
series_name, card_name = resolve_selector(config, None, None)
card_path = resolve_workspace_card_path(config, series_name, card_name)
task_name = resolve_submission_task(config, card_path, task_override)
return series_name, card_name, task_name
if len(values) == 1:
if task_selector_like(values[0]):
series_name, card_name = resolve_selector(config, None, None)
task_selector = task_override or values[0]
else:
series_name, card_name = resolve_selector(config, values[0], None)
task_selector = task_override
card_path = resolve_workspace_card_path(config, series_name, card_name)
task_name = resolve_submission_task(config, card_path, task_selector)
return series_name, card_name, task_name
if len(values) == 2:
if task_selector_like(values[1]):
series_name, card_name = resolve_selector(config, values[0], None)
task_selector = task_override or values[1]
else:
series_name, card_name = resolve_selector(config, values[0], values[1])
task_selector = task_override
card_path = resolve_workspace_card_path(config, series_name, card_name)
task_name = resolve_submission_task(config, card_path, task_selector)
return series_name, card_name, task_name
series_name, card_name = resolve_selector(config, values[0], values[1])
card_path = resolve_workspace_card_path(config, series_name, card_name)
task_name = resolve_submission_task(config, card_path, task_override or values[2])
return series_name, card_name, task_name
def split_pane_selector(values: list[str], explicit_pane: str | None = None) -> tuple[list[str], str | None]:
if "pane" not in values:
return values, explicit_pane
if explicit_pane:
raise SystemExit("Pass pane target either as '--pane TARGET' or as 'pane TARGET', not both.")
index = values.index("pane")
pane_values = values[index + 1 :]
if len(pane_values) != 1:
raise SystemExit("Use: pane TARGET, for example 'pane 0' or 'pane node:0'.")
return values[:index], pane_values[0]
def tmux_pane_target(raw_target: str) -> str:
target = raw_target.strip()
if not target:
raise SystemExit("Empty tmux pane target.")
if re.fullmatch(r"\d+", target):
return f":.{target}"
if re.fullmatch(r"%\d+", target):
return target
window_pane_match = re.fullmatch(r"([^:.]+):(\d+)", target)
if window_pane_match:
window_name, pane_index = window_pane_match.groups()
return f":{window_name}.{pane_index}"
return target
def send_command_to_tmux_pane(shell_command: str, raw_target: str, dry_run: bool = False) -> dict[str, str]:
target = tmux_pane_target(raw_target)
details = {
"pane": raw_target,
"tmux_target": target,
"tmux_send": f"tmux send-keys -t {shlex.quote(target)} -l {shlex.quote(shell_command)}; tmux send-keys -t {shlex.quote(target)} C-m",
}
if dry_run:
return details
resolved = subprocess.run(
["tmux", "display-message", "-p", "-t", target, "#{session_name}:#{window_name}.#{pane_index}"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
if resolved.returncode != 0:
raise SystemExit(f"Missing tmux pane target '{raw_target}' ({target}): {resolved.stderr.strip()}")
subprocess.run(["tmux", "send-keys", "-t", target, "-l", shell_command], check=True)
subprocess.run(["tmux", "send-keys", "-t", target, "C-m"], check=True)
details["tmux_resolved"] = resolved.stdout.strip()
return details
def default_env_instance(profile: str, series_name: str, card_name: str, task_name: str, action: str) -> str:
task_part = task_branch_part(task_name).lower()
del action
return slug_value(f"{profile}-{series_name}-{card_name}-{task_part}", "environment instance")
def env_action_command(
config: WorkspaceConfig,
profile: str,
action: str,
series_name: str | None = None,
card_name: str | None = None,
task_name: str | None = None,
instance: str | None = None,
editor: str | None = None,
target: str | None = None,
device: str | None = None,
deploy_backend: str | None = None,
allow_existing_firmware: bool = False,
) -> tuple[str, dict[str, str]]:
profile = normalize_env_profile(profile)
env_script = ensure_env_tool(config)
selected_target = target or PROFILE_DEFAULT_TARGETS[profile]
env_vars = {
"STEM_WORKSPACE_ROOT": str(config.workspace_root),
"STEM_CARDS_ROOT": str(config.series_root),
"STEM_SOCKET_ROOT": str(config.socket_root),
"STEM_PROFILE": profile,
"STEM_TARGET": selected_target,
"WORKSPACE_ROOT": str(config.workspace_root),
"RV_CARDS_ROOT": str(config.series_root),
"RV_PROFILE": profile,
}
command_args = ["bash", f"./{env_script.name}"]
card_path: Path | None = None
selector = ""
if series_name and card_name:
card_path = resolve_workspace_card_path(config, series_name, card_name)
selector = f"{series_name}/{card_name}"
env_vars.update(
{
"STEM_REPO_PATH": str(card_path),
"STEM_REPO_HOST": str(card_path),
"STEM_CARD": card_path.name,
"RV_REPO_PATH": str(card_path),
"RV_REPO_HOST": str(card_path),
"RV_CARD": card_path.name,
}
)
if instance:
env_vars["STEM_INSTANCE"] = instance
env_vars["RV_INSTANCE"] = instance
elif series_name and card_name and task_name:
generated_instance = default_env_instance(profile, series_name, card_name, task_name, action)
env_vars["STEM_INSTANCE"] = generated_instance
env_vars["RV_INSTANCE"] = generated_instance
if editor:
env_vars["STEM_EDITOR"] = editor
env_vars["RV_EDITOR"] = editor
if device:
env_vars["STEM_PROBE_DEVICE"] = device
if deploy_backend:
env_vars["STEM_DEPLOY_BACKEND"] = deploy_backend
if action in {"build", "test", "run", "debug", "deploy"}:
if not task_name:
raise SystemExit(f"{action} requires a task selector.")
command_args.extend([action, "--target", selected_target, task_name])
if device:
command_args.extend(["--device", device])
if action == "deploy" and deploy_backend:
command_args.extend(["--backend", deploy_backend])
if allow_existing_firmware:
command_args.append("--allow-existing-firmware")
elif action in {"shell", "start", "status", "attach", "stop", "rm"}:
command_args.extend([action, "--target", selected_target])
else:
raise SystemExit(f"Unsupported environment action: {action}")
env_prefix = " ".join(f"{key}={shlex.quote(value)}" for key, value in env_vars.items())
shell_command = " && ".join(
[
f"cd {shlex.quote(str(env_script.parent))}",
" ".join([env_prefix, *[shlex.quote(part) for part in command_args]]),
]
)
details = {
"profile": profile,
"service": ENV_PROFILES[profile]["service"],
"target": selected_target,
"selector": selector,
"task": task_name or "",
"card_path": str(card_path or ""),
"tools_root": str(config.tools_root),
"instance": env_vars.get("RV_INSTANCE", ""),
"device": device or "",
"backend": deploy_backend or "",
"command": shell_command,
}
return shell_command, details
def print_env_plan(details: dict[str, str]) -> None:
keys = [
"profile",
"service",
"target",
"selector",
"task",
"card_path",
"tools_root",
"instance",
"device",
"backend",
"pane",
"tmux_target",
"tmux_resolved",
"tmux_send",
"command",
]
for key in keys:
if key == "command" or details.get(key):
print(f"{key}\t{details.get(key, '')}")
def run_env(config: WorkspaceConfig, args: argparse.Namespace) -> None:
if args.env_command == "list":
print("profile\tservice\timage\tstatus\tpurpose")
for profile, info in ENV_PROFILES.items():
print(
f"{profile}\t{info['service']}\t{info['image']}\t"
f"{'ready' if env_tool_command_path(config).is_file() else 'missing'}\t{info['purpose']}"
)
print(f"tools_root\t{config.tools_root}")
print(f"env_tool_url\t{config.env_tool_url}")
return
if args.env_command == "sync":
print_key_values(sync_env_tool(config, dry_run=args.dry_run))
return
if args.env_command == "sources":
series_name, card_name = resolve_selector(config, args.series, args.card)
card_path = resolve_workspace_card_path(config, series_name, card_name)
env_script = ensure_env_tool(config)
preparer = env_script.parent / "scripts" / "prepare-workspace-sources.py"
if not preparer.is_file():
raise SystemExit(
f"Environment tool does not provide pinned workspace sources: {preparer}. "
"Run: ./stemctl env sync"
)
mode = "check" if args.check else "prepare"
command = [str(preparer), mode, str(card_path)]
if args.dry_run:
print(f"series\t{series_name}")
print(f"card\t{card_name}")
print(f"workspace\t{card_path}")
print(f"command\t{shlex.join(command)}")
return
subprocess.run(command, check=True)
return
if args.env_command == "build":
profile = normalize_env_profile(args.profile)
env_script = ensure_env_tool(config)
shell_command = " && ".join(
[
f"cd {shlex.quote(str(env_script.parent))}",
" ".join(
[
f"STEM_PROFILE={shlex.quote(profile)}",
f"STEM_WORKSPACE_ROOT={shlex.quote(str(config.workspace_root))}",
f"STEM_SOCKET_ROOT={shlex.quote(str(config.socket_root))}",
"bash",
shlex.quote(f"./{env_script.name}"),
"profile-build",
shlex.quote(profile),
]
),
]
)
if args.dry_run:
print(f"profile\t{profile}")
print(f"command\t{shell_command}")
return
subprocess.run(["bash", "-lc", shell_command], check=True)
return
if args.env_command == "ensure":
profile = normalize_env_profile(args.profile)
env_script = ensure_env_tool(config)
env_vars = os.environ.copy()
env_vars.update(
{
"STEM_WORKSPACE_ROOT": str(config.workspace_root),
"STEM_SOCKET_ROOT": str(config.socket_root),
"STEM_PROFILE": profile,
}
)
command = ["bash", str(env_script), "ensure", profile]
if args.dry_run:
print(f"profile\t{profile}")
print(f"command\t{shlex.join(command)}")
return
subprocess.run(command, cwd=env_script.parent, env=env_vars, check=True)
return
raise SystemExit(f"Unsupported env command: {args.env_command}")
def run_probe(config: WorkspaceConfig, args: argparse.Namespace) -> None:
env_script = ensure_env_tool(config)
command = ["bash", str(env_script), "probe", "list"]
if args.json:
command.append("--json")
env = os.environ.copy()
env.update(
{
"STEM_WORKSPACE_ROOT": str(config.workspace_root),
"STEM_SOCKET_ROOT": str(config.socket_root),
"STEM_PROFILE": "rp2350",
}
)
subprocess.run(command, cwd=env_script.parent, env=env, check=True)
def run_mcp(config: WorkspaceConfig, args: argparse.Namespace) -> None:
"""Manage the dynamic MCP target or serve one explicitly bound instance."""
if args.mcp_kind == "list":
print_mcp_instances(config)
return
if args.mcp_kind == "select":
select_mcp_instance(config, args.selector)
return
if args.mcp_kind == "status":
print_mcp_selection(config)
return
env_script = ensure_env_tool(config)
wrapper = env_script.parent / "scripts" / f"mcp-{args.mcp_kind}.sh"
if not wrapper.is_file():
raise SystemExit(f"Missing MCP wrapper in environment sources: {wrapper}")
env = os.environ.copy()
env.update(
{
"STEM_WORKSPACE_ROOT": str(config.workspace_root),
"STEM_SOCKET_ROOT": str(config.socket_root),
}
)
os.execvpe(str(wrapper), [str(wrapper), args.instance], env)
def mcp_selection_root(config: WorkspaceConfig) -> Path:
"""Return the short, stable host path used by generic MCP providers."""
return config.socket_root / "mcp-selected"
def mcp_registry_records(config: WorkspaceConfig) -> list[dict]:
registry_root = config.socket_root / "registry"
records: list[dict] = []
for path in sorted(registry_root.glob("*.json")):
try:
record = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if isinstance(record, dict):
record["_registry_path"] = str(path)
records.append(record)
return records
def is_unix_socket(path: Path) -> bool:
try:
return stat.S_ISSOCK(path.stat().st_mode)
except OSError:
return False
def probe_mcp_socket_servers(record: dict, nvim_socket: Path, tmux_socket: Path) -> None:
runtime = str(record["runtime"])
container_name = str(record["container_name"])
probes = (
(
"Neovim",
[runtime, "exec", container_name, "nvim", "--server", str(nvim_socket), "--remote-expr", "1"],
),
(
"tmux",
[runtime, "exec", container_name, "tmux", "-S", str(tmux_socket), "list-sessions"],
),
)
failures: list[str] = []
for name, command in probes:
try:
result = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
timeout=3,
)
except subprocess.TimeoutExpired:
failures.append(f"{name}: connection timed out")
continue
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip().splitlines()
failures.append(f"{name}: {detail[-1] if detail else 'connection failed'}")
if failures:
raise SystemExit("MCP socket files exist, but their servers are not live:\n" + "\n".join(failures))
def validated_mcp_record(record: dict) -> tuple[dict, Path, Path]:
required = (
"runtime",
"container_name",
"container_id",
"instance_key",
"profile",
"target",
"socket_dir",
)
missing = [key for key in required if not str(record.get(key, "")).strip()]
if missing:
raise SystemExit(f"Incomplete MCP registry ({', '.join(missing)}): {record.get('_registry_path', '?')}")
runtime = str(record["runtime"])
container_name = str(record["container_name"])
expected_id = str(record["container_id"])
expected_instance_key = str(record["instance_key"])
if shutil.which(runtime) is None:
raise SystemExit(f"Recorded container runtime is unavailable: {runtime}")
inspected = subprocess.run(
[runtime, "inspect", container_name],
check=False,
capture_output=True,
text=True,
)
if inspected.returncode != 0:
raise SystemExit(f"Container is not available: {container_name}")
try:
inspection = json.loads(inspected.stdout)[0]
except (IndexError, KeyError, TypeError, json.JSONDecodeError) as error:
raise SystemExit(f"Cannot inspect container identity: {container_name}") from error
actual_id = str(inspection.get("Id", ""))
labels = inspection.get("Config", {}).get("Labels") or {}
actual_instance_key = str(labels.get("edu.stem.instance-key", ""))
actual_profile = str(labels.get("edu.stem.profile", ""))
actual_target = str(labels.get("edu.stem.target", ""))
if (
actual_id != expected_id
or actual_instance_key != expected_instance_key
or actual_profile != str(record["profile"])
or actual_target != str(record["target"])
):
raise SystemExit(f"Stale MCP registry rejected for {container_name} (container identity changed).")
socket_dir = Path(str(record["socket_dir"]))
nvim_socket = socket_dir / "n.sock"
tmux_socket = socket_dir / "t.sock"
missing_sockets = [str(path) for path in (nvim_socket, tmux_socket) if not is_unix_socket(path)]
if missing_sockets:
raise SystemExit("MCP sockets are not live:\n" + "\n".join(missing_sockets))
probe_mcp_socket_servers(record, nvim_socket, tmux_socket)
return record, nvim_socket, tmux_socket
def resolve_mcp_record(config: WorkspaceConfig, selector: str) -> dict:
selector = selector.strip()
if not selector:
raise SystemExit("MCP selector cannot be empty.")
matches: list[dict] = []
for record in mcp_registry_records(config):
container_id = str(record.get("container_id", ""))
if selector in {str(record.get("instance", "")), str(record.get("container_name", ""))}:
matches.append(record)
elif len(selector) >= 12 and container_id.startswith(selector):
matches.append(record)
unique = {str(record.get("container_id", "")): record for record in matches}
if not unique:
raise SystemExit(
f"No registered MCP container matches {selector!r}. "
"Use 'stemctl mcp list' to see available instances."
)
if len(unique) > 1:
names = ", ".join(sorted(str(record.get("instance", "?")) for record in unique.values()))
raise SystemExit(f"Ambiguous MCP selector {selector!r}: {names}")
return next(iter(unique.values()))
def select_mcp_instance(config: WorkspaceConfig, selector: str) -> None:
record, nvim_socket, tmux_socket = validated_mcp_record(resolve_mcp_record(config, selector))
container_id = str(record["container_id"])
cid12 = container_id[:12]
selection_root = mcp_selection_root(config)
target_root = selection_root / "targets" / cid12
target_root.mkdir(parents=True, exist_ok=True)
os.chmod(selection_root, 0o700)
os.chmod(selection_root / "targets", 0o700)
os.chmod(target_root, 0o700)
for link, target in ((target_root / "n.sock", nvim_socket), (target_root / "t.sock", tmux_socket)):
temporary = link.with_name(f".{link.name}.{os.getpid()}")
temporary.unlink(missing_ok=True)
temporary.symlink_to(target)
os.replace(temporary, link)
metadata = {
key: record.get(key, "")
for key in ("runtime", "container_name", "container_id", "profile", "target", "instance", "instance_key", "thread_key", "socket_dir")
}
metadata_path = target_root / "selection.json"
temporary_metadata = metadata_path.with_name(f".{metadata_path.name}.{os.getpid()}")
temporary_metadata.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
os.replace(temporary_metadata, metadata_path)
current = selection_root / "current"
temporary_current = selection_root / f".current.{os.getpid()}"
temporary_current.unlink(missing_ok=True)
temporary_current.symlink_to(Path("targets") / cid12)
os.replace(temporary_current, current)
print(f"instance\t{record.get('instance', '')}")
print(f"container\t{record.get('container_name', '')}")
print(f"container_id\t{container_id}")
print(f"nvim_socket\t{current / 'n.sock'}")
print(f"tmux_socket\t{current / 't.sock'}")
def print_mcp_selection(config: WorkspaceConfig) -> None:
current = mcp_selection_root(config) / "current"
metadata_path = current / "selection.json"
try:
record = json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise SystemExit("No dynamic MCP container is selected. Use 'stemctl mcp select INSTANCE'.") from error
record, _, _ = validated_mcp_record(record)
print(f"instance\t{record.get('instance', '')}")
print(f"container\t{record.get('container_name', '')}")
print(f"container_id\t{record.get('container_id', '')}")
print(f"nvim_socket\t{current / 'n.sock'}")
print(f"tmux_socket\t{current / 't.sock'}")
def print_mcp_instances(config: WorkspaceConfig) -> None:
print("container_id\tinstance\tprofile\tcontainer\tsockets")
for record in sorted(mcp_registry_records(config), key=lambda item: str(item.get("instance", ""))):
socket_dir = Path(str(record.get("socket_dir", "")))
live = False
if is_unix_socket(socket_dir / "n.sock") and is_unix_socket(socket_dir / "t.sock"):
try:
validated_mcp_record(record)
live = True
except SystemExit:
pass
print(
f"{str(record.get('container_id', ''))[:12]}\t{record.get('instance', '')}\t"
f"{record.get('profile', '')}\t{record.get('container_name', '')}\t"
f"{'ready' if live else 'not-ready'}"
)
def run_profile_action(config: WorkspaceConfig, args: argparse.Namespace, action: str) -> None:
profile = normalize_env_profile(args.profile)
selector_values, pane_target = split_pane_selector(args.selector, getattr(args, "pane", None))
instance = getattr(args, "instance", None)
instance_only_actions = {"status", "attach", "stop", "rm"}
if instance and action in instance_only_actions and not selector_values:
# A stable logical instance is sufficient for lifecycle operations on
# an existing container. Resolving workspace defaults here would make
# the operation depend on the currently selected (or still checked
# out) card, even though the environment launcher does not need it.
series_name = card_name = task_name = None
else:
series_name, card_name, task_name = resolve_env_target(
config, selector_values, getattr(args, "task", None)
)
shell_command, details = env_action_command(
config,
profile,
action,
series_name=series_name,
card_name=card_name,
task_name=task_name,
instance=instance,
editor=getattr(args, "editor", None),
target=getattr(args, "target", None),
device=getattr(args, "device", None),
deploy_backend=getattr(args, "backend", None),
allow_existing_firmware=getattr(args, "allow_existing_firmware", False),
)
if pane_target:
pane_details = send_command_to_tmux_pane(shell_command, pane_target, dry_run=args.dry_run)
details.update(pane_details)
if args.dry_run:
print_env_plan(details)
return
print_env_plan(details)
return
if args.dry_run:
print_env_plan(details)
return
subprocess.run(["bash", "-lc", shell_command], check=True)
SESSION_STATUS_READY = "opracowane"
SESSION_STATUS_DRAFT = "robocze"
SESSION_STATUS_MISSING = "brak"
def ordered_series_names(config: WorkspaceConfig) -> list[str]:
names = list(workspace_series_manifests(config))
for path in [*series_directories(config), *source_series_directories(config)]:
if not path.name.startswith(".") and path.name not in names:
names.append(path.name)
return names
def resolve_session_series(config: WorkspaceConfig, selector: str | None) -> str:
value = (selector or config.defaults.get("series") or "").strip()
if not value:
raise SystemExit("Missing series selector. Pass --series or configure defaults.series.")
names = ordered_series_names(config)
if value in names:
return value
if value.isdigit():
index = int(value) - 1
if 0 <= index < len(names):
return names[index]
raise SystemExit(f"Series number {value} is outside 1..{len(names)}.")
matches = [name for name in names if value.lower() in name.lower()]
if len(matches) == 1:
return matches[0]
if not matches:
raise SystemExit(f"Missing series '{value}'. Available: {', '.join(names)}")
raise SystemExit(f"Ambiguous series '{value}'. Matches: {', '.join(matches)}")
def read_card_source(card_path: Path) -> dict | None:
source = card_path / "json" / "card_source.json"
try:
value = json.loads(source.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return value if isinstance(value, dict) else None
def resolve_session_card(config: WorkspaceConfig, series_name: str, selector: str | None) -> str:
value = (selector or default_card_for_series(config, series_name) or "").strip()
if not value:
raise SystemExit("Missing card selector. Pass --card or configure a default card.")
cards = source_card_infos(config, series_name)
if value.isdigit():
number = int(value)
index = number - 1
if 0 <= index < len(cards):
return cards[index].card_id
raise SystemExit(f"Card number {value} is outside 1..{len(cards)} in series {series_name}.")
return resolve_card_info(config, series_name, value).card_id
def task_number(value: str) -> int | None:
match = re.search(r"(?i)(?:^|[^a-z0-9])task[-_]?0*(\d+)(?=$|[^0-9])", value)
return int(match.group(1)) if match else None
def same_task(left: str, right: str) -> bool:
if left.lower() == right.lower():
return True
left_number = task_number(left)
right_number = task_number(right)
return left_number is not None and left_number == right_number
def resolve_session_target(config: WorkspaceConfig, args: argparse.Namespace) -> SessionTarget:
series_name = resolve_session_series(config, getattr(args, "series", None))
card_name = resolve_session_card(config, series_name, getattr(args, "card", None))
card_path = resolve_workspace_card_path(config, series_name, card_name)
task_name = resolve_submission_task(config, card_path, getattr(args, "task", None))
profile = normalize_env_profile(getattr(args, "profile", "hazard3-sim"))
target_name = getattr(args, "target", None) or PROFILE_DEFAULT_TARGETS[profile]
if target_name not in PROFILE_TARGETS[profile]:
supported = ", ".join(sorted(PROFILE_TARGETS[profile]))
raise SystemExit(
f"Target {target_name!r} does not belong to profile {profile}. "
f"Available targets: {supported}."
)
instance = getattr(args, "instance", None) or default_env_instance(
profile, series_name, card_name, task_name, "session"
)
return SessionTarget(
series=series_name,
card=card_name,
card_path=card_path,
task=task_name,
profile=profile,
target=target_name,
instance=instance,
)
def card_navigation_catalog(card: dict | None) -> dict:
entries: list[dict] = []
if not card:
return {"schema": "stem-card-navigation-catalog.v1", "levels": [], "entries": entries}
task_ids: list[str] = []
block_ids: dict[str, list[str]] = {}
global_snapshots: list[str] = []
for section_index, section in enumerate(card.get("sections", [])):
if not isinstance(section, dict):
continue
for asset_index, asset in enumerate(section.get("assets", [])):
interactive = asset.get("interactive") if isinstance(asset, dict) else None
if not isinstance(interactive, dict) or not interactive.get("phases"):
continue
task_value = interactive.get("task") if isinstance(interactive.get("task"), dict) else {}
block_value = interactive.get("block") if isinstance(interactive.get("block"), dict) else {}
task_id = str(task_value.get("id") or f"section-{section_index + 1}")
task_label = str(task_value.get("label") or section.get("title") or task_id)
block_id = str(block_value.get("id") or str(asset.get("label", "")).removeprefix("fig:") or f"asset-{asset_index + 1}")
block_label = str(block_value.get("label") or interactive.get("title") or asset.get("caption") or block_id)
if task_id not in task_ids:
task_ids.append(task_id)
blocks = block_ids.setdefault(task_id, [])
if block_id not in blocks:
blocks.append(block_id)
block_snapshots: list[str] = []
for phase_index, phase in enumerate(interactive.get("phases", [])):
if not isinstance(phase, dict):
continue
phase_id = str(phase.get("id") or f"phase-{phase_index + 1}")
phase_label = str(phase.get("label") or f"Faza {phase_index + 1}")
phase_snapshots: list[str] = []
for step_index, step in enumerate(phase.get("steps", [])):
if not isinstance(step, dict):
continue
step_id = str(step.get("id") or f"step-{step_index + 1}")
step_label = str(step.get("label") or f"Krok {step_index + 1}")
try:
step_number = int(step.get("number", step_index + 1))
except (TypeError, ValueError):
step_number = step_index + 1
snapshot_ref = str(step.get("snapshot_ref") or "") or None
if snapshot_ref and snapshot_ref not in phase_snapshots:
phase_snapshots.append(snapshot_ref)
if snapshot_ref and snapshot_ref not in block_snapshots:
block_snapshots.append(snapshot_ref)
if snapshot_ref and snapshot_ref not in global_snapshots:
global_snapshots.append(snapshot_ref)
entries.append(
{
"task": {"id": task_id, "label": task_label, "index": task_ids.index(task_id)},
"block": {"id": block_id, "label": block_label, "index": blocks.index(block_id)},
"phase": {"id": phase_id, "label": phase_label, "index": phase_index},
"step": {
"id": step_id,
"label": step_label,
"number": step_number,
"index": step_index,
"global_index": len(entries),
},
"snapshot": (
{
"ref": snapshot_ref,
"index": phase_snapshots.index(snapshot_ref),
"block_index": block_snapshots.index(snapshot_ref),
"global_index": global_snapshots.index(snapshot_ref),
}
if snapshot_ref
else None
),
"asset": {
"label": str(asset.get("label") or ""),
"section_index": section_index,
"asset_index": asset_index,
},
}
)
return {
"schema": "stem-card-navigation-catalog.v1",
"levels": ["task", "block", "phase", "step", "snapshot"],
"entries": entries,
}
def development_status(values: list[str]) -> str:
if values and all(value == SESSION_STATUS_READY for value in values):
return SESSION_STATUS_READY
if values and any(value != SESSION_STATUS_MISSING for value in values):
return SESSION_STATUS_DRAFT
return SESSION_STATUS_MISSING
def checkpoint_items(card: dict | None) -> dict:
value = card.get("debug_checkpoints", {}).get("items", {}) if card else {}
return value if isinstance(value, dict) else {}
def valid_checkpoint_recipe(recipe: object) -> bool:
if not isinstance(recipe, dict):
return False
stop = recipe.get("stop")
if not isinstance(stop, dict) or not isinstance(stop.get("symbol"), str):
return False
if not isinstance(stop.get("offset"), int) or stop["offset"] < 0:
return False
verify = recipe.get("verify", {"expressions": []})
return isinstance(verify, dict) and isinstance(verify.get("expressions", []), list)
def navigation_entry_status(entry: dict, recipes: dict) -> str:
snapshot = entry.get("snapshot") or {}
reference = snapshot.get("ref") if isinstance(snapshot, dict) else None
if reference and valid_checkpoint_recipe(recipes.get(reference)):
return SESSION_STATUS_READY
if reference or entry.get("step"):
return SESSION_STATUS_DRAFT
return SESSION_STATUS_MISSING
def card_material_status(card_path: Path) -> str:
source = read_card_source(card_path)
if source is None:
return SESSION_STATUS_MISSING if not card_path.is_dir() else SESSION_STATUS_DRAFT
declared = str(source.get("card", {}).get("status", "")).strip().lower()
if declared in {"gotowa", "gotowy", "ready", "opracowane", "opracowana", "published"}:
return SESSION_STATUS_READY
return SESSION_STATUS_DRAFT
def print_session_choices(config: WorkspaceConfig, args: argparse.Namespace) -> None:
series_names = ordered_series_names(config)
raw_series = getattr(args, "series", None) or config.defaults.get("series")
selected_series = resolve_session_series(config, raw_series) if raw_series else None
raw_card = getattr(args, "card", None)
if selected_series and raw_card is None:
raw_card = default_card_for_series(config, selected_series)
selected_card = resolve_session_card(config, selected_series, raw_card) if selected_series and raw_card else None
cards = source_card_infos(config, selected_series) if selected_series else []
card_info = resolve_card_info(config, selected_series, selected_card) if selected_series and selected_card else None
card_path = workspace_card_path_for_info(config, card_info) if card_info else None
source = read_card_source(card_path) if card_path else None
catalog = card_navigation_catalog(source)
recipes = checkpoint_items(source)
explicit_task = getattr(args, "task", None)
selected_task = None
if explicit_task and card_path and card_path.is_dir():
selected_task = resolve_submission_task(config, card_path, explicit_task)
series_rows = [
[str(index), "*" if name == selected_series else "", name]
for index, name in enumerate(series_names, 1)
]
card_rows = []
for index, info in enumerate(cards, 1):
path = workspace_card_path_for_info(config, info)
card_rows.append(
[
str(index),
"*" if info.card_id == selected_card else "",
info.card_id,
info.title or info.repo,
card_material_status(path),
]
)
nav_status_by_task: dict[str, list[str]] = {}
for entry in catalog.get("entries", []):
nav_status_by_task.setdefault(entry["task"]["id"], []).append(navigation_entry_status(entry, recipes))
task_rows = []
if card_path and card_path.is_dir():
for entry in task_entries(card_path):
matching = [
status
for task_id, statuses in nav_status_by_task.items()
if same_task(task_id, entry.name)
for status in statuses
]
status = development_status(matching) if matching else SESSION_STATUS_DRAFT
task_rows.append(
[
str(task_number(entry.name) or len(task_rows) + 1),
"*" if selected_task == entry.name else "",
entry.name,
status,
str(entry.path),
]
)
uml_rows = []
for entry in catalog.get("entries", []):
if selected_task and not same_task(entry["task"]["id"], selected_task):
continue
snapshot = entry.get("snapshot") or {}
uml_rows.append(
[
entry["task"]["id"],
str(entry["block"]["index"] + 1),
str(entry["phase"]["index"] + 1),
str(entry["step"]["number"]),
str(snapshot.get("index", -1) + 1) if snapshot else "-",
str(snapshot.get("block_index", -1) + 1) if snapshot else "-",
str(snapshot.get("global_index", -1) + 1) if snapshot else "-",
entry["phase"]["id"],
entry["step"]["id"],
str(snapshot.get("ref") or "-"),
navigation_entry_status(entry, recipes),
"/".join(
(
entry["task"]["id"],
entry["block"]["id"],
entry["phase"]["id"],
entry["step"]["id"],
)
),
entry["step"]["label"],
]
)
if getattr(args, "json", False):
print(
json.dumps(
{
"schema": "stem-session-choices.v1",
"series": [
{"number": int(row[0]), "selected": bool(row[1]), "id": row[2]}
for row in series_rows
],
"cards": [
{
"number": int(row[0]),
"selected": bool(row[1]),
"id": row[2],
"title": row[3],
"status": row[4],
}
for row in card_rows
],
"tasks": [
{
"number": int(row[0]),
"selected": bool(row[1]),
"id": row[2],
"status": row[3],
"source": row[4],
}
for row in task_rows
],
"uml": [
{
"task_id": row[0],
"block_number": int(row[1]),
"phase_number": int(row[2]),
"step_number": int(row[3]),
"snapshot_phase_number": None if row[4] == "-" else int(row[4]),
"snapshot_block_number": None if row[5] == "-" else int(row[5]),
"snapshot_global_number": None if row[6] == "-" else int(row[6]),
"phase_id": row[7],
"step_id": row[8],
"snapshot_ref": None if row[9] == "-" else row[9],
"status": row[10],
"selector": row[11],
"label": row[12],
}
for row in uml_rows
],
"status_values": [SESSION_STATUS_READY, SESSION_STATUS_DRAFT, SESSION_STATUS_MISSING],
},
indent=2,
ensure_ascii=False,
)
)
return
print("Serie:")
print_table(["nr", "wybrana", "seria"], series_rows)
print("\nKarty:")
print_table(["nr", "wybrana", "karta", "tytuł", "status"], card_rows)
print("\nTaski:")
print_table(["nr", "wybrany", "task", "status", "źródło"], task_rows)
print("\nEtapy UML:")
print_table(
[
"task",
"blok",
"faza",
"krok",
"snap/faza",
"snap/blok",
"snap/global",
"phase_id",
"step_id",
"snapshot",
"status",
"selector",
"opis",
],
uml_rows,
)
def session_stage_selectors(
args: argparse.Namespace,
task_name: str | None = None,
) -> tuple[str | None, str | None, str | None, str | None]:
block = getattr(args, "block", None)
phase = getattr(args, "phase", None)
step = getattr(args, "step", None)
snapshot = getattr(args, "snapshot", None)
stage = (getattr(args, "stage", None) or "").strip()
first = bool(getattr(args, "first", False))
if first and (stage or any(value for value in (block, phase, step, snapshot))):
raise SystemExit("--first cannot be combined with another UML stage selector.")
if not stage:
return block, phase, step, snapshot
if any(value for value in (block, phase, step, snapshot)):
raise SystemExit("Pass either --stage or the separate --block/--phase/--step/--snapshot selectors.")
if re.fullmatch(r"(?i)task\d+\.[a-z0-9.-]+", stage):
return None, None, None, stage
parts = [part.strip() for part in re.split(r"[/:]", stage) if part.strip()]
if len(parts) == 1:
return None, None, parts[0], None
if len(parts) == 2:
return None, parts[0], parts[1], None
if len(parts) == 3:
return parts[0], parts[1], parts[2], None
if len(parts) == 4:
if task_name and not same_task(parts[0], task_name):
raise SystemExit(f"Stage selector belongs to {parts[0]}, selected task is {task_name}.")
return parts[1], parts[2], parts[3], None
raise SystemExit(
"--stage accepts STEP, PHASE/STEP, BLOCK/PHASE/STEP, "
"TASK/BLOCK/PHASE/STEP or a snapshot_ref."
)
def navigation_level_path(entry: dict, level: str) -> str:
task_id = str(entry.get("task", {}).get("id", ""))
block_id = str(entry.get("block", {}).get("id", ""))
phase_id = str(entry.get("phase", {}).get("id", ""))
if level == "block":
return f"{task_id}/{block_id}"
if level == "phase":
return f"{task_id}/{block_id}/{phase_id}"
if level == "snapshot":
return f"{task_id}/{block_id}/{entry.get('snapshot', {}).get('ref', '')}"
return ""
def unique_navigation_values(entries: list[dict], level: str) -> list[tuple[str, dict]]:
values: list[tuple[str, dict]] = []
seen: set[str] = set()
for entry in entries:
value = entry.get(level)
if not isinstance(value, dict):
continue
key = navigation_level_path(entry, level)
if not key or key == "None" or key in seen:
continue
seen.add(key)
values.append((key, value))
return values
def filter_navigation_level(entries: list[dict], level: str, selector: str | None) -> list[dict]:
if selector is None:
return entries
values = unique_navigation_values(entries, level)
selected: tuple[str, dict] | None = None
raw = selector.strip()
if raw.isdigit():
if level in {"phase", "snapshot"}:
parent_paths = {
"/".join(path.split("/")[:2])
for path, _ in values
}
if len(parent_paths) > 1:
raise SystemExit(f"Numeric {level} requires an explicit --block parent.")
index = int(raw) - 1
if 0 <= index < len(values):
selected = values[index]
else:
key_name = "ref" if level == "snapshot" else "id"
exact = [item for item in values if str(item[1].get(key_name, "")).lower() == raw.lower()]
matches = exact or [
item
for item in values
if raw.lower() in str(item[1].get(key_name, "")).lower()
or raw.lower() in str(item[1].get("label", "")).lower()
]
if len(matches) == 1:
selected = matches[0]
elif len(matches) > 1:
choices = ", ".join(path for path, _ in matches)
raise SystemExit(f"Ambiguous {level} selector '{raw}'. Matches: {choices}")
if selected is None:
choices = ", ".join(path for path, _ in values)
raise SystemExit(f"Missing {level} '{raw}'. Available: {choices}")
selected_path = selected[0]
return [
entry
for entry in entries
if isinstance(entry.get(level), dict) and navigation_level_path(entry, level) == selected_path
]
def resolve_navigation_entry(catalog: dict, task_name: str, args: argparse.Namespace) -> dict:
entries = [
entry
for entry in catalog.get("entries", [])
if isinstance(entry, dict) and same_task(str(entry.get("task", {}).get("id", "")), task_name)
]
if not entries:
raise SystemExit(f"Card server has no UML navigation entries for {task_name}.")
block, phase, step, snapshot = session_stage_selectors(args, task_name)
entries = filter_navigation_level(entries, "block", block)
entries = filter_navigation_level(entries, "phase", phase)
entries = filter_navigation_level(entries, "snapshot", snapshot)
if step is not None:
raw = step.strip()
matches: list[dict] = []
if raw.isdigit():
number = int(raw)
matches = [entry for entry in entries if int(entry.get("step", {}).get("number", -1)) == number]
if not matches and phase is not None:
matches = [entry for entry in entries if int(entry.get("step", {}).get("index", -1)) == number - 1]
else:
exact = [entry for entry in entries if str(entry.get("step", {}).get("id", "")).lower() == raw.lower()]
matches = exact or [
entry
for entry in entries
if raw.lower() in str(entry.get("step", {}).get("id", "")).lower()
or raw.lower() in str(entry.get("step", {}).get("label", "")).lower()
]
if not matches:
choices = ", ".join(
f"{entry.get('step', {}).get('number')}:{entry.get('step', {}).get('id')}" for entry in entries
)
raise SystemExit(f"Missing step '{raw}'. Available: {choices}")
if len(matches) > 1:
raise SystemExit(f"Ambiguous step '{raw}'. Add --phase or use a step ID.")
entries = [matches[0]]
if not entries:
raise SystemExit("The UML selectors do not identify any stage.")
return entries[0]
def card_api_json(
base_url: str,
path: str,
method: str = "GET",
payload: dict | None = None,
timeout: float = 30,
) -> dict:
data = None
headers = {"accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["content-type"] = "application/json"
request = urlrequest.Request(
f"{base_url.rstrip('/')}{path}", data=data, headers=headers, method=method
)
try:
with urlrequest.urlopen(request, timeout=timeout) as response:
value = json.loads(response.read().decode("utf-8"))
except urlerror.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
raise SystemExit(f"Card API {method} {path} returned HTTP {error.code}: {detail}") from error
except (urlerror.URLError, TimeoutError, json.JSONDecodeError) as error:
raise SystemExit(
f"Card API is unavailable at {base_url}: {error}. Start the selected card server or pass --card-url."
) from error
if not isinstance(value, dict):
raise SystemExit(f"Card API {path} returned a non-object JSON response.")
return value
def session_stage_requested(args: argparse.Namespace) -> bool:
return bool(getattr(args, "first", False)) or any(
getattr(args, name, None)
for name in ("stage", "block", "phase", "step", "snapshot")
)
def verify_card_api_identity(base_url: str, session: SessionTarget, timeout: float) -> dict:
local_source = read_card_source(session.card_path)
card_file = session.card_path / "json" / "card_source.json"
if not local_source or not card_file.is_file():
raise SystemExit(f"Cannot verify card API identity without {card_file}.")
expected = {
"id": str(local_source.get("card", {}).get("id", "")),
"uuid": str(local_source.get("card", {}).get("uuid", "")),
"version": str(local_source.get("card", {}).get("version", "")),
"source_sha256": hashlib.sha256(card_file.read_bytes()).hexdigest(),
}
if not all(expected.values()):
raise SystemExit(f"Card identity is incomplete in {card_file}.")
served = card_api_json(base_url, "/api/identity", timeout=timeout)
mismatches = [name for name, value in expected.items() if str(served.get(name, "")) != value]
if mismatches:
raise SystemExit(
f"Card API mismatch for {', '.join(mismatches)} at {base_url}. "
"Start the correct card server or pass --card-url."
)
return served
def validate_session_checkpoint(session: SessionTarget, snapshot_ref: str) -> None:
source = read_card_source(session.card_path)
registry = source.get("debug_checkpoints") if source else None
if not isinstance(registry, dict):
raise SystemExit("The selected card has no debug_checkpoints registry.")
targets = registry.get("targets")
if not isinstance(targets, dict) or not isinstance(targets.get(session.profile), dict):
raise SystemExit(f"The card has no checkpoint adapter for profile {session.profile}.")
artifact = registry.get("artifact")
if not isinstance(artifact, dict) or not artifact.get("source") or not artifact.get("elf"):
raise SystemExit("The checkpoint artifact identity is incomplete.")
recipe = checkpoint_items(source).get(snapshot_ref)
if not valid_checkpoint_recipe(recipe):
raise SystemExit(f"Snapshot {snapshot_ref} has no complete checkpoint recipe.")
def prepare_session_stage(session: SessionTarget, args: argparse.Namespace, online: bool) -> dict:
verify_card_api_identity(args.card_url, session, args.timeout)
catalog = card_api_json(args.card_url, "/api/navigation/catalog", timeout=args.timeout)
entry = resolve_navigation_entry(catalog, session.task, args)
snapshot = entry.get("snapshot") or {}
snapshot_ref = snapshot.get("ref") if isinstance(snapshot, dict) else None
if online:
if not snapshot_ref:
raise SystemExit("The selected UML step has no snapshot and cannot be replayed online.")
validate_session_checkpoint(session, str(snapshot_ref))
return entry
def select_session_stage(
config: WorkspaceConfig,
session: SessionTarget,
args: argparse.Namespace,
online: bool,
prepared_entry: dict | None = None,
) -> dict:
base_url = args.card_url
entry = prepared_entry or prepare_session_stage(session, args, online)
snapshot = entry.get("snapshot") or {}
snapshot_ref = snapshot.get("ref") if isinstance(snapshot, dict) else None
result: dict = {}
if online:
if not snapshot_ref:
raise SystemExit("The selected UML step has no snapshot and cannot be replayed online.")
expected_record = resolve_mcp_record(config, session.instance)
validated_mcp_record(expected_record)
validate_mcp_card_mount(expected_record, session.card_path)
if str(expected_record.get("profile", "")) != session.profile:
raise SystemExit("MCP profile changed after preflight.")
if str(expected_record.get("target", "")) != session.target:
raise SystemExit("MCP target changed after preflight.")
select_mcp_instance(config, session.instance)
expected_identity = verify_card_api_identity(base_url, session, args.timeout)
result = card_api_json(
base_url,
"/api/checkpoint/activate",
method="POST",
payload={
"snapshot_ref": snapshot_ref,
"generation": int(time.time() * 1000),
"expected_binding": {
key: str(expected_record.get(key, ""))
for key in ("container_id", "instance", "profile", "target")
},
"expected_identity": {
key: str(expected_identity.get(key, ""))
for key in ("id", "uuid", "version", "source_sha256")
},
},
timeout=max(args.timeout, 25),
)
if result.get("status") != "ready":
raise SystemExit(
f"Checkpoint {snapshot_ref} did not reach ready: "
f"{result.get('status', 'unknown')}{result.get('message', 'no message')}"
)
verify_card_api_identity(base_url, session, args.timeout)
navigation = card_api_json(
base_url,
"/api/navigation/current",
method="PUT",
payload={
"task_id": entry["task"]["id"],
"block_id": entry["block"]["id"],
"phase_id": entry["phase"]["id"],
"step_id": entry["step"]["id"],
"snapshot_ref": snapshot_ref,
"focus_level": args.focus,
"sync_requested": online,
"actor": "stemctl",
},
timeout=args.timeout,
)
card_api_json(
base_url,
"/api/viewer/state",
method="PUT",
payload={
"actor": "stemctl",
"nvim": {
"visible": True,
"sync": online,
"control": online and args.control,
},
},
timeout=args.timeout,
)
print(f"task\t{entry['task']['id']}")
print(f"block\t{entry['block']['id']}")
print(f"phase\t{entry['phase']['id']}")
print(f"step\t{entry['step']['number']}:{entry['step']['id']}")
print(f"snapshot\t{snapshot_ref or ''}")
print(f"mode\t{'online' if online else 'offline'}")
if not online:
return navigation
print(f"checkpoint_status\t{result.get('status', '')}")
print(f"checkpoint_message\t{result.get('message', '')}")
if result.get("actual_pc"):
print(f"pc\t{result['actual_pc']}")
return result
def mark_invoking_codex_pane() -> None:
current_pane = os.environ.get("TMUX_PANE", "").strip()
thread_id = os.environ.get("CODEX_THREAD_ID", "").strip()
if not current_pane or not thread_id:
return
pane = subprocess.run(
["tmux", "display-message", "-p", "-t", current_pane, "#{pane_pid}"],
check=False,
capture_output=True,
text=True,
)
if pane.returncode != 0 or not pane.stdout.strip().isdigit():
return
subprocess.run(
["tmux", "set-option", "-p", "-t", current_pane, "@stem_codex_pid", pane.stdout.strip()],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.run(
["tmux", "set-option", "-p", "-t", current_pane, "@stem_codex_thread", thread_id],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def resolve_tmux_session_pane(raw_target: str, force: bool = False) -> tuple[str, str, str]:
mark_invoking_codex_pane()
target = raw_target.strip()
current_match = re.fullmatch(r"current:(\d+)", target)
if current_match:
current_pane = os.environ.get("TMUX_PANE", "").strip()
if not current_pane:
raise SystemExit("--pane current:N requires stemctl to run inside tmux; pass an explicit target instead.")
window = subprocess.run(
["tmux", "display-message", "-p", "-t", current_pane, "#{session_name}:#{window_index}"],
check=False,
capture_output=True,
text=True,
)
if window.returncode != 0:
raise SystemExit(f"Cannot resolve the current tmux window: {window.stderr.strip()}")
target = f"{window.stdout.strip()}.{current_match.group(1)}"
else:
target = tmux_pane_target(target)
resolved = subprocess.run(
[
"tmux",
"display-message",
"-p",
"-t",
target,
"#{session_name}:#{window_index}.#{pane_index}\t#{pane_id}\t#{pane_current_path}"
"\t#{pane_pid}\t#{@stem_codex_pid}\t#{@stem_codex_thread}",
],
check=False,
capture_output=True,
text=True,
)
if resolved.returncode != 0:
raise SystemExit(f"Missing tmux pane target '{raw_target}' ({target}): {resolved.stderr.strip()}")
name, pane_id, current_path, pane_pid, protected_pid, protected_thread = resolved.stdout.rstrip("\n").split("\t", 5)
current_pane = os.environ.get("TMUX_PANE", "").strip()
if current_pane and pane_id == current_pane:
raise SystemExit(
f"Refusing to replace the pane running stemctl/Codex ({pane_id}). Choose another pane, normally current:0."
)
if protected_thread and protected_pid == pane_pid and not force:
raise SystemExit(
f"Refusing to replace pane {pane_id}: it is owned by Codex thread {protected_thread}. "
"Use another pane or pass --force-pane deliberately."
)
return name, pane_id, current_path
def respawn_tmux_session_pane(
raw_target: str,
cwd: Path,
shell_command: str | None,
dry_run: bool = False,
force: bool = False,
stem_instance: str | None = None,
) -> dict[str, str]:
name, pane_id, _ = resolve_tmux_session_pane(raw_target, force=force)
shell = os.environ.get("SHELL", "/bin/bash")
if shell_command:
inner = f"{shell_command}; exec {shlex.quote(shell)} -l"
pane_command = f"{shlex.quote(shell)} -lc {shlex.quote(inner)}"
else:
pane_command = f"exec {shlex.quote(shell)} -l"
command = ["tmux", "respawn-pane", "-k", "-t", pane_id, "-c", str(cwd), pane_command]
details = {
"pane": raw_target,
"tmux_target": name,
"tmux_pane_id": pane_id,
"tmux_command": shlex.join(command),
}
if not dry_run:
subprocess.run(command, check=True)
# A debugger session contains an inner tmux and Neovim. If its host
# pane remains in a split, the inner client inherits only that short
# height and leaves an unusable empty area below the editor. Zoom the
# replacement pane after every attach/refresh/reset so it receives the
# full host window height. A later user Ctrl-a z simply restores the
# ordinary host-pane layout.
zoom_state = subprocess.run(
["tmux", "display-message", "-p", "-t", pane_id, "#{window_zoomed_flag}"],
check=False,
text=True,
capture_output=True,
)
if zoom_state.returncode == 0 and zoom_state.stdout.strip() != "1":
subprocess.run(
["tmux", "resize-pane", "-Z", "-t", pane_id],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.run(
["tmux", "set-option", "-p", "-t", pane_id, "@stem_role", "debugger" if shell_command else "shell"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
instance_option = ["tmux", "set-option", "-p", "-t", pane_id]
if shell_command and stem_instance:
instance_option.extend(["@stem_instance", stem_instance])
else:
instance_option.extend(["-u", "@stem_instance"])
subprocess.run(
instance_option,
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return details
def validate_mcp_card_mount(record: dict, card_path: Path) -> None:
runtime = str(record.get("runtime", ""))
container_name = str(record.get("container_name", ""))
inspected = subprocess.run(
[runtime, "inspect", container_name],
check=False,
capture_output=True,
text=True,
)
if inspected.returncode != 0:
raise SystemExit(f"Cannot inspect MCP container mount: {container_name}")
try:
inspection = json.loads(inspected.stdout)[0]
except (IndexError, TypeError, json.JSONDecodeError) as error:
raise SystemExit(f"Cannot decode MCP container mount: {container_name}") from error
workspace_mounts = [
mount
for mount in inspection.get("Mounts", [])
if isinstance(mount, dict) and mount.get("Destination") == "/workspace"
]
if len(workspace_mounts) != 1:
raise SystemExit(f"MCP container {container_name} has no unique /workspace bind mount.")
source = Path(str(workspace_mounts[0].get("Source", ""))).resolve()
if source != card_path.resolve():
raise SystemExit(
f"MCP container/card mismatch: {container_name} mounts {source}, selected card is {card_path.resolve()}."
)
def ready_mcp_record(
config: WorkspaceConfig,
instance: str,
card_path: Path | None = None,
profile: str | None = None,
target: str | None = None,
) -> tuple[dict | None, str]:
try:
record = resolve_mcp_record(config, instance)
validated_mcp_record(record)
if card_path is not None:
validate_mcp_card_mount(record, card_path)
if profile is not None and str(record.get("profile", "")) != profile:
raise SystemExit(
f"MCP profile mismatch: {record.get('profile', '<unknown>')} != {profile}."
)
if target is not None and str(record.get("target", "")) != target:
raise SystemExit(
f"MCP target mismatch: {record.get('target', '<unknown>')} != {target}."
)
return record, ""
except SystemExit as error:
return None, str(error)
def wait_for_session_mcp(
config: WorkspaceConfig,
instance: str,
timeout: float,
card_path: Path | None = None,
profile: str | None = None,
target: str | None = None,
) -> dict:
deadline = time.monotonic() + timeout
last_error = ""
print(f"waiting_for_mcp\t{instance}", file=sys.stderr, flush=True)
while time.monotonic() < deadline:
record, last_error = ready_mcp_record(config, instance, card_path, profile, target)
if record is not None:
return record
time.sleep(0.25)
raise SystemExit(f"MCP for {instance} did not become ready within {timeout:g}s: {last_error}")
def session_env_command(config: WorkspaceConfig, session: SessionTarget, action: str) -> tuple[str, dict[str, str]]:
return env_action_command(
config,
session.profile,
action,
series_name=session.series,
card_name=session.card,
task_name=session.task,
instance=session.instance,
editor="nvim",
target=session.target,
)
def print_session_target(session: SessionTarget) -> None:
print(f"series\t{session.series}")
print(f"card\t{session.card}")
print(f"card_path\t{session.card_path}")
print(f"task\t{session.task}")
print(f"profile\t{session.profile}")
print(f"target\t{session.target}")
print(f"instance\t{session.instance}")
def session_status(config: WorkspaceConfig, session: SessionTarget, args: argparse.Namespace) -> None:
data: dict = {
"schema": "stem-session-status.v1",
"series": session.series,
"card": session.card,
"card_path": str(session.card_path),
"task": session.task,
"profile": session.profile,
"target": session.target,
"instance": session.instance,
}
record, error = ready_mcp_record(
config,
session.instance,
session.card_path,
session.profile,
session.target,
)
if record is None:
data["mcp"] = {"status": "not-ready", "detail": error}
else:
data["mcp"] = {
"status": "ready",
"container": record.get("container_name", ""),
"container_id": record.get("container_id", ""),
}
try:
identity = verify_card_api_identity(args.card_url, session, min(args.timeout, 3))
state = card_api_json(args.card_url, "/api/state", timeout=min(args.timeout, 3))
except SystemExit as api_error:
data["card_api"] = {"status": "offline", "detail": str(api_error)}
else:
navigation = state.get("navigation") or {}
data["card_api"] = {
"status": "online",
"identity": identity,
"phase": navigation.get("phase", {}).get("id", ""),
"step": navigation.get("step", {}),
"snapshot": navigation.get("snapshot_ref", ""),
"sync": bool(navigation.get("sync_requested")),
}
if args.json:
print(json.dumps(data, indent=2, ensure_ascii=False))
else:
for key in ("series", "card", "card_path", "task", "profile", "target", "instance"):
print(f"{key}\t{data[key]}")
print(f"mcp\t{data['mcp']['status']}")
if data["mcp"]["status"] == "ready":
print(f"container\t{data['mcp']['container']}")
print(f"container_id\t{data['mcp']['container_id']}")
else:
print(f"mcp_detail\t{data['mcp']['detail']}")
print(f"card_api\t{data['card_api']['status']}")
if data["card_api"]["status"] == "online":
api = data["card_api"]
print(f"card_api_id\t{api['identity'].get('id', '')}")
print(f"phase\t{api['phase']}")
print(f"step\t{api['step'].get('number', '')}:{api['step'].get('id', '')}")
print(f"snapshot\t{api['snapshot']}")
print(f"sync\t{str(api['sync']).lower()}")
else:
print(f"card_api_detail\t{data['card_api']['detail']}")
if args.strict and (
data["mcp"]["status"] != "ready" or data["card_api"]["status"] != "online"
):
raise SystemExit("Session is not fully ready.")
def validate_session_command_args(args: argparse.Namespace) -> None:
command = args.session_command
has_stage = session_stage_requested(args)
if args.offline and args.control:
raise SystemExit("--control requires online SYNC and cannot be combined with --offline.")
if (args.offline or args.control) and not has_stage:
raise SystemExit("--offline/--control require an explicit UML stage selector.")
if command in {"stage", "checkpoint"} and not has_stage:
raise SystemExit("stage/checkpoint requires a UML selector or explicit --first.")
if command in {"status", "detach"} and has_stage:
raise SystemExit(f"{command} does not accept UML stage selectors.")
if command == "status" and args.dry_run:
raise SystemExit("status is already read-only; --dry-run is not applicable.")
if command in {"reset", "start"} and args.pane == "none" and has_stage and not args.offline:
raise SystemExit(
f"Online UML replay during {command} requires a debugger pane; "
"omit --pane none or use --offline."
)
if command in {"attach", "detach"} and args.pane == "none":
raise SystemExit(f"{command} requires --pane; use current:0 or an explicit tmux pane ID.")
def print_prepared_stage(entry: dict, online: bool) -> None:
snapshot = entry.get("snapshot") or {}
print(f"planned_task\t{entry.get('task', {}).get('id', '')}")
print(f"planned_block\t{entry.get('block', {}).get('id', '')}")
print(f"planned_phase\t{entry.get('phase', {}).get('id', '')}")
print(
f"planned_step\t{entry.get('step', {}).get('number', '')}:"
f"{entry.get('step', {}).get('id', '')}"
)
print(f"planned_snapshot\t{snapshot.get('ref', '')}")
print(f"planned_mode\t{'online' if online else 'offline'}")
def run_session(config: WorkspaceConfig, args: argparse.Namespace) -> None:
command = args.session_command
if command == "choices":
print_session_choices(config, args)
return
validate_session_command_args(args)
session = resolve_session_target(config, args)
if command == "status":
session_status(config, session, args)
return
online = not args.offline
prepared_stage = prepare_session_stage(session, args, online) if session_stage_requested(args) else None
if command in {"reset", "start", "refresh", "attach", "detach"} and args.pane != "none":
# Resolve and protect the target before rm/start/MCP selection. The
# respawn call resolves it once more immediately before replacement.
resolve_tmux_session_pane(args.pane, force=args.force_pane)
if command == "refresh" and args.pane == "none" and prepared_stage is not None and online:
record, error = ready_mcp_record(
config,
session.instance,
session.card_path,
session.profile,
session.target,
)
if record is None:
raise SystemExit(
"Online refresh with --pane none requires an already live debugger: " + error
)
if command == "detach":
details = respawn_tmux_session_pane(
args.pane,
session.card_path,
None,
dry_run=args.dry_run,
force=args.force_pane,
stem_instance=session.instance,
)
print_session_target(session)
print_key_values(details)
print("status\tdetached" if not args.dry_run else "status\tdry-run")
return
if command in {"stage", "checkpoint"}:
print_session_target(session)
print_prepared_stage(prepared_stage or {}, online)
if args.dry_run:
print("status\tdry-run")
return
if online:
wait_for_session_mcp(
config, session.instance, args.timeout, session.card_path, session.profile, session.target
)
select_session_stage(
config,
session,
args,
online=online,
prepared_entry=prepared_stage,
)
return
print_session_target(session)
if prepared_stage is not None:
print_prepared_stage(prepared_stage, online)
if command == "reset":
remove_command, remove_details = session_env_command(config, session, "rm")
print(f"remove_command\t{remove_command}")
if not args.dry_run:
subprocess.run(["bash", "-lc", remove_command], check=True)
if args.pane == "none":
start_command, _ = session_env_command(config, session, "start")
print(f"start_command\t{start_command}")
if not args.dry_run:
subprocess.run(["bash", "-lc", start_command], check=True)
print_env_plan(remove_details)
if prepared_stage is not None and not args.dry_run:
select_session_stage(
config, session, args, online=False, prepared_entry=prepared_stage
)
return
debug_command, debug_details = session_env_command(config, session, "debug")
pane_details = respawn_tmux_session_pane(
args.pane,
session.card_path,
debug_command,
dry_run=args.dry_run,
force=args.force_pane,
stem_instance=session.instance,
)
print_key_values(pane_details)
if args.dry_run:
print_env_plan(debug_details)
return
wait_for_session_mcp(
config, session.instance, args.timeout, session.card_path, session.profile, session.target
)
select_mcp_instance(config, session.instance)
if prepared_stage is not None:
select_session_stage(
config, session, args, online=online, prepared_entry=prepared_stage
)
return
if command == "start":
if args.pane == "none":
start_command, details = session_env_command(config, session, "start")
if args.dry_run:
print_env_plan(details)
else:
subprocess.run(["bash", "-lc", start_command], check=True)
if prepared_stage is not None:
select_session_stage(
config, session, args, online=False, prepared_entry=prepared_stage
)
return
debug_command, details = session_env_command(config, session, "debug")
pane_details = respawn_tmux_session_pane(
args.pane,
session.card_path,
debug_command,
dry_run=args.dry_run,
force=args.force_pane,
stem_instance=session.instance,
)
print_key_values(pane_details)
if args.dry_run:
print_env_plan(details)
return
wait_for_session_mcp(
config, session.instance, args.timeout, session.card_path, session.profile, session.target
)
select_mcp_instance(config, session.instance)
if prepared_stage is not None:
select_session_stage(
config, session, args, online=online, prepared_entry=prepared_stage
)
return
if command == "refresh":
start_command, details = session_env_command(config, session, "start")
if args.dry_run:
print_env_plan(details)
else:
subprocess.run(["bash", "-lc", start_command], check=True)
record, _ = (
ready_mcp_record(
config,
session.instance,
session.card_path,
session.profile,
session.target,
)
if not args.dry_run
else (None, "")
)
pane_action = "attach" if record is not None else "debug"
pane_command, pane_env_details = session_env_command(config, session, pane_action)
if args.pane != "none":
pane_details = respawn_tmux_session_pane(
args.pane,
session.card_path,
pane_command,
dry_run=args.dry_run,
force=args.force_pane,
stem_instance=session.instance,
)
print_key_values(pane_details)
elif record is None and not args.dry_run and (prepared_stage is None or online):
raise SystemExit("The debugger is not ready and refresh cannot create it with --pane none.")
if args.dry_run:
print_env_plan(pane_env_details)
return
if record is not None or args.pane != "none":
wait_for_session_mcp(
config, session.instance, args.timeout, session.card_path, session.profile, session.target
)
select_mcp_instance(config, session.instance)
if prepared_stage is not None:
select_session_stage(
config, session, args, online=online, prepared_entry=prepared_stage
)
return
if command == "attach":
if not args.dry_run:
wait_for_session_mcp(
config, session.instance, args.timeout, session.card_path, session.profile, session.target
)
select_mcp_instance(config, session.instance)
attach_command, details = session_env_command(config, session, "attach")
pane_details = respawn_tmux_session_pane(
args.pane,
session.card_path,
attach_command,
dry_run=args.dry_run,
force=args.force_pane,
stem_instance=session.instance,
)
print_key_values(pane_details)
if args.dry_run:
print_env_plan(details)
if prepared_stage is not None and not args.dry_run:
select_session_stage(
config, session, args, online=online, prepared_entry=prepared_stage
)
return
raise SystemExit(f"Unsupported session command: {command}")
def workspace_info_status(config: WorkspaceConfig) -> str:
info_root = workspace_info_root(config)
if (info_root / ".git").is_dir():
return "git"
if config.workspace_info_path.is_file():
return "file"
return "missing"
def sync_workspace_info(config: WorkspaceConfig, pull_existing: bool = True) -> str:
info_root = workspace_info_root(config)
info_root.parent.mkdir(parents=True, exist_ok=True)
if (info_root / ".git").is_dir():
if pull_existing:
subprocess.run(["git", "-C", str(info_root), "pull", "--ff-only"], check=True)
config.workspace_info = json.loads(config.workspace_info_path.read_text(encoding="utf-8"))
return "pulled"
if config.workspace_info_path.is_file():
config.workspace_info = json.loads(config.workspace_info_path.read_text(encoding="utf-8"))
return "existing"
raise SystemExit(f"Workspace info repo exists but manifest is missing: {config.workspace_info_path}")
if info_root.exists():
raise SystemExit(f"Workspace info path exists but is not a git repo: {info_root}")
print(f"workspace-info missing; cloning {config.workspace_info_url}", file=sys.stderr)
subprocess.run(["git", "clone", config.workspace_info_url, str(info_root)], check=True)
if not config.workspace_info_path.is_file():
raise SystemExit(f"Workspace info clone does not contain manifest: {config.workspace_info_path}")
config.workspace_info = json.loads(config.workspace_info_path.read_text(encoding="utf-8"))
return "cloned"
def ensure_workspace_info(config: WorkspaceConfig) -> None:
if config.workspace_info:
return
if config.workspace_info_path.is_file():
config.workspace_info = json.loads(config.workspace_info_path.read_text(encoding="utf-8"))
return
sync_workspace_info(config, pull_existing=False)
def run_workspace(config: WorkspaceConfig, args: argparse.Namespace) -> None:
if args.workspace_command == "show":
print(f"workspace_info_path\t{config.workspace_info_path}")
print(f"workspace_info_url\t{config.workspace_info_url}")
print(f"status\t{workspace_info_status(config)}")
if config.workspace_info:
print(f"workspace\t{config.workspace_info.get('workspace', '')}")
print(f"series\t{len(config.workspace_info.get('series', []))}")
return
if args.workspace_command == "sync":
info_root = workspace_info_root(config)
if args.dry_run:
print(f"workspace_info_path\t{config.workspace_info_path}")
print(f"workspace_info_url\t{config.workspace_info_url}")
print(f"status\t{workspace_info_status(config)}")
print(f"command\t{'pull' if (info_root / '.git').is_dir() else 'clone'}")
return
sync_workspace_info(config, pull_existing=True)
print(f"workspace_info_path\t{config.workspace_info_path}")
print(f"status\tready")
return
if args.workspace_command == "migrate":
migrated = 0
already_ready = 0
candidates = sorted(path for path in config.series_root.glob("*/*") if path.is_dir())
for card_path in candidates:
legacy_state = card_path / ".rv"
stem_state = card_path / ".stem"
if stem_state.exists() or stem_state.is_symlink():
already_ready += 1
continue
if not legacy_state.is_dir():
continue
print(f"state\t{stem_state} -> .rv")
migrated += 1
if not args.dry_run:
stem_state.symlink_to(".rv", target_is_directory=True)
print(f"migrated\t{migrated}")
print(f"already_ready\t{already_ready}")
print(f"mode\t{'dry-run' if args.dry_run else 'applied'}")
return
if args.workspace_command == "doctor":
requested_runtime = os.environ.get("STEM_RUNTIME", "").strip()
runtime = requested_runtime or ("podman" if shutil.which("podman") else "")
runtime_ok = runtime == "podman" and shutil.which("podman") is not None
if requested_runtime and requested_runtime != "podman":
runtime_detail = f"{requested_runtime} is not supported for the rootless student workflow"
else:
runtime_detail = runtime or "missing podman"
checks: list[tuple[str, bool, str]] = [
("workspace", config.workspace_root.is_dir(), str(config.workspace_root)),
("series", config.series_root.is_dir(), str(config.series_root)),
("env-tool", env_tool_command_path(config).is_file(), str(env_tool_command_path(config))),
("runtime", runtime_ok, runtime_detail),
]
socket_probe = config.socket_root / ("t" * 12) / ("i" * 12) / ("c" * 12) / "n.sock"
checks.append(("socket-path", len(os.fsencode(socket_probe)) <= 100, f"{len(os.fsencode(socket_probe))} bytes"))
if runtime_ok:
probe = subprocess.run(
["podman", "info", "--format", "{{.Host.Security.Rootless}}"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
checks.append(("rootless", probe.returncode == 0 and probe.stdout.strip() == "true", probe.stdout.strip() or probe.stderr.strip()))
else:
checks.append(("rootless", False, "rootless Podman is required"))
print("check\tstatus\tdetail")
for name, ok, detail in checks:
print(f"{name}\t{'ok' if ok else 'FAIL'}\t{detail}")
if not all(ok for _, ok, _ in checks):
raise SystemExit(1)
return
raise SystemExit(f"Unsupported workspace command: {args.workspace_command}")
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"workspace_info_path\t{config.workspace_info_path}")
print(f"workspace_info_url\t{config.workspace_info_url}")
print(f"workspace_info_status\t{workspace_info_status(config)}")
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"env_tool_url\t{config.env_tool_url}")
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="API user recorded on the token.")
parser.add_argument("--token-name", help="Remote id inside tokens.json, for example 'r1'. Default: --remote.")
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("STEMCTL - launcher kart pracy STEM")
print()
print("Usage:")
print(" ./stemctl <command> [options]")
print(" ./stemctl tokens <command> [options]")
print(" ./stemctl <command> --help")
print()
print("Commands:")
print_table(
["command", "arguments", "purpose"],
[
["show-config", "", "show resolved paths and Git defaults"],
["workspace", "show|sync|migrate|doctor", "manage and diagnose workspace"],
["series", "list|use|cards|fetch|tasks", "work with series, cards and tasks"],
["card", "use [series] card", "set the default card, optionally with the default series"],
["tasks", "list|show|switch", "shortcut for tasks in the default card"],
["env", "list|sync|ensure|build", "build three container profiles from Dockerfile"],
["build/test/run/debug", "PROFILE [selector]", "execute a card action"],
["deploy", "rp2350 [selector] --target TARGET", "flash a physical RP2350"],
["start/status/attach/stop/rm", "PROFILE [selector]", "manage a persistent rootless container"],
["probe", "list", "discover RP2350 probes and BOOTSEL targets"],
["mcp", "tmux|nvim INSTANCE", "serve one verified container session over stdio"],
["session", "choices|status|start|reset|refresh|attach|detach|stage", "select and control a lesson session"],
["list-series", "", "compat alias for series list"],
["list-cards", "[series]", "compat alias for series cards list"],
["tmux-container", "[series] [card]", "compat: run canonical rootless stem shell in pane 0"],
["submission", "[series] [card] --class K [--task T]", "prepare answer repo and student branch"],
["tokens", "list|compare|cmp|sync|update|remove|rm|add|read|stats|write", "manage tokens.json and Git remote credentials"],
],
)
print()
print("Typical flow:")
print_table(
["step", "command", "result"],
[
["1", "./stemctl workspace sync", "clone or update workspace-info"],
["2", "./stemctl series cards fetch inf bss", "fetch the card on the host"],
["3", "./stemctl env ensure native-amd64", "build or reuse the local native profile"],
["4", "./stemctl test native-amd64 1", "run native golden/sanitizer tests"],
["5", "./stemctl test hazard3-sim 1", "run the same vectors on Hazard3"],
["6", "./stemctl debug hazard3-sim 1", "open common tmux/nvim/GDB UI"],
["7", "./stemctl deploy rp2350 1 --target rp2350-rv --device /dev/bus/usb/001/006", "flash during a practical class"],
],
)
print()
print_config_summary(config_path)
print()
print("Docs:")
print(" doc/rvctl.md")
print(" doc/tokens.md")
print(" doc/tokens.schema.json")
print(" doc/containers.md")
print(" doc/workspace.md")
print(" doc/session.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"],
[
["list", "store|remote|both", "read-only", "list one source without comparing it"],
["compare/cmp", "[--repo PATH]", "read-only", "compare remote/store with marker and auth masks"],
["add", "REMOTE_ID", "tokens.json", "add an empty token skeleton for manual editing"],
["read", "[--server ENDPOINT]", "tokens.json", "show servers, remote ids and tokens"],
["stats", "[--repo PATH]", "repo + tokens.json", "compare remote URLs with local token store"],
["sync remote", "REMOTE_ID [--repo PATH]", "repo -> tokens.json", "copy one remote URL into the store"],
["sync store", "REMOTE_ID [--repo PATH]", "tokens.json -> repo", "write one store record into a remote URL"],
["remove/rm", "store|remote|both REMOTE_ID", "selected", "remove token store record or Git remote"],
["write", "--remote R [--replace]", "tokens.json -> repo", "write selected token into a remote URL"],
["update", "REMOTE_ID", "API -> tokens.json", "refresh valid/scope/org/repo metadata"],
],
)
print()
print("Examples:")
print_table(
["case", "command"],
[
["list token store", "./rvctl tokens list store"],
["list launcher remotes", "./rvctl tokens list remote"],
["compare launcher state", "./rvctl tokens cmp"],
["read remote r1 into store", "./rvctl tokens sync remote r1"],
["refresh r1 metadata", "./rvctl tokens update r1"],
["write store r1 to remote", "./rvctl tokens sync store r1 --repo PATH"],
["remove r1 from store", "./rvctl tokens rm store r1"],
["remove Git remote r1", "./rvctl tokens rm remote r1"],
["compare selected card", "./rvctl tokens cmp --repo ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack"],
["stats selected card", "./rvctl tokens stats --repo ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack"],
["write auth to r1", "./rvctl tokens write --repo PATH --remote r1 --server URL"],
],
)
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 positive_finite_float(raw_value: str) -> float:
try:
value = float(raw_value)
except ValueError as error:
raise argparse.ArgumentTypeError("must be a number") from error
if not (value > 0 and value < float("inf")):
raise argparse.ArgumentTypeError("must be a finite number greater than zero")
return value
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog=os.environ.get("STEM_CLI_NAME", Path(sys.argv[0]).name),
description="STEM card launcher for native AMD64, Hazard3 simulation and RP2350.",
)
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'.")
workspace_parser = subparsers.add_parser("workspace", help="Manage workspace metadata manifest.")
workspace_subparsers = workspace_parser.add_subparsers(dest="workspace_command", required=True)
workspace_subparsers.add_parser("show", help="Show workspace-info configuration.")
workspace_sync_parser = workspace_subparsers.add_parser("sync", help="Clone or update workspace-info into meta/.")
workspace_sync_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.")
workspace_migrate_parser = workspace_subparsers.add_parser(
"migrate", help="Expose legacy .rv state as .stem without deleting data."
)
workspace_migrate_parser.add_argument("--dry-run", action="store_true", help="Only list state directories.")
workspace_subparsers.add_parser("doctor", help="Check workspace, rootless runtime and MCP socket budget.")
env_parser = subparsers.add_parser("env", help="Manage the three canonical container environments.")
env_subparsers = env_parser.add_subparsers(dest="env_command", required=True)
env_subparsers.add_parser("list", help="List supported environment profiles.")
env_sync_parser = env_subparsers.add_parser("sync", help="Clone or update rv32i-hazard3-student-env into tools/.")
env_sync_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.")
env_sources_parser = env_subparsers.add_parser(
"sources", help="Prepare or verify pinned Hazard3 sources in one host card workspace."
)
env_sources_parser.add_argument("series", nargs="?", help="Series id; defaults to workspace.json.")
env_sources_parser.add_argument("card", nargs="?", help="Card id; defaults to workspace.json.")
env_sources_parser.add_argument("--check", action="store_true", help="Verify without changing the workspace.")
env_sources_parser.add_argument("--dry-run", action="store_true", help="Print the preparation command only.")
env_build_parser = env_subparsers.add_parser("build", help="Build one container profile.")
env_build_parser.add_argument("profile", choices=ENV_PROFILE_CHOICES, help="Environment profile.")
env_build_parser.add_argument("--dry-run", action="store_true", help="Print command without building.")
env_ensure_parser = env_subparsers.add_parser("ensure", help="Reuse or locally build a profile from Dockerfile.")
env_ensure_parser.add_argument("profile", choices=ENV_PROFILE_CHOICES, help="Environment profile.")
env_ensure_parser.add_argument("--dry-run", action="store_true", help="Print command without building.")
for action, help_text in (
("build", "Build the selected task inside a profile."),
("test", "Run the selected task tests inside a profile."),
("run", "Run the selected task without implicit deployment."),
("debug", "Open the selected task in the common tmux/nvim debugger UI."),
("deploy", "Deploy an RP2350 artifact using the explicitly selected probe."),
("shell", "Open a shell in the persistent profile container."),
("start", "Create or start the persistent profile container."),
("status", "Show status of the selected profile container."),
("attach", "Attach to the selected profile container UI."),
("stop", "Stop the selected profile container."),
("rm", "Remove the selected profile container; sources remain on the host."),
):
action_parser = subparsers.add_parser(action, help=help_text)
action_parser.add_argument("profile", choices=ENV_PROFILE_CHOICES, help="Environment profile.")
action_parser.add_argument("selector", nargs="*", help="Optional selector: [task], [card task] or [series card task].")
action_parser.add_argument("--task", help="Task override. Defaults to selector or defaults.task.")
action_parser.add_argument("--target", help="Target override, for example rp2350-rv or rp2350-arm.")
action_parser.add_argument("--instance", help="Stable logical container instance name.")
action_parser.add_argument("--pane", help="Send command to a host tmux pane (for example 0 or %%3).")
action_parser.add_argument("--dry-run", action="store_true", help="Print command without changing state.")
if action in {"debug", "shell", "attach"}:
action_parser.add_argument("--editor", choices=["nvim", "vim"], help="Editor inside the container.")
if action in {"debug", "deploy"}:
action_parser.add_argument(
"--device",
help="Exact host device path reported by 'stemctl probe list'.",
)
if action == "deploy":
action_parser.add_argument(
"--backend",
choices=["probe", "bootsel"],
default="probe",
help="Deployment transport (default: probe).",
)
if action == "run":
action_parser.add_argument(
"--allow-existing-firmware",
action="store_true",
help="For RP2350 only: run firmware whose deployment cannot be verified.",
)
probe_parser = subparsers.add_parser("probe", help="Discover RP2350 debug probes and BOOTSEL devices.")
probe_subparsers = probe_parser.add_subparsers(dest="probe_command", required=True)
probe_list_parser = probe_subparsers.add_parser("list", help="List usable probe/target devices without sudo.")
probe_list_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
mcp_parser = subparsers.add_parser("mcp", help="Select an MCP container or run its bundled MCP server.")
mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_kind", required=True)
for kind in ("tmux", "nvim"):
kind_parser = mcp_subparsers.add_parser(kind, help=f"Serve {kind} tools over MCP stdio.")
kind_parser.add_argument("instance", help="Stable logical instance name (required to avoid cross-container selection).")
mcp_select_parser = mcp_subparsers.add_parser(
"select", help="Atomically point dynamic tmux and Neovim MCP sockets at one container."
)
mcp_select_parser.add_argument("selector", help="Logical instance, container name, or at least 12 ID characters.")
mcp_subparsers.add_parser("status", help="Show and validate the dynamically selected MCP container.")
mcp_subparsers.add_parser("list", help="List registered container instances and socket readiness.")
session_parser = subparsers.add_parser(
"session",
help="Select a card/task/UML stage and manage its container, MCP and tmux pane.",
)
session_subparsers = session_parser.add_subparsers(dest="session_command", required=True)
session_choices_parser = session_subparsers.add_parser(
"choices", help="List numbered series, cards, tasks and UML stages with material status."
)
session_choices_parser.add_argument("--series", help="Series ID or 1-based number.")
session_choices_parser.add_argument("--card", help="Card ID or 1-based number shown in this table.")
session_choices_parser.add_argument("--task", help="Limit UML rows to a task ID or number.")
session_choices_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
session_actions = {
"status": "Show the resolved container, MCP and current browser/UML state.",
"start": "Start the selected container and optionally connect it to a host tmux pane.",
"reset": "Remove and recreate the selected container, then optionally connect it.",
"refresh": "Reuse the selected container and refresh its host-pane connection.",
"attach": "Attach the running selected container to a host tmux pane.",
"detach": "Detach the selected container from the host pane without stopping it.",
"stage": "Select and replay one UML stage (alias: checkpoint).",
}
for session_action, help_text in session_actions.items():
aliases = ["checkpoint"] if session_action == "stage" else []
action_parser = session_subparsers.add_parser(session_action, aliases=aliases, help=help_text)
action_parser.add_argument(
"profile",
nargs="?",
choices=ENV_PROFILE_CHOICES,
default="hazard3-sim",
help="Container profile (default: hazard3-sim).",
)
action_parser.add_argument("--series", help="Series ID or 1-based number.")
action_parser.add_argument("--card", help="Card ID or 1-based number shown by session choices.")
action_parser.add_argument("--task", help="Task ID or number, for example 4.")
action_parser.add_argument("--target", help="Target override, for example hazard3-baremetal.")
action_parser.add_argument("--instance", help="Stable logical container instance name.")
action_parser.add_argument(
"--pane",
default="current:0",
help="Host tmux pane; current:0 means pane 0 of this Codex window, none disables pane changes.",
)
action_parser.add_argument(
"--card-url",
default=os.environ.get("STEM_CARD_URL", "http://127.0.0.1:8080"),
help="HTTP endpoint of the selected card server.",
)
action_parser.add_argument(
"--stage",
help="STEP, PHASE/STEP, BLOCK/PHASE/STEP, canonical TASK/... selector or snapshot_ref.",
)
action_parser.add_argument("--first", action="store_true", help="Select the first UML step explicitly.")
action_parser.add_argument("--block", help="UML block ID or 1-based number.")
action_parser.add_argument("--phase", help="UML phase ID or 1-based number.")
action_parser.add_argument("--step", help="Displayed global step number, local phase number or step ID.")
action_parser.add_argument("--snapshot", help="Snapshot ref or 1-based number in the selection.")
action_parser.add_argument(
"--focus",
choices=["task", "block", "phase", "step", "snapshot"],
default="step",
help="Browser keyboard-navigation focus level.",
)
action_parser.add_argument(
"--offline",
action="store_true",
help="Select the UML stage without following it in the live container.",
)
action_parser.add_argument(
"--control",
action="store_true",
help="Enable browser keyboard control of Neovim together with SYNC.",
)
action_parser.add_argument(
"--timeout",
type=positive_finite_float,
default=60,
help="Seconds to wait for MCP/debugger readiness (default: 60).",
)
action_parser.add_argument(
"--force-pane",
action="store_true",
help="Allow replacing a pane marked as owned by another Codex (never the invoking pane).",
)
action_parser.add_argument("--dry-run", action="store_true", help="Print the plan without changing state.")
if session_action == "status":
action_parser.add_argument("--json", action="store_true", help="Emit machine-readable status JSON.")
action_parser.add_argument(
"--strict",
action="store_true",
help="Exit non-zero unless MCP and the matching card API are both ready.",
)
tasks_parser = subparsers.add_parser("tasks", help="Shortcut for tasks in the default card.")
tasks_subparsers = tasks_parser.add_subparsers(dest="tasks_command", required=True)
tasks_short_list_parser = tasks_subparsers.add_parser("list", help="List tasks from the default or selected card.")
tasks_short_list_parser.add_argument("selector", nargs="*", help="Optional card selector: [card] or [series card].")
tasks_short_show_parser = tasks_subparsers.add_parser("show", help="Show one task from the default or selected card.")
tasks_short_show_parser.add_argument("selector", nargs="+", help="Task selector: task, card task, or series card task.")
tasks_short_switch_parser = tasks_subparsers.add_parser("switch", help="Create or switch to the answer branch for a task.")
tasks_short_switch_parser.add_argument("selector", nargs="+", help="Task selector: task, card task, or series card task.")
tasks_short_switch_parser.add_argument("--branch", help="Override the branch name.")
tasks_short_switch_parser.add_argument("--dry-run", action="store_true", help="Print plan without switching branches.")
card_parser = subparsers.add_parser("card", help="Manage the default card selector.")
card_subparsers = card_parser.add_subparsers(dest="card_command", required=True)
card_use_parser = card_subparsers.add_parser("use", help="Set the default card, optionally with the default series.")
card_use_parser.add_argument("selector", nargs="+", help="Use '<card>' or '<series> <card>', for example 'bss' or 'inf bss'.")
tmux_parser = subparsers.add_parser(
"tmux-container",
help="Compatibility wrapper: create host tmux with the canonical rootless stem shell in pane 0.",
)
tmux_parser.add_argument("series", nargs="?", help="Series id or full selector like 'inf/bss'.")
tmux_parser.add_argument("card", nargs="?", help="Card id or unique fragment, for example 'bss'.")
tmux_parser.add_argument("--profile", choices=ENV_PROFILE_CHOICES, default="hazard3-sim", help="Container profile.")
tmux_parser.add_argument("--session", help="tmux session name.")
tmux_parser.add_argument("--window", help="tmux window name.")
tmux_parser.add_argument("--instance", help="Stable STEM_INSTANCE passed to the canonical runtime.")
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/bss'.")
submission_parser.add_argument("card", nargs="?", help="Card id or unique fragment, for example 'bss'.")
submission_parser.add_argument("--class", dest="class_name", required=True, help="Class id, for example 'c2025-1a-inf'.")
submission_parser.add_argument("--nick", help="Student nick. Defaults to the user from tokens.json.")
submission_parser.add_argument("--task", help="Task name or unique fragment. Defaults to defaults.task.")
submission_parser.add_argument(
"--branch",
help="Override the submission branch name. Defaults to <store-user>T<task-number>a.",
)
submission_parser.add_argument(
"--date",
default=dt.date.today().isoformat(),
help="Lesson date reported in the submission plan. Default: today in YYYY-MM-DD.",
)
submission_parser.add_argument(
"--source-url",
help="Override the source repo URL. By default launcher reads r1 or 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.",
)
series_parser = subparsers.add_parser("series", help="Work with series, cards and tasks.")
series_subparsers = series_parser.add_subparsers(dest="series_command", required=True)
series_subparsers.add_parser("list", help="List available series.")
series_use_parser = series_subparsers.add_parser("use", help="Set the default series.")
series_use_parser.add_argument("series", help="Series id, for example 'inf'.")
series_show_parser = series_subparsers.add_parser("show", help="Show one series.")
series_show_parser.add_argument("series", help="Series id, for example 'inf'.")
series_fetch_parser = series_subparsers.add_parser("fetch", help="Fetch all cards in a series.")
series_fetch_parser.add_argument("series", help="Series id, for example 'inf'.")
series_fetch_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning.")
cards_group_parser = series_subparsers.add_parser("cards", help="Work with cards in a series.")
cards_subparsers = cards_group_parser.add_subparsers(dest="cards_command", required=True)
cards_list_parser = cards_subparsers.add_parser("list", help="List cards in a series.")
cards_list_parser.add_argument("series", help="Series id, for example 'inf'.")
cards_show_parser = cards_subparsers.add_parser("show", help="Show one card.")
cards_show_parser.add_argument("series", help="Series id, for example 'inf'.")
cards_show_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.")
cards_fetch_parser = cards_subparsers.add_parser("fetch", help="Fetch one card into workspace.")
cards_fetch_parser.add_argument("series", help="Series id, for example 'inf'.")
cards_fetch_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.")
cards_fetch_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning.")
cards_submission_parser = cards_subparsers.add_parser("submission", help="Print/apply answer remotes for a card.")
cards_submission_parser.add_argument("series", help="Series id, for example 'inf'.")
cards_submission_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.")
cards_submission_parser.add_argument("--class", dest="class_name", required=True, help="Class id.")
cards_submission_parser.add_argument("--nick", help="Student nick. Defaults to the user from tokens.json.")
cards_submission_parser.add_argument("--task", help="Task name or unique fragment. Defaults to defaults.task.")
cards_submission_parser.add_argument("--branch", help="Override the submission branch name.")
cards_submission_parser.add_argument("--date", default=dt.date.today().isoformat(), help="Lesson date in YYYY-MM-DD.")
cards_submission_parser.add_argument("--source-url", help="Override source repo URL.")
cards_submission_parser.add_argument("--apply", action="store_true", help="Write remotes into the selected card repo.")
tasks_group_parser = cards_subparsers.add_parser("tasks", help="Work with tasks in a card.")
tasks_subparsers = tasks_group_parser.add_subparsers(dest="tasks_command", required=True)
tasks_list_parser = tasks_subparsers.add_parser("list", help="List tasks in a card.")
tasks_list_parser.add_argument("series", help="Series id, for example 'inf'.")
tasks_list_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.")
tasks_show_parser = tasks_subparsers.add_parser("show", help="Show one task.")
tasks_show_parser.add_argument("series", help="Series id, for example 'inf'.")
tasks_show_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.")
tasks_show_parser.add_argument("task", help="Task name or unique fragment.")
tasks_switch_parser = tasks_subparsers.add_parser("switch", help="Create or switch to the answer branch for a task.")
tasks_switch_parser.add_argument("series", help="Series id, for example 'inf'.")
tasks_switch_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.")
tasks_switch_parser.add_argument("task", help="Task name or unique fragment, for example 'task4' or '4'.")
tasks_switch_parser.add_argument("--branch", help="Override the branch name.")
tasks_switch_parser.add_argument("--dry-run", action="store_true", help="Print plan without switching branches.")
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_compare_parser = tokens_subparsers.add_parser(
"compare",
aliases=["cmp"],
help="Compare repo remotes with tokens.json and print paired token rows.",
)
tokens_compare_parser.add_argument(
"--repo",
help="Path inside a target git repo. Default: repo containing rvctl.",
)
tokens_list_parser = tokens_subparsers.add_parser(
"list",
help="List token store records, Git remotes, or both without comparing them.",
)
tokens_list_parser.add_argument("target", choices=["store", "remote", "both"], help="Source to list.")
tokens_list_parser.add_argument(
"--repo",
help="Path inside a target git repo for target remote/both. Default: repo containing rvctl.",
)
tokens_list_parser.add_argument("--server", help="Filter output to one server endpoint.")
tokens_read_parser = tokens_subparsers.add_parser(
"read",
help="Read tokens.json and print stored servers, token ids and remotes.",
)
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_add_parser = tokens_subparsers.add_parser(
"add",
help="Add an empty token skeleton to tokens.json.",
)
tokens_add_parser.add_argument("token_id", metavar="REMOTE_ID", help="Remote id, for example 'r1' or 'r1a'.")
tokens_add_parser.add_argument("--server", help="Server endpoint. Default: git.base_url from workspace.json.")
tokens_add_parser.add_argument("--value", default="", help="Optional token value. Default: empty for manual editing.")
tokens_add_parser.add_argument("--user", help="Optional API user label.")
tokens_add_parser.add_argument("--remote", help="Compatibility alias. If used, it must match REMOTE_ID.")
tokens_add_parser.add_argument("--org", help="Optional remote organization.")
tokens_add_parser.add_argument("--repo", dest="repo_name", help="Optional remote repository.")
tokens_add_parser.add_argument("--dry-run", action="store_true", help="Print the planned token without writing it.")
tokens_stats_parser = tokens_subparsers.add_parser(
"stats",
help="Print per-server token statistics from tokens.json.",
)
tokens_stats_parser.add_argument(
"--repo",
help="Path inside a target git repo. Default: repo containing rvctl.",
)
tokens_stats_parser.add_argument("--server", help="Filter output to one server endpoint.")
tokens_sync_parser = tokens_subparsers.add_parser(
"sync",
help="Synchronize one remote id between git remote URL and tokens.json.",
)
tokens_sync_parser.add_argument("direction", choices=["remote", "store"], help="'remote' reads git remote into store; 'store' writes store into git remote.")
tokens_sync_parser.add_argument("remote_id", help="Remote id, for example 'r1' or 'r1a'.")
tokens_sync_parser.add_argument(
"--repo",
help="Path inside a target git repo. Default: repo containing rvctl.",
)
tokens_sync_parser.add_argument("--url", help="Override target remote URL when writing store -> remote.")
add_store_selection_options(tokens_sync_parser)
tokens_sync_parser.add_argument("--replace", action="store_true", help="Replace different credentials when writing store -> remote.")
tokens_sync_parser.add_argument("--dry-run", action="store_true", help="Print the planned sync without writing it.")
tokens_remove_parser = tokens_subparsers.add_parser(
"remove",
aliases=["rm"],
help="Remove a token store record, a Git remote, or both.",
)
tokens_remove_parser.add_argument("target", choices=["store", "remote", "both"], help="Where to remove REMOTE_ID from.")
tokens_remove_parser.add_argument("remote_id", help="Remote id, for example 'r1' or 'r1a'.")
tokens_remove_parser.add_argument(
"--repo",
help="Path inside a target git repo for target remote/both. Default: repo containing rvctl.",
)
tokens_remove_parser.add_argument("--server", help="Server endpoint used to disambiguate store records.")
tokens_remove_parser.add_argument("--dry-run", action="store_true", help="Print the planned removal without writing it.")
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="Override target remote URL when creating or updating the remote.")
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="Refresh API metadata for one remote id, or use a legacy --from direction.",
)
tokens_update_parser.add_argument("remote_id", nargs="?", help="Remote id to refresh from API, for example 'r1'.")
tokens_update_parser.add_argument(
"--from",
dest="from_source",
choices=["remotes", "store"],
help="Legacy 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="Override target remote URL 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)
profile_actions = {"build", "test", "run", "debug", "deploy", "shell", "start", "status", "attach", "stop", "rm"}
if args.command in {"list-series", "list-cards", "series", "card", "tasks", "session", "tmux-container", "submission"} | profile_actions:
ensure_workspace_info(config)
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 == "workspace":
run_workspace(config, args)
return 0
if args.command == "env":
run_env(config, args)
return 0
if args.command == "probe":
run_probe(config, args)
return 0
if args.command == "mcp":
run_mcp(config, args)
return 0
if args.command == "session":
run_session(config, args)
return 0
if args.command in profile_actions:
run_profile_action(config, args, args.command)
return 0
if args.command == "tasks":
run_tasks(config, args)
return 0
if args.command == "card":
run_card(config, args)
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 == "series":
run_series(config, args)
return 0
if args.command == "tokens":
if args.tokens_command in {"compare", "cmp"}:
run_tokens_compare(config, args)
return 0
if args.tokens_command == "list":
run_tokens_list(config, args)
return 0
if args.tokens_command == "add":
run_tokens_add(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 == "sync":
run_tokens_sync(config, args)
return 0
if args.tokens_command in {"remove", "rm"}:
run_tokens_remove(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())