Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ dev-crewai = [

[project.scripts]
band-acp = "band.integrations.acp.cli:entry_point"
band-register-agent = "band.cli.register_agent:main"
band-trigger = "band.cli.trigger:main"

[tool.setuptools.packages.find]
Expand All @@ -225,6 +226,7 @@ include-package-data = true

[tool.setuptools.package-data]
"band.integrations.slack" = ["templates/*.yaml"]
"band.cli" = ["*.sh"]

[tool.pytest.ini_options]
testpaths = ["tests"]
Expand Down
76 changes: 76 additions & 0 deletions src/band/cli/register-agent.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
#
# Register a Band external agent from a user API key, using only curl + a JSON
# parser. A portable, dependency-light alternative to the Hermes plugin's
# hermes_band_platform/skills/add-band/scripts/register_agent.py: no Python SDK,
# no hermes_cli, no cloned repo — so a bootstrap can mint a Band agent before it
# installs anything (the same shape openclaw/bootstrap.sh uses).
#
# Security: the user key is read from $BAND_USER_API_KEY (never an argument) and
# handed to curl through a --config heredoc on stdin, so it never appears in any
# process's argv (`ps`). Only the returned agent-scoped id + key are printed; the
# user key is never echoed.
#
# Output (stdout — capture- / eval-able):
# BAND_AGENT_ID=<uuid>
# BAND_API_KEY=<agent-key>
#
# Usage (bundled with the SDK as the `band-register-agent` command):
# export BAND_USER_API_KEY=... # required
# eval "$(band-register-agent)" # sets BAND_AGENT_ID + BAND_API_KEY
#
# Env knobs: BAND_BASE_URL (default https://app.band.ai),
# BAND_AGENT_NAME, BAND_AGENT_DESCRIPTION.
set -euo pipefail

base="${BAND_BASE_URL:-https://app.band.ai}"; base="${base%/}"
name="${BAND_AGENT_NAME:-Band agent}"
desc="${BAND_AGENT_DESCRIPTION:-Agent on Band}"
: "${BAND_USER_API_KEY:?set BAND_USER_API_KEY to a Band user API key with agent-create scope}"

req_body=$(printf '{"agent":{"name":"%s","description":"%s"}}' "$name" "$desc")

# Only the secret X-API-Key header goes through stdin (-K -), never argv.
resp=$(curl -sS -X POST "$base/api/v1/me/agents/register" \
-H "Content-Type: application/json" -d "$req_body" -w $'\n%{http_code}' -K - <<EOF
header = "X-API-Key: $BAND_USER_API_KEY"
EOF
) || true

code=${resp##*$'\n'}
out=${resp%$'\n'*}
case "$code" in
200 | 201) ;;
*) echo "band: registration failed (HTTP ${code:-?}): $(printf '%.300s' "$out")" >&2; exit 1 ;;
esac

# Pull the agent id + key from the response shapes Band may return.
if command -v jq >/dev/null 2>&1; then
id=$(printf '%s' "$out" | jq -r '.data.agent.id // .agent.id // .data.id // .agent_id // .id // empty')
key=$(printf '%s' "$out" | jq -r '.data.credentials.api_key // .credentials.api_key // .data.api_key // .api_key // .key // .token // empty')
elif command -v python3 >/dev/null 2>&1; then
read -r id key < <(printf '%s' "$out" | python3 -c '
import sys, json
d = json.load(sys.stdin)
def g(*path):
cur = d
for k in path:
if not isinstance(cur, dict):
return None
cur = cur.get(k)
return cur
i = g("data","agent","id") or g("agent","id") or g("data","id") or d.get("agent_id") or d.get("id") or ""
k = g("data","credentials","api_key") or g("credentials","api_key") or g("data","api_key") or d.get("api_key") or d.get("key") or d.get("token") or ""
print(str(i).strip(), str(k).strip())
')
else
echo "band: need jq or python3 to parse the registration response" >&2
exit 1
fi

[ -n "${id:-}" ] && [ -n "${key:-}" ] || {
echo "band: registration response missing agent id/key" >&2
exit 1
}

printf 'BAND_AGENT_ID=%s\nBAND_API_KEY=%s\n' "$id" "$key"
103 changes: 103 additions & 0 deletions src/band/cli/register_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""
Band agent registration — bundled bootstrap wrapper.

Thin wrapper around the bundled ``register-agent.sh`` shell script, exposed as
the ``band-register-agent`` console entry point. Any project that installs the
SDK can register a Band external agent from a user API key without vendoring the
script — sharing collapses to installing the SDK and running one command.

The shell script is bundled verbatim and prints eval-able output to stdout::

BAND_AGENT_ID=<uuid>
BAND_API_KEY=<agent-key>

Usage::

# Preferred — key from the environment, kept out of the process list:
export BAND_USER_API_KEY=...
eval "$(band-register-agent)"

# Convenience — pass the user key as an optional positional argument:
eval "$(band-register-agent <user-api-key>)"

When the key is passed as an argument it is exported into the script's
environment as ``BAND_USER_API_KEY``. Note this makes the key visible in the
process list (``ps``); prefer the environment variable on shared machines.

Other knobs are read by the bundled script from the environment: ``BAND_BASE_URL``,
``BAND_AGENT_NAME``, ``BAND_AGENT_DESCRIPTION``.

Exit code mirrors the shell script (0 success, non-zero failure); 127 if ``bash``
is not available.
"""

from __future__ import annotations

import argparse
import logging
import os
import subprocess
import sys
from importlib.resources import as_file, files

logger = logging.getLogger(__name__)

SCRIPT_NAME = "register-agent.sh"


def build_parser() -> argparse.ArgumentParser:
"""Build the CLI argument parser."""
parser = argparse.ArgumentParser(
prog="band-register-agent",
description=(
"Register a Band external agent from a user API key "
"(wraps the bundled register-agent.sh)."
),
epilog=(
"Output is eval-able: BAND_AGENT_ID=... and BAND_API_KEY=... are "
'printed to stdout, e.g. eval "$(band-register-agent)".'
),
)
parser.add_argument(
"api_key",
nargs="?",
help=(
"Band user API key with agent-create scope. If omitted, the script "
"reads BAND_USER_API_KEY from the environment (preferred). Passing "
"the key here exposes it in the process list (ps); use the env var "
"on shared machines."
),
)
return parser


def main() -> None:
"""CLI entry point: run the bundled registration script."""
args = build_parser().parse_args()

env = os.environ.copy()
if args.api_key:
env["BAND_USER_API_KEY"] = args.api_key

script = files("band.cli").joinpath(SCRIPT_NAME)
try:
with as_file(script) as script_path:
logger.debug("Running bundled registration script: %s", script_path)
completed = subprocess.run(
["bash", str(script_path)],
env=env,
check=False,
)
except FileNotFoundError:
# bash is not on PATH.
sys.stderr.write(
"Error: 'bash' is required to run band-register-agent but was not "
"found on PATH.\n"
)
sys.exit(127)

sys.exit(completed.returncode)


if __name__ == "__main__":
main()
92 changes: 92 additions & 0 deletions tests/cli/test_register_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from __future__ import annotations

from importlib.resources import as_file, files
from unittest.mock import MagicMock, patch

import pytest

from band.cli.register_agent import SCRIPT_NAME, build_parser, main


# --- Parser ---


def test_api_key_is_optional() -> None:
"""The positional API key is optional (falls back to the env var)."""
args = build_parser().parse_args([])
assert args.api_key is None

args = build_parser().parse_args(["user-key-123"])
assert args.api_key == "user-key-123"


# --- Bundled script resolution ---


def test_bundled_script_is_packaged() -> None:
"""register-agent.sh ships inside band.cli and is resolvable at runtime."""
with as_file(files("band.cli").joinpath(SCRIPT_NAME)) as script_path:
assert script_path.exists()
assert script_path.name == SCRIPT_NAME
assert script_path.read_text().startswith("#!/usr/bin/env bash")


# --- main() wiring ---


def _run_main(argv: list[str], *, returncode: int = 0) -> MagicMock:
"""Invoke main() with argv, mocking the subprocess call. Returns the mock."""
completed = MagicMock(returncode=returncode)
with (
patch("band.cli.register_agent.subprocess.run", return_value=completed) as run,
patch("sys.argv", ["band-register-agent", *argv]),
pytest.raises(SystemExit) as exc,
):
main()
run.exit_code = exc.value.code # type: ignore[attr-defined]
return run


def test_runs_bash_against_bundled_script() -> None:
"""main() invokes bash on the bundled script and mirrors its exit code."""
run = _run_main([], returncode=0)
assert run.exit_code == 0 # type: ignore[attr-defined]

cmd = run.call_args.args[0]
assert cmd[0] == "bash"
assert cmd[1].endswith(SCRIPT_NAME)


def test_positional_key_populates_env() -> None:
"""A positional API key is exported as BAND_USER_API_KEY for the script."""
run = _run_main(["user-key-xyz"])
env = run.call_args.kwargs["env"]
assert env["BAND_USER_API_KEY"] == "user-key-xyz"


def test_no_key_leaves_env_untouched() -> None:
"""Without an argument, the script reads BAND_USER_API_KEY from the inherited env."""
with patch.dict("os.environ", {"BAND_USER_API_KEY": "from-env"}, clear=False):
run = _run_main([])
env = run.call_args.kwargs["env"]
assert env["BAND_USER_API_KEY"] == "from-env"


def test_nonzero_exit_code_is_propagated() -> None:
"""A failing script surfaces its exit code (so callers/CI see the failure)."""
run = _run_main([], returncode=1)
assert run.exit_code == 1 # type: ignore[attr-defined]


def test_missing_bash_exits_127() -> None:
"""If bash is not on PATH, exit 127 with a clear message."""
with (
patch(
"band.cli.register_agent.subprocess.run",
side_effect=FileNotFoundError,
),
patch("sys.argv", ["band-register-agent"]),
pytest.raises(SystemExit) as exc,
):
main()
assert exc.value.code == 127