feat(activity): multi-service config, make help default, install-cli
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 21s

Bare make lists targets. make install-cli installs the activity tool via uv.
Named activity-core backends live in ~/.config/activity/services.json with
list/add/use/default/which; -s/--service selects one call without changing
the default; --activity-url is a one-shot override.
This commit is contained in:
tegwick 2026-08-06 17:25:54 +02:00
parent 9f4993d9e7
commit 192942244e
8 changed files with 755 additions and 49 deletions

View file

@ -19,8 +19,10 @@ from activity_core.review_cli.merge import (
summarize_runs,
)
from activity_core.review_cli.repo import resolve_repo
from activity_core.review_cli import services as svc_mod
from activity_core.review_cli.sources import (
activity_core_url,
apply_endpoint_overrides,
fetch_hub_progress,
fetch_ops_automations,
fetch_ops_runs_for_automation,
@ -530,6 +532,13 @@ def cmd_status(args: argparse.Namespace) -> int:
"Sources: "
+ " ".join(f"{k}={v}" for k, v in sorted(sources.items()))
)
ep = getattr(args, "_endpoints", None) or {}
if ep:
print(
f"Service: {ep.get('service') or '(none)'} "
f"(source={ep.get('source')}) "
f"api={ep.get('activity_core_url') or '(unset)'}"
)
for w in ctx["warnings"]:
print(f"warn: {w}", file=sys.stderr)
if run_summary.get("failed"):
@ -569,6 +578,152 @@ def cmd_checkpoint(args: argparse.Namespace) -> int:
return 2
def cmd_service(args: argparse.Namespace) -> int:
"""Manage named activity-core service endpoints."""
action = args.service_action
cfg_path = Path(args.config_dir).expanduser() if args.config_dir else None
path = svc_mod.config_path(cfg_path) if cfg_path else None
try:
if action == "list":
cfg = svc_mod.load_config(path)
if args.format == "json":
_print_json(
{
"config_path": str(path or svc_mod.config_path()),
"default": cfg.get("default"),
"services": cfg.get("services") or {},
}
)
return 0
services = cfg.get("services") or {}
default = cfg.get("default")
print(f"config: {path or svc_mod.config_path()}")
if not services:
print("(no services configured)")
print(
" activity service add railiance "
"--url https://activity.coulomb.social"
)
return 0
print(f"default: {default or '(none)'}")
for name, svc in sorted(services.items()):
mark = "*" if name == default else " "
desc = svc.get("description") or ""
print(f" {mark} {name}")
print(f" activity_core_url: {svc.get('activity_core_url')}")
if svc.get("state_hub_url"):
print(f" state_hub_url: {svc.get('state_hub_url')}")
if desc:
print(f" description: {desc}")
return 0
if action == "show":
name = args.name or svc_mod.load_config(path).get("default")
if not name:
print("no service name and no default set", file=sys.stderr)
return 2
svc = svc_mod.get_service(name, path)
if not svc:
print(f"unknown service {name!r}", file=sys.stderr)
return 2
report = {"name": name, **svc, "is_default": name == svc_mod.load_config(path).get("default")}
if args.format == "json":
_print_json(report)
else:
print(f"service: {name}")
for k, v in svc.items():
print(f" {k}: {v}")
return 0
if action == "add":
if not args.name or not args.url:
print("usage: activity service add NAME --url URL [--hub-url URL]", file=sys.stderr)
return 2
cfg = svc_mod.add_service(
args.name,
activity_core_url=args.url,
state_hub_url=args.hub_url,
description=args.description or "",
make_default=bool(args.make_default),
path=path,
)
if args.format == "json":
_print_json(cfg)
else:
print(f"added service {args.name!r}")
print(f" activity_core_url={args.url}")
if args.hub_url:
print(f" state_hub_url={args.hub_url}")
print(f" default={cfg.get('default')}")
print(f" config={path or svc_mod.config_path()}")
return 0
if action == "remove":
if not args.name:
print("usage: activity service remove NAME", file=sys.stderr)
return 2
cfg = svc_mod.remove_service(args.name, path)
print(f"removed {args.name!r}; default={cfg.get('default')}")
return 0
if action == "use":
if not args.name:
print("usage: activity service use NAME", file=sys.stderr)
return 2
cfg = svc_mod.set_default(args.name, path)
print(f"default service → {cfg.get('default')}")
return 0
if action == "default":
cfg = svc_mod.load_config(path)
name = cfg.get("default")
if args.format == "json":
_print_json(
{
"default": name,
"service": cfg["services"].get(name) if name else None,
}
)
else:
if not name:
print("default: (none)")
else:
print(f"default: {name}")
svc = cfg["services"].get(name) or {}
print(f" activity_core_url: {svc.get('activity_core_url')}")
return 0
if action == "which":
# Show what this invocation would use (after flags)
try:
ep = svc_mod.resolve_endpoints(
service=getattr(args, "service", None),
activity_url=getattr(args, "activity_url", None),
hub_url=getattr(args, "hub_url", None),
config_path_override=path,
)
except KeyError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if args.format == "json":
_print_json(ep)
else:
print(f"source: {ep['source']}")
print(f"service: {ep.get('service') or '(none)'}")
print(f"activity_core_url: {ep.get('activity_core_url') or '(unset)'}")
print(f"state_hub_url: {ep.get('state_hub_url') or '(unset)'}")
print(f"config: {ep.get('config_path')}")
return 0
except (ValueError, KeyError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
print("usage: activity service list|show|add|remove|use|default|which", file=sys.stderr)
return 2
def cmd_ack(args: argparse.Namespace) -> int:
repo = resolve_repo(cwd=Path(args.cwd) if args.cwd else None, explicit=args.repo)
state_dir = Path(args.state_dir).expanduser() if args.state_dir else None
@ -617,19 +772,15 @@ def cmd_ack(args: argparse.Namespace) -> int:
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="activity",
description=(
"Repo-scoped automation review CLI (ACTIVITY-WP-0028). "
"Run from a consumer repo (e.g. freedom-intelligence)."
),
)
def _common_parent() -> argparse.ArgumentParser:
"""Shared flags available on every subcommand (before or after the verb)."""
p = argparse.ArgumentParser(add_help=False)
p.add_argument("--repo", help="Repo slug (default: auto-detect from cwd)")
p.add_argument("--cwd", help="Working directory (default: process cwd)")
p.add_argument("--state-dir", help="Override checkpoint state directory")
p.add_argument(
"--state-dir",
help="Override checkpoint state directory",
"--config-dir",
help="Override config dir (default: ~/.config/activity)",
)
p.add_argument(
"--format",
@ -642,6 +793,41 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Exit 3 when inbox has open items",
)
p.add_argument(
"--service",
"-s",
metavar="NAME",
help=(
"Use named activity-core service for this call only "
"(does not change default; see: activity service)"
),
)
p.add_argument(
"--activity-url",
metavar="URL",
help="One-shot activity-core API base URL (overrides --service / config)",
)
p.add_argument(
"--hub-url",
metavar="URL",
dest="hub_url",
help="One-shot State Hub base URL (or per-service hub when adding)",
)
return p
def build_parser() -> argparse.ArgumentParser:
# Shared options live on each subcommand (not the top parser) so
# `activity list --format json` and `activity -s railiance runs` both work.
common = _common_parent()
p = argparse.ArgumentParser(
prog="activity",
description=(
"Repo-scoped automation review CLI (ACTIVITY-WP-0028). "
"Run from a consumer repo (e.g. freedom-intelligence). "
"Shared flags go after the verb: activity list --format json"
),
)
sub = p.add_subparsers(dest="command", required=True)
for name, help_text in (
@ -651,22 +837,26 @@ def build_parser() -> argparse.ArgumentParser:
("deliverables", "What was produced"),
("inbox", "Open deliverables for review"),
):
sp = sub.add_parser(name, help=help_text)
sp = sub.add_parser(name, help=help_text, parents=[common])
if name in {"runs", "deliverables", "status", "inbox"}:
sp.add_argument(
"--since",
default=None,
help="today|yesterday|week|sunday|checkpoint|ISO (default: checkpoint or today)",
help="today|yesterday|week|sunday|checkpoint|ISO (default: week or checkpoint)",
)
sp_cp = sub.add_parser("checkpoint", help="Show/set/clear local review cursor")
sp_cp = sub.add_parser(
"checkpoint", help="Show/set/clear local review cursor", parents=[common]
)
sp_cp.add_argument(
"checkpoint_action",
choices=("show", "set", "clear"),
help="Action",
)
sp_ack = sub.add_parser("ack", help="Mark path(s) or ops_run id(s) reviewed")
sp_ack = sub.add_parser(
"ack", help="Mark path(s) or ops_run id(s) reviewed", parents=[common]
)
sp_ack.add_argument(
"targets",
nargs="*",
@ -677,13 +867,66 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Ack all deliverable paths since checkpoint",
)
sp_svc = sub.add_parser(
"service",
help="Configure named activity-core services (list/add/use/…)",
parents=[common],
)
sp_svc.add_argument(
"service_action",
choices=("list", "show", "add", "remove", "use", "default", "which"),
help="Action",
)
sp_svc.add_argument(
"name",
nargs="?",
help="Service name (for show/add/remove/use)",
)
sp_svc.add_argument(
"--url",
help="activity-core API base URL (for add)",
)
sp_svc.add_argument(
"--description",
default="",
help="Optional description (for add)",
)
sp_svc.add_argument(
"--make-default",
action="store_true",
help="Set as default when adding",
)
return p
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
# propagate format/strict onto subcommands that share globals
# Resolve multi-service endpoints before command handlers hit the network.
cfg_dir = (
Path(args.config_dir).expanduser()
if getattr(args, "config_dir", None)
else None
)
cfg_path = svc_mod.config_path(cfg_dir) if cfg_dir else None
try:
endpoints = svc_mod.resolve_endpoints(
service=getattr(args, "service", None),
activity_url=getattr(args, "activity_url", None),
hub_url=getattr(args, "hub_url", None),
config_path_override=cfg_path,
)
except KeyError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
apply_endpoint_overrides(
endpoints.get("activity_core_url"),
endpoints.get("state_hub_url"),
)
args._endpoints = endpoints # noqa: SLF001
handlers = {
"list": cmd_list,
"status": cmd_status,
@ -692,6 +935,7 @@ def main(argv: list[str] | None = None) -> int:
"inbox": cmd_inbox,
"checkpoint": cmd_checkpoint,
"ack": cmd_ack,
"service": cmd_service,
}
handler = handlers.get(args.command)
if handler is None:

View file

@ -0,0 +1,232 @@
"""Named activity-core service endpoints (multi-env config).
Config path: ${XDG_CONFIG_HOME:-~/.config}/activity/services.json
Example::
{
"default": "railiance",
"services": {
"railiance": {
"activity_core_url": "https://activity.coulomb.social",
"state_hub_url": "http://127.0.0.1:18000",
"description": "Railiance prod (SSO)"
},
"local": {
"activity_core_url": "http://127.0.0.1:8010",
"state_hub_url": "http://127.0.0.1:8000",
"description": "Local docker/dev stack"
}
}
}
Resolution for activity_core_url (highest first):
1. ``--activity-url`` CLI flag
2. ``--service NAME`` services[NAME].activity_core_url
3. ``ACTIVITY_CORE_URL`` environment variable
4. config default service's activity_core_url
5. unset
"""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any
_SERVICE_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]{0,63}$")
def config_path(config_dir: Path | None = None) -> Path:
if config_dir is not None:
return Path(config_dir).expanduser() / "services.json"
override = os.environ.get("ACTIVITY_CONFIG_DIR", "").strip()
if override:
return Path(override).expanduser() / "services.json"
xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
if xdg:
return Path(xdg).expanduser() / "activity" / "services.json"
return Path.home() / ".config" / "activity" / "services.json"
def empty_config() -> dict[str, Any]:
return {"default": None, "services": {}}
def load_config(path: Path | None = None) -> dict[str, Any]:
p = path or config_path()
if not p.is_file():
return empty_config()
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty_config()
if not isinstance(data, dict):
return empty_config()
services = data.get("services") or {}
if not isinstance(services, dict):
services = {}
# normalize service entries
clean: dict[str, Any] = {}
for name, svc in services.items():
if not isinstance(svc, dict):
continue
clean[str(name)] = {
"activity_core_url": _norm_url(svc.get("activity_core_url")),
"state_hub_url": _norm_url(svc.get("state_hub_url")),
"description": str(svc.get("description") or ""),
}
default = data.get("default")
if default is not None:
default = str(default)
if default not in clean:
default = None
return {"default": default, "services": clean}
def save_config(data: dict[str, Any], path: Path | None = None) -> Path:
p = path or config_path()
p.parent.mkdir(parents=True, exist_ok=True)
payload = {
"default": data.get("default"),
"services": data.get("services") or {},
}
p.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return p
def _norm_url(value: Any) -> str | None:
if not isinstance(value, str):
return None
v = value.strip().rstrip("/")
return v or None
def validate_service_name(name: str) -> str:
name = name.strip()
if not _SERVICE_NAME_RE.match(name):
raise ValueError(
f"invalid service name {name!r}: use letters, digits, _- "
"(start with a letter, max 64 chars)"
)
return name
def add_service(
name: str,
*,
activity_core_url: str,
state_hub_url: str | None = None,
description: str = "",
make_default: bool = False,
path: Path | None = None,
) -> dict[str, Any]:
name = validate_service_name(name)
url = _norm_url(activity_core_url)
if not url:
raise ValueError("activity_core_url is required")
cfg = load_config(path)
cfg["services"][name] = {
"activity_core_url": url,
"state_hub_url": _norm_url(state_hub_url),
"description": description or "",
}
if make_default or not cfg.get("default"):
cfg["default"] = name
save_config(cfg, path)
return cfg
def remove_service(name: str, path: Path | None = None) -> dict[str, Any]:
cfg = load_config(path)
if name not in cfg["services"]:
raise KeyError(f"unknown service {name!r}")
del cfg["services"][name]
if cfg.get("default") == name:
cfg["default"] = next(iter(cfg["services"]), None)
save_config(cfg, path)
return cfg
def set_default(name: str, path: Path | None = None) -> dict[str, Any]:
cfg = load_config(path)
if name not in cfg["services"]:
raise KeyError(f"unknown service {name!r}")
cfg["default"] = name
save_config(cfg, path)
return cfg
def get_service(name: str, path: Path | None = None) -> dict[str, Any] | None:
cfg = load_config(path)
return cfg["services"].get(name)
def resolve_endpoints(
*,
service: str | None = None,
activity_url: str | None = None,
hub_url: str | None = None,
config_path_override: Path | None = None,
) -> dict[str, Any]:
"""Resolve which activity-core / hub URLs to use for this invocation.
Returns dict with keys:
activity_core_url, state_hub_url, service (name or None),
source (flag|service|env|default|none)
"""
cfg = load_config(config_path_override)
selected_name: str | None = None
source = "none"
core: str | None = None
hub: str | None = None
# 1) explicit one-shot URL flags
if activity_url and activity_url.strip():
core = _norm_url(activity_url)
source = "flag"
# 2) named service (one-shot; does not change default)
elif service and service.strip():
name = service.strip()
svc = cfg["services"].get(name)
if not svc:
known = ", ".join(sorted(cfg["services"])) or "(none)"
raise KeyError(f"unknown service {name!r}; known: {known}")
selected_name = name
core = svc.get("activity_core_url")
hub = svc.get("state_hub_url")
source = "service"
# 3) environment
elif os.environ.get("ACTIVITY_CORE_URL", "").strip():
core = _norm_url(os.environ.get("ACTIVITY_CORE_URL"))
source = "env"
# 4) config default
elif cfg.get("default") and cfg["default"] in cfg["services"]:
selected_name = cfg["default"]
svc = cfg["services"][selected_name]
core = svc.get("activity_core_url")
hub = svc.get("state_hub_url")
source = "default"
# Hub precedence: --hub-url > service-selected hub > env > none
# (do not let env override an explicit --service / default service hub)
if hub_url and hub_url.strip():
hub = _norm_url(hub_url)
elif hub is None and (
os.environ.get("STATE_HUB_URL", "").strip()
or os.environ.get("STATEHUB_URL", "").strip()
):
hub = _norm_url(
os.environ.get("STATE_HUB_URL") or os.environ.get("STATEHUB_URL")
)
return {
"activity_core_url": core,
"state_hub_url": hub,
"service": selected_name,
"source": source,
"config_path": str(config_path_override or config_path()),
}

View file

@ -8,13 +8,42 @@ from typing import Any
import httpx
# Set by main after resolving --service / config / env (see services.resolve_endpoints).
_endpoint_override_active = False
_override_activity_url: str | None = None
_override_hub_url: str | None = None
def apply_endpoint_overrides(
activity_core_url: str | None = None,
state_hub_url: str | None = None,
) -> None:
"""Pin URLs for this process (call once after service resolution)."""
global _endpoint_override_active, _override_activity_url, _override_hub_url
_endpoint_override_active = True
_override_activity_url = (
activity_core_url.strip().rstrip("/") if activity_core_url else None
)
_override_hub_url = state_hub_url.strip().rstrip("/") if state_hub_url else None
def clear_endpoint_overrides() -> None:
global _endpoint_override_active, _override_activity_url, _override_hub_url
_endpoint_override_active = False
_override_activity_url = None
_override_hub_url = None
def activity_core_url() -> str | None:
if _endpoint_override_active:
return _override_activity_url
raw = (os.environ.get("ACTIVITY_CORE_URL") or "").strip().rstrip("/")
return raw or None
def state_hub_url() -> str | None:
if _endpoint_override_active:
return _override_hub_url
raw = (
os.environ.get("STATE_HUB_URL")
or os.environ.get("STATEHUB_URL")