feat(cli): AGNES_MARKETPLACE_URL env override for refresh-marketplace --bootstrap (#345 A) (#354)

Deployments that serve the marketplace from a different host than the
API (reverse-proxy split, CDN-fronted marketplace, etc.) now bootstrap
cleanly. Default behaviour unchanged — falls back to
`{server_host}/marketplace.git/` derived from the workspace's
bootstrapped server URL.

Implementation:
- AGNES_MARKETPLACE_URL parsed via urlparse; missing scheme/host
  fail-fast with a clear error rather than silently falling back, so
  operator misconfigurations surface immediately.
- PAT is re-injected into the override URL the same way as the default
  path (user-info form of HTTP basic for git clone), then stripped on
  the post-clone `remote set-url` so it doesn't sit at rest in
  .git/config.
- Trailing slash normalized so urljoin-style consumers don't drop the
  path. Path defaults to /marketplace.git/ if the override omits it.

Help text updated to document the override.

Tests: 2 new in test_cli_refresh_marketplace.py (env override happy
path + invalid URL rejection). 46/46 refresh_marketplace passes.
This commit is contained in:
ZdenekSrotyr 2026-05-19 16:47:26 +02:00 committed by GitHub
parent c910281df1
commit 82a4b663d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 122 additions and 16 deletions

View file

@ -10,6 +10,9 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased]
### Added
- `AGNES_MARKETPLACE_URL` env override for `agnes refresh-marketplace --bootstrap`. Pre-fix the marketplace endpoint was hardcoded to `{server_host}/marketplace.git/`, which broke deployments that serve the marketplace from a different host than the API (reverse-proxy split, CDN-fronted marketplace). When set, the env var is parsed via `urlparse`; missing scheme or host fails fast with a clear error (operator misconfiguration surfaces immediately). The PAT injection / strip behavior is preserved on the override path. Default behavior unchanged when the env var is empty / unset. Closes #345 A.
### Added
- `agnes query --json` is now a shortcut for `--format json` — paste-prompts and LLM-assisted analysts routinely reach for `--json` first, and the typer "Did you mean `--stdin`?" suggestion the missing flag previously produced was actively misleading. `--json --format <other>` is rejected as mutually exclusive (`--json --format json` is redundantly allowed). Closes #345 D.

View file

@ -76,7 +76,10 @@ def refresh_marketplace(
"If no marketplace clone exists yet, clone it and register the "
"local path with Claude Code. Used by the install flow as a "
"one-liner replacement for an inline `git clone` + chmod + "
"`claude plugin marketplace add` sequence."
"`claude plugin marketplace add` sequence. Marketplace URL "
"defaults to {server_host}/marketplace.git/; override via the "
"AGNES_MARKETPLACE_URL env var when the marketplace lives on "
"a different host than the API."
),
),
):
@ -177,6 +180,34 @@ def _bootstrap_clone(token: str) -> bool:
in plaintext at `.git/config` (refreshes use the credential helper).
Returns False on any failure.
"""
# Marketplace URL resolution (issue #345 A):
# 1. `AGNES_MARKETPLACE_URL` env var — explicit override for deployments
# that serve the marketplace from a different host than the API
# (reverse-proxy split, CDN-fronted marketplace, etc.). Must be a
# full base URL including scheme, ending at the `/marketplace.git/`
# path or its equivalent on the operator's deployment.
# 2. Fallback to ``{scheme}://{server_host}/marketplace.git/`` derived
# from the workspace's bootstrapped server URL.
marketplace_base = os.environ.get("AGNES_MARKETPLACE_URL", "").strip()
if marketplace_base:
parsed = urlparse(marketplace_base)
if not parsed.scheme or not parsed.hostname:
typer.echo(
f"error: AGNES_MARKETPLACE_URL={marketplace_base!r} is not a full URL "
"(expected scheme://host[:port]/marketplace.git/).",
err=True,
)
return False
# Normalize: ensure trailing slash so urljoin-style consumers don't drop the path.
clean_url = marketplace_base if marketplace_base.endswith("/") else marketplace_base + "/"
scheme = parsed.scheme
# Re-inject the PAT into the env-supplied URL so the clone authenticates.
host_with_port = parsed.netloc.split("@", 1)[-1]
path = parsed.path if parsed.path else "/marketplace.git/"
if not path.endswith("/"):
path += "/"
auth_url = f"{scheme}://x:{token}@{host_with_port}{path}"
else:
server_url = get_server_url()
if not server_url:
typer.echo("error: no server URL configured; run `agnes init` first.", err=True)
@ -190,6 +221,8 @@ def _bootstrap_clone(token: str) -> bool:
if parsed.port:
server_host = f"{server_host}:{parsed.port}"
scheme = parsed.scheme or "https"
auth_url = f"{scheme}://x:{token}@{server_host}/marketplace.git/"
clean_url = f"{scheme}://{server_host}/marketplace.git/"
# Stale dir without a `.git/` subdir means an interrupted prior install;
# remove it so the fresh clone has somewhere to land.
@ -202,9 +235,6 @@ def _bootstrap_clone(token: str) -> bool:
CLONE_DIR.parent.mkdir(parents=True, exist_ok=True)
auth_url = f"{scheme}://x:{token}@{server_host}/marketplace.git/"
clean_url = f"{scheme}://{server_host}/marketplace.git/"
typer.echo(f"Cloning marketplace from {clean_url} into {CLONE_DIR}...")
try:

View file

@ -616,6 +616,79 @@ def test_bootstrap_with_no_existing_clone_clones_and_registers(
assert add_calls[0].cmd[4] == str(clone_target)
def test_bootstrap_honors_marketplace_url_env_override(
tmp_path, monkeypatch, with_token, claude_in_path, recorder,
):
"""``AGNES_MARKETPLACE_URL`` overrides the derived ``server_host/marketplace.git/``
base for deployments that serve the marketplace from a different host
than the API reverse-proxy split, CDN-fronted marketplace, etc.
Issue #345 A.
"""
cfg_dir = tmp_path / "_cfg"
(cfg_dir / "config.yaml").write_text(
"server: https://agnes.example.com\n", encoding="utf-8",
)
monkeypatch.setenv("AGNES_MARKETPLACE_URL", "https://plugins.example.com/marketplace.git/")
clone_target = tmp_path / "fresh_marketplace"
monkeypatch.setattr(rm_module, "CLONE_DIR", clone_target)
real_run = recorder.run
def fake_run(cmd, *args, **kwargs):
if cmd[:2] == ["git", "clone"]:
(clone_target / ".git").mkdir(parents=True, exist_ok=True)
(clone_target / ".claude-plugin").mkdir(parents=True, exist_ok=True)
(clone_target / ".claude-plugin" / "marketplace.json").write_text(
json.dumps({"name": "agnes", "plugins": []}),
encoding="utf-8",
)
return real_run(cmd, *args, **kwargs)
monkeypatch.setattr(rm_module.subprocess, "run", fake_run)
result = runner.invoke(refresh_marketplace_app, ["--bootstrap"])
assert result.exit_code == 0, result.output
# Clone URL must point at the env-override host, NOT at the
# api server's hostname, and must carry the PAT.
clone_calls = [c for c in recorder.calls if c.cmd[:2] == ["git", "clone"]]
assert len(clone_calls) == 1
url_arg = next(a for a in clone_calls[0].cmd if a.startswith("https://"))
assert "plugins.example.com/marketplace.git/" in url_arg
assert "agnes.example.com" not in url_arg
assert with_token in url_arg
# PAT-stripped URL after clone is also the override host.
set_url_calls = [
c for c in recorder.calls
if c.cmd[:5] == ["git", "-C", str(clone_target), "remote", "set-url"]
]
assert len(set_url_calls) == 1
new_url = set_url_calls[0].cmd[6]
assert "plugins.example.com/marketplace.git/" in new_url
assert with_token not in new_url
def test_bootstrap_rejects_invalid_marketplace_url_env(
tmp_path, monkeypatch, with_token, claude_in_path,
):
"""``AGNES_MARKETPLACE_URL`` without scheme is rejected with a clear
error silent fallback to the derived URL would hide an operator
misconfiguration. Issue #345 A.
"""
cfg_dir = tmp_path / "_cfg"
(cfg_dir / "config.yaml").write_text(
"server: https://agnes.example.com\n", encoding="utf-8",
)
monkeypatch.setenv("AGNES_MARKETPLACE_URL", "plugins.example.com/marketplace.git/")
monkeypatch.setattr(rm_module, "CLONE_DIR", tmp_path / "fresh_marketplace")
result = runner.invoke(refresh_marketplace_app, ["--bootstrap"])
assert result.exit_code == 1
assert "AGNES_MARKETPLACE_URL" in result.output
def test_bootstrap_clone_failure_exits_nonzero(
tmp_path, monkeypatch, with_token, claude_in_path, recorder,
):