删除 allowall.sh、旧版 CLI/Web 路由,README 改为 Hermes 专用说明。 Co-authored-by: Cursor <cursoragent@cursor.com>
487 lines
14 KiB
Bash
Executable File
487 lines
14 KiB
Bash
Executable File
#!/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
|