Files
hermes-agent-home/hermes-agent-cli.sh
T
carlandCursor 08b25e8f87 移除 OpenClaw/ClawHub 遗留并更新文档
删除 allowall.sh、旧版 CLI/Web 路由,README 改为 Hermes 专用说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 14:54:14 +08:00

487 lines
14 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# NousResearch Hermes Agent 多实例管理(Docker Compose)
# 用法: ./hermes-agent-cli.sh help
#
# 默认拉取官方镜像 nousresearch/hermes-agent:latest,无需本地 build。
# 仅当 HERMES_USE_LOCAL_BUILD=1 时需设置 HERMES_SOURCE 指向源码仓库。
if [ -z "${BASH_VERSION-}" ]; then
exec /usr/bin/env bash "$0" "$@"
fi
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
ENV_FILE="$SCRIPT_DIR/.env"
if [ -f "$ENV_FILE" ]; then
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
fi
if [ -n "${hermes_source:-}" ] && [ -z "${HERMES_SOURCE:-}" ]; then
HERMES_SOURCE="$hermes_source"
export HERMES_SOURCE
fi
# 宿主机 UID/GID,供容器 entrypoint 映射(默认当前用户)
HERMES_CONTAINER_UID="${HERMES_CONTAINER_UID:-$(id -u)}"
HERMES_CONTAINER_GID="${HERMES_CONTAINER_GID:-$(id -g)}"
HERMES_IMAGE="${HERMES_IMAGE:-nousresearch/hermes-agent:latest}"
HERMES_DASHBOARD="${HERMES_DASHBOARD:-1}"
HERMES_USE_LOCAL_BUILD="${HERMES_USE_LOCAL_BUILD:-0}"
HM_DATA_ROOT="${HERMES_HOME_DATA_DIR:-$SCRIPT_DIR/hermes-agent-instances}"
COMPOSE_INSTANCE_FILE="$SCRIPT_DIR/docker-compose.instance.yml"
COMPOSE_BUILD_FILE="$SCRIPT_DIR/docker-compose.instance.build.yml"
COMPOSE_FILES=()
COMPOSE_INIT_DONE=
if [[ "${1:-}" =~ ^[0-9]+$ ]] && [ "${2:-}" = "" ]; then
set -- run "$1"
fi
MARK_BOOTSTRAP_STUB=".hermes-bootstrap-stub"
MARK_SETUP_DONE=".hermes-setup-completed"
MARK_SETUP_DECLINED=".hermes-setup-declined"
usage() {
cat <<'EOF'
用法: hermes-agent-cli.sh <子命令> [参数]
run [--quick|--wizard|--build] <序号> 启动 gateway;--build 缺镜像时先构建再启动(耗时长)
bootstrap <序号> 创建数据目录(首次由 entrypoint 初始化 config)
setup <序号> 交互运行 hermes setup 向导(写入 API 密钥等)
restart <序号> 重启 gateway
stop <序号> 停止 gateway
status <序号> 输出 gateway 的 docker compose ps --format json
check Docker 可用 + compose 配置可解析
compose-build|image-build|pull <序号> 拉取官方镜像(默认);HERMES_USE_LOCAL_BUILD=1 时为本地 build
list-ids|instances 列出实例序号(数据目录 ∪ Docker 项目 hermes-<N>)
destroy|destory <序号> compose down 并删除宿主机该实例 data 目录
exec <序号> 进入运行中 gateway 容器的 shell
log|logs <序号> 跟踪 gateway 日志
cli <序号> [参数...] 在容器内执行 hermes(如 channels、config 子命令)
config <序号> [参数...] 同 cli config …
help, --help, -h 本说明
说明:
- 默认镜像: nousresearch/hermes-agent:latest(docker pull,无需 clone 源码)
- 本地构建: .env 设 HERMES_USE_LOCAL_BUILD=1 且 HERMES_SOURCE 指向源码仓库
- 每实例数据: HERMES_HOME_DATA_DIR/<序号>/data → 容器 /opt/data(config.yaml、.env、sessions/…)
- Compose 项目名: hermes-<序号>;服务名: gateway;容器名如 hermes-<序号>-gateway-1
- 端口: 网关 API 8641+<序号>(实例 1 → 8642);Dashboard 9118+<序号>(实例 1 → 9119)
- 文档: https://hermes-agent.nousresearch.com/docs/user-guide/docker
EOF
}
instance_paths() {
_I_ID="$1"
_I_GATE=$((8641 + _I_ID))
_I_DASH=$((9118 + _I_ID))
_I_DATA="$HM_DATA_ROOT/$_I_ID/data"
mkdir -p "$_I_DATA"
instance_bind_mounts_chown "$_I_DATA"
}
instance_bind_mounts_chown() {
local d="$1"
if [ "${HERMES_SKIP_BIND_CHOWN:-}" = 1 ]; then
return 0
fi
if [ "$(id -u)" -eq 0 ]; then
chown -R "${HERMES_CONTAINER_UID}:${HERMES_CONTAINER_GID}" "$d"
fi
}
instance_has_config() {
local data="$1"
[ -f "$data/config.yaml" ] || [ -f "$data/.env" ]
}
compose_ensure() {
if [ -n "$COMPOSE_INIT_DONE" ]; then
return 0
fi
if [ ! -f "$COMPOSE_INSTANCE_FILE" ]; then
echo "缺少 $COMPOSE_INSTANCE_FILE" >&2
exit 1
fi
COMPOSE_FILES=(-f "$COMPOSE_INSTANCE_FILE")
if [ "$HERMES_USE_LOCAL_BUILD" = 1 ] || [ "$HERMES_USE_LOCAL_BUILD" = true ]; then
local root="${HERMES_SOURCE:-}"
if [ -z "$root" ]; then
echo "HERMES_USE_LOCAL_BUILD=1 时请在 $ENV_FILE 设置 HERMES_SOURCE(源码仓库根目录)。" >&2
exit 1
fi
if [ ! -d "$root" ]; then
echo "HERMES_SOURCE 不是目录: $root" >&2
exit 1
fi
root="$(cd "$root" && pwd)"
if [ ! -f "$root/Dockerfile" ]; then
echo "HERMES_SOURCE 下未找到 Dockerfile。" >&2
exit 1
fi
if [ ! -f "$COMPOSE_BUILD_FILE" ]; then
echo "缺少 $COMPOSE_BUILD_FILE" >&2
exit 1
fi
export HERMES_SOURCE="$root"
export HERMES_BUILD_CONTEXT="$root"
COMPOSE_FILES+=(-f "$COMPOSE_BUILD_FILE")
fi
COMPOSE_INIT_DONE=1
}
require_id() {
local id="${1:-}"
if [ -z "$id" ] || ! [[ "$id" =~ ^[0-9]+$ ]]; then
echo "请提供数字序号,例如:$0 ${2:-run} 1" >&2
exit 1
fi
}
compose_project() {
printf 'hermes-%s' "$1"
}
compose_for_instance() {
local id="$1"
shift
instance_paths "$id"
HERMES_DATA_DIR="$_I_DATA" \
HERMES_GATEWAY_PORT="$_I_GATE" \
HERMES_DASHBOARD_PORT="$_I_DASH" \
HERMES_UID="$HERMES_CONTAINER_UID" \
HERMES_GID="$HERMES_CONTAINER_GID" \
HERMES_IMAGE="$HERMES_IMAGE" \
HERMES_DASHBOARD="$HERMES_DASHBOARD" \
HERMES_BUILD_CONTEXT="${HERMES_BUILD_CONTEXT:-${HERMES_SOURCE:-}}" \
docker compose \
"${COMPOSE_FILES[@]}" \
--project-directory "$SCRIPT_DIR" \
-p "$(compose_project "$id")" \
"$@"
}
compose_service_container_id() {
local id="$1"
local svc="${2:-gateway}"
compose_for_instance "$id" ps -q "$svc" 2>/dev/null | head -n1
}
hermes_image_ready() {
docker image inspect "${HERMES_IMAGE}" >/dev/null 2>&1
}
cmd_bootstrap() {
compose_ensure
local ID="$1"
instance_paths "$ID"
echo "实例 #${ID}: 已创建数据目录 ${_I_DATA}(首次启动时 entrypoint 会写入默认 config.yaml / .env)。"
touch "$_I_DATA/$MARK_BOOTSTRAP_STUB"
rm -f "$_I_DATA/$MARK_SETUP_DONE" "$_I_DATA/$MARK_SETUP_DECLINED"
}
cmd_setup() {
compose_ensure
local ID="$1"
instance_paths "$ID"
if [ "${2:-}" = "--from-run" ]; then
echo "实例 #${ID}: 进入 Hermes 官方 setup 向导…"
else
echo "实例 #${ID}: hermes setup(完成后可 $0 run ${ID})"
fi
local run_opts=(run --rm --no-deps)
if [ -t 1 ]; then
run_opts+=(-it)
else
run_opts+=(-T)
fi
compose_for_instance "$ID" "${run_opts[@]}" gateway setup
touch "$_I_DATA/$MARK_SETUP_DONE"
rm -f "$_I_DATA/$MARK_BOOTSTRAP_STUB" "$_I_DATA/$MARK_SETUP_DECLINED"
}
cmd_run() {
compose_ensure
local ID="$1"
local RUN_MODE="${2:-}"
instance_paths "$ID"
local MY_DATA="$_I_DATA"
run_setup_first() {
cmd_setup "$ID" --from-run || exit $?
}
if [ "$RUN_MODE" = wizard ]; then
if [ ! -t 1 ]; then
echo "实例 #${ID}: --wizard 需要 TTY。" >&2
exit 1
fi
run_setup_first
elif ! instance_has_config "$MY_DATA"; then
if [ "$RUN_MODE" = quick ]; then
echo "实例 #${ID}: 尚无 config,--quick 仅创建目录并启动(entrypoint 会初始化)。" >&2
cmd_bootstrap "$ID" || exit $?
elif [ -t 1 ]; then
run_setup_first
else
echo "实例 #${ID}: 无 TTY,已 bootstrap 后启动;请在终端执行 $0 setup ${ID}。" >&2
cmd_bootstrap "$ID" || exit $?
fi
elif \
[ -f "$MY_DATA/$MARK_BOOTSTRAP_STUB" ] && \
[ ! -f "$MY_DATA/$MARK_SETUP_DONE" ] && \
[ "$RUN_MODE" != quick ] && \
[ -t 1 ]; then
run_setup_first
fi
echo "正在启动实例 #${ID}(Nous Hermes Agent gateway)…"
echo "数据目录: $MY_DATA"
echo "端口: API http://127.0.0.1:$((8641 + ID)):8642 · Dashboard http://127.0.0.1:$((9118 + ID)):9119"
if [ "$RUN_MODE" = build ]; then
if ! hermes_image_ready; then
echo "实例 #${ID}: 镜像 ${HERMES_IMAGE} 不存在,开始构建(可能需 10–30 分钟)…" >&2
cmd_compose_build "$ID"
fi
compose_for_instance "$ID" up -d --no-build gateway
return
fi
if ! hermes_image_ready; then
echo "本地尚无镜像 ${HERMES_IMAGE}。" >&2
echo "请先执行: $0 compose-build ${ID} # 默认从 Docker Hub 拉取官方镜像" >&2
echo "或在 Web 点「拉取镜像」;完成后再点「启动」。" >&2
exit 1
fi
compose_for_instance "$ID" up -d --no-build gateway
}
cmd_restart() {
compose_ensure
local ID="$1"
compose_for_instance "$ID" restart gateway
}
cmd_stop() {
compose_ensure
local ID="$1"
compose_for_instance "$ID" stop gateway
}
cmd_status() {
compose_ensure
local ID="$1"
compose_for_instance "$ID" ps -a --format json gateway
}
cmd_check() {
compose_ensure
if ! docker info >/dev/null 2>&1; then
echo "无法连接 Docker 守护进程(请启动 Docker Desktop、Colima 或 dockerd)。" >&2
exit 1
fi
local _chk_td _chk_pr
_chk_td="$(mktemp -d "${TMPDIR:-/tmp}/hermes-agent-compose-check.XXXXXX")"
mkdir -p "$_chk_td/data"
_chk_pr="hermes-agent-preflight-$$"
if ! \
HERMES_DATA_DIR="$_chk_td/data" \
HERMES_GATEWAY_PORT=8642 \
HERMES_DASHBOARD_PORT=9119 \
HERMES_UID="$HERMES_CONTAINER_UID" \
HERMES_GID="$HERMES_CONTAINER_GID" \
HERMES_IMAGE="$HERMES_IMAGE" \
HERMES_DASHBOARD="$HERMES_DASHBOARD" \
docker compose "${COMPOSE_FILES[@]}" --project-directory "$SCRIPT_DIR" -p "$_chk_pr" config >/dev/null 2>&1
then
echo "docker compose config 解析失败。" >&2
HERMES_DATA_DIR="$_chk_td/data" \
HERMES_GATEWAY_PORT=8642 \
HERMES_DASHBOARD_PORT=9119 \
HERMES_UID="$HERMES_CONTAINER_UID" \
HERMES_GID="$HERMES_CONTAINER_GID" \
HERMES_IMAGE="$HERMES_IMAGE" \
HERMES_DASHBOARD="$HERMES_DASHBOARD" \
docker compose "${COMPOSE_FILES[@]}" --project-directory "$SCRIPT_DIR" -p "$_chk_pr" config || true
rm -rf "$_chk_td"
exit 1
fi
rm -rf "$_chk_td"
}
cmd_list_ids() {
local -a ids=()
local d n line proj
if [ -d "$HM_DATA_ROOT" ]; then
shopt -s nullglob
for d in "$HM_DATA_ROOT"/*; do
[ -d "$d" ] || continue
n="$(basename "$d")"
if [[ "$n" =~ ^[0-9]+$ ]]; then
ids+=("$n")
fi
done
shopt -u nullglob
fi
while IFS= read -r line; do
[ -z "$line" ] && continue
if [[ "$line" =~ ^hermes-([0-9]+)-gateway ]]; then
ids+=("${BASH_REMATCH[1]}")
fi
done < <(docker ps -a --no-trunc --format '{{.Names}}' 2>/dev/null || true)
while IFS= read -r proj; do
[ -z "$proj" ] && continue
if [[ "$proj" =~ ^hermes-([0-9]+)$ ]]; then
ids+=("${BASH_REMATCH[1]}")
fi
done < <(docker compose ls -a --format '{{.Name}}' 2>/dev/null || true)
if [ "${#ids[@]}" -eq 0 ]; then
return 0
fi
printf '%s\n' "${ids[@]}" | sort -nu
}
cmd_destroy() {
compose_ensure
local ID="$1"
local INSTANCE_DIR="$HM_DATA_ROOT/$ID"
echo "实例 #${ID}: docker compose down …" >&2
compose_for_instance "$ID" down --remove-orphans
if [ -d "$INSTANCE_DIR" ]; then
echo "实例 #${ID}: 删除 ${INSTANCE_DIR} …" >&2
rm -rf "$INSTANCE_DIR"
fi
}
cmd_exec() {
compose_ensure
local ID="$1"
local cid
cid="$(compose_service_container_id "$ID" gateway)"
if [ -z "$cid" ]; then
echo "实例 #${ID}: gateway 未运行(先 $0 run ${ID})。" >&2
exit 1
fi
docker exec -it -w /opt/data "$cid" /bin/sh
}
cmd_log() {
compose_ensure
local ID="$1"
compose_for_instance "$ID" logs -f --tail 200 gateway
}
cmd_compose_build() {
compose_ensure
local ID="$1"
require_id "$ID" compose-build
if [ "$HERMES_USE_LOCAL_BUILD" = 1 ] || [ "$HERMES_USE_LOCAL_BUILD" = true ]; then
echo "在 ${HERMES_SOURCE} 本地构建镜像 ${HERMES_IMAGE}(可能需 10–30 分钟)…" >&2
compose_for_instance "$ID" build
else
if hermes_image_ready; then
echo "镜像 ${HERMES_IMAGE} 已存在于本机,跳过拉取(如需更新可: docker pull ${HERMES_IMAGE})。" >&2
return 0
fi
echo "从 Docker Hub 拉取官方镜像 ${HERMES_IMAGE} …" >&2
docker pull "${HERMES_IMAGE}"
fi
}
hermes_in_container() {
local ID="$1"
shift
compose_ensure
instance_paths "$ID"
local cid
cid="$(compose_service_container_id "$ID" gateway)"
if [ -n "$cid" ]; then
if [ -t 1 ]; then
docker exec -it -w /opt/data "$cid" hermes "$@"
else
docker exec -i -w /opt/data "$cid" hermes "$@"
fi
return
fi
local run_opts=(run --rm --no-deps)
if [ -t 1 ]; then
run_opts+=(-it)
else
run_opts+=(-T)
fi
compose_for_instance "$ID" "${run_opts[@]}" gateway "$@"
}
cmd_cli() {
local ID="$1"
shift
if [ $# -eq 0 ]; then
echo "用法: $0 cli <序号> <子命令> [参数…]" >&2
echo "示例: $0 cli 1 config" >&2
echo " $0 setup 1 # 首次配置向导" >&2
exit 1
fi
hermes_in_container "$ID" "$@"
}
cmd_config() {
local ID="$1"
shift
if [ $# -eq 0 ]; then
hermes_in_container "$ID" config
else
hermes_in_container "$ID" config "$@"
fi
}
CMD="${1:-}"
case "$CMD" in
help|--help|-h) usage ;;
"") usage >&2; exit 1 ;;
run)
case "${2:-}" in
--quick) require_id "${3:-}" run; cmd_run "$3" quick ;;
--wizard) require_id "${3:-}" run; cmd_run "$3" wizard ;;
--build) require_id "${3:-}" run; cmd_run "$3" build ;;
*) require_id "${2:-}" run; cmd_run "$2" ;;
esac
;;
bootstrap) require_id "${2:-}" bootstrap; cmd_bootstrap "$2" ;;
setup) require_id "${2:-}" setup; cmd_setup "$2" ;;
restart) require_id "${2:-}" restart; cmd_restart "$2" ;;
stop) require_id "${2:-}" stop; cmd_stop "$2" ;;
status) require_id "${2:-}" status; cmd_status "$2" ;;
check) cmd_check ;;
compose-build|image-build|pull) require_id "${2:-}" compose-build; cmd_compose_build "$2" ;;
list-ids|instances) cmd_list_ids ;;
destroy|destory) require_id "${2:-}" destroy; cmd_destroy "$2" ;;
exec) require_id "${2:-}" exec; cmd_exec "$2" ;;
log|logs) require_id "${2:-}" log; cmd_log "$2" ;;
cli)
require_id "${2:-}" cli
_id="$2"
shift 2
cmd_cli "$_id" "$@"
;;
config)
require_id "${2:-}" config
_id="$2"
shift 2
cmd_config "$_id" "$@"
;;
*)
echo "未知子命令: $CMD" >&2
echo "运行 $0 help 查看说明。" >&2
exit 1
;;
esac