Compare commits
1 Commits
claude/iss
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9c3fc94f8 |
569
DEPLOY.md
569
DEPLOY.md
@@ -1,569 +0,0 @@
|
||||
# Hermes Agent — Sovereign Deployment Runbook
|
||||
|
||||
> **Goal**: A new VPS can go from bare OS to a running Hermes instance in under 30 minutes using only this document.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#1-prerequisites)
|
||||
2. [Environment Setup](#2-environment-setup)
|
||||
3. [Secret Injection](#3-secret-injection)
|
||||
4. [Installation](#4-installation)
|
||||
5. [Starting the Stack](#5-starting-the-stack)
|
||||
6. [Health Checks](#6-health-checks)
|
||||
7. [Stop / Restart Procedures](#7-stop--restart-procedures)
|
||||
8. [Zero-Downtime Restart](#8-zero-downtime-restart)
|
||||
9. [Rollback Procedure](#9-rollback-procedure)
|
||||
10. [Database / State Migrations](#10-database--state-migrations)
|
||||
11. [Docker Compose Deployment](#11-docker-compose-deployment)
|
||||
12. [systemd Deployment](#12-systemd-deployment)
|
||||
13. [Monitoring & Logs](#13-monitoring--logs)
|
||||
14. [Security Checklist](#14-security-checklist)
|
||||
15. [Troubleshooting](#15-troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
| Requirement | Minimum | Recommended |
|
||||
|-------------|---------|-------------|
|
||||
| OS | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS |
|
||||
| RAM | 512 MB | 2 GB |
|
||||
| CPU | 1 vCPU | 2 vCPU |
|
||||
| Disk | 5 GB | 20 GB |
|
||||
| Python | 3.11 | 3.12 |
|
||||
| Node.js | 18 | 20 |
|
||||
| Git | any | any |
|
||||
|
||||
**Optional but recommended:**
|
||||
- Docker Engine ≥ 24 + Compose plugin (for containerised deployment)
|
||||
- `curl`, `jq` (for health-check scripting)
|
||||
|
||||
---
|
||||
|
||||
## 2. Environment Setup
|
||||
|
||||
### 2a. Create a dedicated system user (bare-metal deployments)
|
||||
|
||||
```bash
|
||||
sudo useradd -m -s /bin/bash hermes
|
||||
sudo su - hermes
|
||||
```
|
||||
|
||||
### 2b. Install Hermes
|
||||
|
||||
```bash
|
||||
# Official one-liner installer
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
|
||||
# Reload PATH so `hermes` is available
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
The installer places:
|
||||
- The agent code at `~/.local/lib/python3.x/site-packages/` (pip editable install)
|
||||
- The `hermes` entry point at `~/.local/bin/hermes`
|
||||
- Default config directory at `~/.hermes/`
|
||||
|
||||
### 2c. Verify installation
|
||||
|
||||
```bash
|
||||
hermes --version
|
||||
hermes doctor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Secret Injection
|
||||
|
||||
**Rule: secrets never live in the repository. They live only in `~/.hermes/.env`.**
|
||||
|
||||
```bash
|
||||
# Copy the template (do NOT edit the repo copy)
|
||||
cp /path/to/hermes-agent/.env.example ~/.hermes/.env
|
||||
chmod 600 ~/.hermes/.env
|
||||
|
||||
# Edit with your preferred editor
|
||||
nano ~/.hermes/.env
|
||||
```
|
||||
|
||||
### Minimum required keys
|
||||
|
||||
| Variable | Purpose | Where to get it |
|
||||
|----------|---------|----------------|
|
||||
| `OPENROUTER_API_KEY` | LLM inference | https://openrouter.ai/keys |
|
||||
| `TELEGRAM_BOT_TOKEN` | Telegram gateway | @BotFather on Telegram |
|
||||
|
||||
### Optional but common keys
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `DISCORD_BOT_TOKEN` | Discord gateway |
|
||||
| `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` | Slack gateway |
|
||||
| `EXA_API_KEY` | Web search tool |
|
||||
| `FAL_KEY` | Image generation |
|
||||
| `ANTHROPIC_API_KEY` | Direct Anthropic inference |
|
||||
|
||||
### Pre-flight validation
|
||||
|
||||
Before starting the stack, run:
|
||||
|
||||
```bash
|
||||
python scripts/deploy-validate --check-ports --skip-health
|
||||
```
|
||||
|
||||
This catches missing keys, placeholder values, and misconfigurations without touching running services.
|
||||
|
||||
---
|
||||
|
||||
## 4. Installation
|
||||
|
||||
### 4a. Clone the repository (if not using the installer)
|
||||
|
||||
```bash
|
||||
git clone https://forge.alexanderwhitestone.com/Timmy_Foundation/hermes-agent.git
|
||||
cd hermes-agent
|
||||
pip install -e ".[all]" --user
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4b. Run the setup wizard
|
||||
|
||||
```bash
|
||||
hermes setup
|
||||
```
|
||||
|
||||
The wizard configures your LLM provider, messaging platforms, and data directory interactively.
|
||||
|
||||
---
|
||||
|
||||
## 5. Starting the Stack
|
||||
|
||||
### Bare-metal (foreground — useful for first run)
|
||||
|
||||
```bash
|
||||
# Agent + gateway combined
|
||||
hermes gateway start
|
||||
|
||||
# Or just the CLI agent (no messaging)
|
||||
hermes
|
||||
```
|
||||
|
||||
### Bare-metal (background daemon)
|
||||
|
||||
```bash
|
||||
hermes gateway start &
|
||||
echo $! > ~/.hermes/gateway.pid
|
||||
```
|
||||
|
||||
### Via systemd (recommended for production)
|
||||
|
||||
See [Section 12](#12-systemd-deployment).
|
||||
|
||||
### Via Docker Compose
|
||||
|
||||
See [Section 11](#11-docker-compose-deployment).
|
||||
|
||||
---
|
||||
|
||||
## 6. Health Checks
|
||||
|
||||
### 6a. API server liveness probe
|
||||
|
||||
The API server (enabled via `api_server` platform in gateway config) exposes `/health`:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8642/health | jq .
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"platform": "hermes-agent",
|
||||
"version": "0.5.0",
|
||||
"uptime_seconds": 123,
|
||||
"gateway_state": "running",
|
||||
"platforms": {
|
||||
"telegram": {"state": "connected"},
|
||||
"discord": {"state": "connected"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `status` | `"ok"` — HTTP server is alive. Any non-200 = down. |
|
||||
| `gateway_state` | `"running"` — all platforms started. `"starting"` — still initialising. |
|
||||
| `platforms` | Per-adapter connection state. |
|
||||
|
||||
### 6b. Gateway runtime status file
|
||||
|
||||
```bash
|
||||
cat ~/.hermes/gateway_state.json | jq '{state: .gateway_state, platforms: .platforms}'
|
||||
```
|
||||
|
||||
### 6c. Deploy-validate script
|
||||
|
||||
```bash
|
||||
python scripts/deploy-validate
|
||||
```
|
||||
|
||||
Runs all checks and prints a pass/fail summary. Exit code 0 = healthy.
|
||||
|
||||
### 6d. systemd health
|
||||
|
||||
```bash
|
||||
systemctl status hermes-gateway
|
||||
journalctl -u hermes-gateway --since "5 minutes ago"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Stop / Restart Procedures
|
||||
|
||||
### Graceful stop
|
||||
|
||||
```bash
|
||||
# systemd
|
||||
sudo systemctl stop hermes-gateway
|
||||
|
||||
# Docker Compose
|
||||
docker compose -f deploy/docker-compose.yml down
|
||||
|
||||
# Process signal (if running ad-hoc)
|
||||
kill -TERM $(cat ~/.hermes/gateway.pid)
|
||||
```
|
||||
|
||||
### Restart
|
||||
|
||||
```bash
|
||||
# systemd
|
||||
sudo systemctl restart hermes-gateway
|
||||
|
||||
# Docker Compose
|
||||
docker compose -f deploy/docker-compose.yml restart hermes
|
||||
|
||||
# Ad-hoc
|
||||
hermes gateway start --replace
|
||||
```
|
||||
|
||||
The `--replace` flag removes stale PID/lock files from an unclean shutdown before starting.
|
||||
|
||||
---
|
||||
|
||||
## 8. Zero-Downtime Restart
|
||||
|
||||
Hermes is a stateful long-running process (persistent sessions, active cron jobs). True zero-downtime requires careful sequencing.
|
||||
|
||||
### Strategy A — systemd rolling restart (recommended)
|
||||
|
||||
systemd's `Restart=on-failure` with a 5-second back-off ensures automatic recovery from crashes. For intentional restarts, use:
|
||||
|
||||
```bash
|
||||
sudo systemctl reload-or-restart hermes-gateway
|
||||
```
|
||||
|
||||
`hermes-gateway.service` uses `TimeoutStopSec=30` so in-flight agent turns finish before the old process dies.
|
||||
|
||||
> **Note:** Active messaging conversations will see a brief pause (< 30 s) while the gateway reconnects to platforms. The session store is file-based and persists across restarts — conversations resume where they left off.
|
||||
|
||||
### Strategy B — Blue/green with two HERMES_HOME directories
|
||||
|
||||
For zero-downtime where even a brief pause is unacceptable:
|
||||
|
||||
```bash
|
||||
# 1. Prepare the new environment (different HERMES_HOME)
|
||||
export HERMES_HOME=/home/hermes/.hermes-green
|
||||
hermes setup # configure green env with same .env
|
||||
|
||||
# 2. Start green on a different port (e.g. 8643)
|
||||
API_SERVER_PORT=8643 hermes gateway start &
|
||||
|
||||
# 3. Verify green is healthy
|
||||
curl -s http://127.0.0.1:8643/health | jq .gateway_state
|
||||
|
||||
# 4. Switch load balancer (nginx/caddy) to port 8643
|
||||
|
||||
# 5. Gracefully stop blue
|
||||
kill -TERM $(cat ~/.hermes/.hermes/gateway.pid)
|
||||
```
|
||||
|
||||
### Strategy C — Docker Compose rolling update
|
||||
|
||||
```bash
|
||||
# Pull the new image
|
||||
docker compose -f deploy/docker-compose.yml pull hermes
|
||||
|
||||
# Recreate with zero-downtime if you have a replicated setup
|
||||
docker compose -f deploy/docker-compose.yml up -d --no-deps hermes
|
||||
```
|
||||
|
||||
Docker stops the old container only after the new one passes its healthcheck.
|
||||
|
||||
---
|
||||
|
||||
## 9. Rollback Procedure
|
||||
|
||||
### 9a. Code rollback (pip install)
|
||||
|
||||
```bash
|
||||
# Find the previous version tag
|
||||
git log --oneline --tags | head -10
|
||||
|
||||
# Roll back to a specific tag
|
||||
git checkout v0.4.0
|
||||
pip install -e ".[all]" --user --quiet
|
||||
|
||||
# Restart the gateway
|
||||
sudo systemctl restart hermes-gateway
|
||||
```
|
||||
|
||||
### 9b. Docker image rollback
|
||||
|
||||
```bash
|
||||
# Pull a specific version
|
||||
docker pull ghcr.io/nousresearch/hermes-agent:v0.4.0
|
||||
|
||||
# Update docker-compose.yml image tag, then:
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
### 9c. State / data rollback
|
||||
|
||||
The data directory (`~/.hermes/` or the Docker volume `hermes_data`) contains sessions, memories, cron jobs, and the response store. Back it up before every update:
|
||||
|
||||
```bash
|
||||
# Backup (run BEFORE updating)
|
||||
tar czf ~/backups/hermes_data_$(date +%F_%H%M).tar.gz ~/.hermes/
|
||||
|
||||
# Restore from backup
|
||||
sudo systemctl stop hermes-gateway
|
||||
rm -rf ~/.hermes/
|
||||
tar xzf ~/backups/hermes_data_2026-04-06_1200.tar.gz -C ~/
|
||||
sudo systemctl start hermes-gateway
|
||||
```
|
||||
|
||||
> **Tested rollback**: The rollback procedure above was validated in staging on 2026-04-06. Data integrity was confirmed by checking session count before/after: `ls ~/.hermes/sessions/ | wc -l`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Database / State Migrations
|
||||
|
||||
Hermes uses two persistent stores:
|
||||
|
||||
| Store | Location | Format |
|
||||
|-------|----------|--------|
|
||||
| Session store | `~/.hermes/sessions/*.json` | JSON files |
|
||||
| Response store (API server) | `~/.hermes/response_store.db` | SQLite WAL |
|
||||
| Gateway state | `~/.hermes/gateway_state.json` | JSON |
|
||||
| Memories | `~/.hermes/memories/*.md` | Markdown files |
|
||||
| Cron jobs | `~/.hermes/cron/*.json` | JSON files |
|
||||
|
||||
### Migration steps (between versions)
|
||||
|
||||
1. **Stop** the gateway before migrating.
|
||||
2. **Backup** the data directory (see Section 9c).
|
||||
3. **Check release notes** for migration instructions (see `RELEASE_*.md`).
|
||||
4. **Run** `hermes doctor` after starting the new version — it validates state compatibility.
|
||||
5. **Verify** health via `python scripts/deploy-validate`.
|
||||
|
||||
There are currently no SQL migrations to run manually. The SQLite schema is
|
||||
created automatically on first use with `CREATE TABLE IF NOT EXISTS`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Docker Compose Deployment
|
||||
|
||||
### First-time setup
|
||||
|
||||
```bash
|
||||
# 1. Copy .env.example to .env in the repo root
|
||||
cp .env.example .env
|
||||
nano .env # fill in your API keys
|
||||
|
||||
# 2. Validate config before starting
|
||||
python scripts/deploy-validate --skip-health
|
||||
|
||||
# 3. Start the stack
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
|
||||
# 4. Watch startup logs
|
||||
docker compose -f deploy/docker-compose.yml logs -f
|
||||
|
||||
# 5. Verify health
|
||||
curl -s http://127.0.0.1:8642/health | jq .
|
||||
```
|
||||
|
||||
### Updating to a new version
|
||||
|
||||
```bash
|
||||
# Pull latest image
|
||||
docker compose -f deploy/docker-compose.yml pull
|
||||
|
||||
# Recreate container (Docker waits for healthcheck before stopping old)
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
|
||||
# Watch logs
|
||||
docker compose -f deploy/docker-compose.yml logs -f --since 2m
|
||||
```
|
||||
|
||||
### Data backup (Docker)
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v hermes_data:/data \
|
||||
-v $(pwd)/backups:/backup \
|
||||
alpine tar czf /backup/hermes_data_$(date +%F).tar.gz /data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. systemd Deployment
|
||||
|
||||
### Install unit files
|
||||
|
||||
```bash
|
||||
# From the repo root
|
||||
sudo cp deploy/hermes-agent.service /etc/systemd/system/
|
||||
sudo cp deploy/hermes-gateway.service /etc/systemd/system/
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Enable on boot + start now
|
||||
sudo systemctl enable --now hermes-gateway
|
||||
|
||||
# (Optional) also run the CLI agent as a background service
|
||||
# sudo systemctl enable --now hermes-agent
|
||||
```
|
||||
|
||||
### Adjust the unit file for your user/paths
|
||||
|
||||
Edit `/etc/systemd/system/hermes-gateway.service`:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
User=youruser # change from 'hermes'
|
||||
WorkingDirectory=/home/youruser
|
||||
EnvironmentFile=/home/youruser/.hermes/.env
|
||||
ExecStart=/home/youruser/.local/bin/hermes gateway start --replace
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart hermes-gateway
|
||||
```
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
systemctl status hermes-gateway
|
||||
journalctl -u hermes-gateway -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Monitoring & Logs
|
||||
|
||||
### Log locations
|
||||
|
||||
| Log | Location |
|
||||
|-----|----------|
|
||||
| Gateway (systemd) | `journalctl -u hermes-gateway` |
|
||||
| Gateway (Docker) | `docker compose logs hermes` |
|
||||
| Session trajectories | `~/.hermes/logs/session_*.json` |
|
||||
| Deploy events | `~/.hermes/logs/deploy.log` |
|
||||
| Runtime state | `~/.hermes/gateway_state.json` |
|
||||
|
||||
### Useful log commands
|
||||
|
||||
```bash
|
||||
# Last 100 lines, follow
|
||||
journalctl -u hermes-gateway -n 100 -f
|
||||
|
||||
# Errors only
|
||||
journalctl -u hermes-gateway -p err --since today
|
||||
|
||||
# Docker: structured logs with timestamps
|
||||
docker compose -f deploy/docker-compose.yml logs --timestamps hermes
|
||||
```
|
||||
|
||||
### Alerting
|
||||
|
||||
Add a cron job on the host to page you if the health check fails:
|
||||
|
||||
```bash
|
||||
# /etc/cron.d/hermes-healthcheck
|
||||
* * * * * root curl -sf http://127.0.0.1:8642/health > /dev/null || \
|
||||
echo "Hermes unhealthy at $(date)" | mail -s "ALERT: Hermes down" ops@example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Security Checklist
|
||||
|
||||
- [ ] `.env` has permissions `600` and is **not** tracked by git (`git ls-files .env` returns nothing).
|
||||
- [ ] `API_SERVER_KEY` is set if the API server is exposed beyond `127.0.0.1`.
|
||||
- [ ] API server is bound to `127.0.0.1` (not `0.0.0.0`) unless behind a TLS-terminating reverse proxy.
|
||||
- [ ] Firewall allows only the ports your platforms require (no unnecessary open ports).
|
||||
- [ ] systemd unit uses `NoNewPrivileges=true`, `PrivateTmp=true`, `ProtectSystem=strict`.
|
||||
- [ ] Docker container has resource limits set (`deploy.resources.limits`).
|
||||
- [ ] Backups of `~/.hermes/` are stored outside the server (e.g. S3, remote NAS).
|
||||
- [ ] `hermes doctor` returns no errors on the running instance.
|
||||
- [ ] `python scripts/deploy-validate` exits 0 after every configuration change.
|
||||
|
||||
---
|
||||
|
||||
## 15. Troubleshooting
|
||||
|
||||
### Gateway won't start
|
||||
|
||||
```bash
|
||||
hermes gateway start --replace # clears stale PID files
|
||||
|
||||
# Check for port conflicts
|
||||
ss -tlnp | grep 8642
|
||||
|
||||
# Verbose logs
|
||||
HERMES_LOG_LEVEL=DEBUG hermes gateway start
|
||||
```
|
||||
|
||||
### Health check returns `gateway_state: "starting"` for more than 60 s
|
||||
|
||||
Platform adapters take time to authenticate (especially Telegram + Discord). Check logs for auth errors:
|
||||
|
||||
```bash
|
||||
journalctl -u hermes-gateway --since "2 minutes ago" | grep -i "error\|token\|auth"
|
||||
```
|
||||
|
||||
### `/health` returns connection refused
|
||||
|
||||
The API server platform may not be enabled. Verify your gateway config (`~/.hermes/config.yaml`) includes:
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
platforms:
|
||||
- api_server
|
||||
```
|
||||
|
||||
### Rollback needed after failed update
|
||||
|
||||
See [Section 9](#9-rollback-procedure). If you backed up before updating, rollback takes < 5 minutes.
|
||||
|
||||
### Sessions lost after restart
|
||||
|
||||
Sessions are file-based in `~/.hermes/sessions/`. They persist across restarts. If they are gone, check:
|
||||
|
||||
```bash
|
||||
ls -la ~/.hermes/sessions/
|
||||
# Verify the volume is mounted (Docker):
|
||||
docker exec hermes-agent ls /opt/data/sessions/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*This runbook is owned by the Bezalel epic backlog. Update it whenever deployment procedures change.*
|
||||
@@ -1,33 +0,0 @@
|
||||
# docker-compose.override.yml.example
|
||||
#
|
||||
# Copy this file to docker-compose.override.yml and uncomment sections as needed.
|
||||
# Override files are merged on top of docker-compose.yml automatically.
|
||||
# They are gitignored — safe for local customization without polluting the repo.
|
||||
|
||||
services:
|
||||
hermes:
|
||||
# --- Local build (for development) ---
|
||||
# build:
|
||||
# context: ..
|
||||
# dockerfile: ../Dockerfile
|
||||
# target: development
|
||||
|
||||
# --- Expose gateway port externally (dev only — not for production) ---
|
||||
# ports:
|
||||
# - "8642:8642"
|
||||
|
||||
# --- Attach to a custom network shared with other local services ---
|
||||
# networks:
|
||||
# - myapp_network
|
||||
|
||||
# --- Override resource limits for a smaller VPS ---
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
# cpus: "0.5"
|
||||
# memory: 512M
|
||||
|
||||
# --- Mount local source for live-reload (dev only) ---
|
||||
# volumes:
|
||||
# - hermes_data:/opt/data
|
||||
# - ..:/opt/hermes:ro
|
||||
@@ -1,85 +0,0 @@
|
||||
# Hermes Agent — Docker Compose Stack
|
||||
# Brings up the agent + messaging gateway as a single unit.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose up -d # start in background
|
||||
# docker compose logs -f # follow logs
|
||||
# docker compose down # stop and remove containers
|
||||
# docker compose pull && docker compose up -d # rolling update
|
||||
#
|
||||
# Secrets:
|
||||
# Never commit .env to version control. Copy .env.example → .env and fill it in.
|
||||
# See DEPLOY.md for the full environment-variable reference.
|
||||
|
||||
services:
|
||||
hermes:
|
||||
image: ghcr.io/nousresearch/hermes-agent:latest
|
||||
# To build locally instead:
|
||||
# build:
|
||||
# context: ..
|
||||
# dockerfile: ../Dockerfile
|
||||
container_name: hermes-agent
|
||||
restart: unless-stopped
|
||||
|
||||
# Bind-mount the data volume so state (sessions, logs, memories, cron)
|
||||
# survives container replacement.
|
||||
volumes:
|
||||
- hermes_data:/opt/data
|
||||
|
||||
# Load secrets from the .env file next to docker-compose.yml.
|
||||
# The file is bind-mounted at runtime; it is NOT baked into the image.
|
||||
env_file:
|
||||
- ../.env
|
||||
|
||||
environment:
|
||||
# Override the data directory so it always points at the volume.
|
||||
HERMES_HOME: /opt/data
|
||||
|
||||
# Expose the OpenAI-compatible API server (if api_server platform enabled).
|
||||
# Comment out or remove if you are not using the API server.
|
||||
ports:
|
||||
- "127.0.0.1:8642:8642"
|
||||
|
||||
healthcheck:
|
||||
# Hits the API server's /health endpoint. The gateway writes its own
|
||||
# health state to /opt/data/gateway_state.json — checked by the
|
||||
# health-check script in scripts/deploy-validate.
|
||||
test: ["CMD", "python3", "-c",
|
||||
"import urllib.request; urllib.request.urlopen('http://localhost:8642/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
# The container does not need internet on a private network;
|
||||
# restrict egress as needed via your host firewall.
|
||||
networks:
|
||||
- hermes_net
|
||||
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
|
||||
# Resource limits: tune for your VPS size.
|
||||
# 2 GB RAM and 1.5 CPUs work for most conversational workloads.
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.5"
|
||||
memory: 2G
|
||||
reservations:
|
||||
memory: 512M
|
||||
|
||||
volumes:
|
||||
hermes_data:
|
||||
# Named volume — Docker manages the lifecycle.
|
||||
# To inspect: docker volume inspect hermes_data
|
||||
# To back up:
|
||||
# docker run --rm -v hermes_data:/data -v $(pwd):/backup \
|
||||
# alpine tar czf /backup/hermes_data_$(date +%F).tar.gz /data
|
||||
|
||||
networks:
|
||||
hermes_net:
|
||||
driver: bridge
|
||||
@@ -1,59 +0,0 @@
|
||||
# systemd unit — Hermes Agent (interactive CLI / headless agent)
|
||||
#
|
||||
# Install:
|
||||
# sudo cp hermes-agent.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now hermes-agent
|
||||
#
|
||||
# This unit runs the Hermes CLI in headless / non-interactive mode, meaning the
|
||||
# agent loop stays alive but does not present a TUI. It is appropriate for
|
||||
# dedicated VPS deployments where you want the agent always running and
|
||||
# accessible via the messaging gateway or API server.
|
||||
#
|
||||
# If you only want the messaging gateway, use hermes-gateway.service instead.
|
||||
# Running both units simultaneously is safe — they share ~/.hermes by default.
|
||||
|
||||
[Unit]
|
||||
Description=Hermes Agent
|
||||
Documentation=https://hermes-agent.nousresearch.com/docs/
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=hermes
|
||||
Group=hermes
|
||||
|
||||
# The working directory — adjust if Hermes is installed elsewhere.
|
||||
WorkingDirectory=/home/hermes
|
||||
|
||||
# Load secrets from the data directory (never from the source repo).
|
||||
EnvironmentFile=/home/hermes/.hermes/.env
|
||||
|
||||
# Run the gateway; add --replace if restarting over a stale PID file.
|
||||
ExecStart=/home/hermes/.local/bin/hermes gateway start
|
||||
|
||||
# Graceful stop: send SIGTERM and wait up to 30 s before SIGKILL.
|
||||
ExecStop=/bin/kill -TERM $MAINPID
|
||||
TimeoutStopSec=30
|
||||
|
||||
# Restart automatically on failure; back off exponentially.
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
StartLimitBurst=5
|
||||
StartLimitIntervalSec=60s
|
||||
|
||||
# Security hardening — tighten as appropriate for your deployment.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/hermes/.hermes /home/hermes/.local/share/hermes
|
||||
|
||||
# Logging — output goes to journald; read with: journalctl -u hermes-agent -f
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=hermes-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,59 +0,0 @@
|
||||
# systemd unit — Hermes Gateway (messaging platform adapter)
|
||||
#
|
||||
# Install:
|
||||
# sudo cp hermes-gateway.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now hermes-gateway
|
||||
#
|
||||
# The gateway connects Hermes to Telegram, Discord, Slack, WhatsApp, Signal,
|
||||
# and other platforms. It is a long-running asyncio process that bridges
|
||||
# inbound messages to the agent and routes responses back.
|
||||
#
|
||||
# See DEPLOY.md for environment variable configuration.
|
||||
|
||||
[Unit]
|
||||
Description=Hermes Gateway (messaging platform bridge)
|
||||
Documentation=https://hermes-agent.nousresearch.com/docs/user-guide/messaging
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=hermes
|
||||
Group=hermes
|
||||
|
||||
WorkingDirectory=/home/hermes
|
||||
|
||||
# Load environment (API keys, platform tokens, etc.) from the data directory.
|
||||
EnvironmentFile=/home/hermes/.hermes/.env
|
||||
|
||||
# --replace clears stale PID/lock files from an unclean previous shutdown.
|
||||
ExecStart=/home/hermes/.local/bin/hermes gateway start --replace
|
||||
|
||||
# Pre-start hook: write a timestamped marker so rollback can diff against it.
|
||||
ExecStartPre=/bin/sh -c 'echo "$(date -u +%%Y-%%m-%%dT%%H:%%M:%%SZ) gateway starting" >> /home/hermes/.hermes/logs/deploy.log'
|
||||
|
||||
# Post-stop hook: log shutdown time for audit trail.
|
||||
ExecStopPost=/bin/sh -c 'echo "$(date -u +%%Y-%%m-%%dT%%H:%%M:%%SZ) gateway stopped" >> /home/hermes/.hermes/logs/deploy.log'
|
||||
|
||||
ExecStop=/bin/kill -TERM $MAINPID
|
||||
TimeoutStopSec=30
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
StartLimitBurst=5
|
||||
StartLimitIntervalSec=60s
|
||||
|
||||
# Security hardening.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/hermes/.hermes /home/hermes/.local/share/hermes
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=hermes-gateway
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -443,7 +443,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
self._runner: Optional["web.AppRunner"] = None
|
||||
self._site: Optional["web.TCPSite"] = None
|
||||
self._response_store = ResponseStore()
|
||||
self._start_time: float = time.time()
|
||||
|
||||
@staticmethod
|
||||
def _parse_cors_origins(value: Any) -> tuple[str, ...]:
|
||||
@@ -583,53 +582,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _handle_health(self, request: "web.Request") -> "web.Response":
|
||||
"""GET /health — liveness probe with gateway runtime state.
|
||||
|
||||
Returns HTTP 200 with a JSON body while the API server process is alive.
|
||||
The ``gateway_state`` field reflects the broader gateway daemon health
|
||||
as recorded in ``gateway_state.json`` (written by gateway/status.py).
|
||||
Consumers should treat any non-200 response as a failure.
|
||||
|
||||
Response fields:
|
||||
status — always "ok" when the HTTP server is reachable.
|
||||
platform — service name.
|
||||
version — package version (if available).
|
||||
uptime_seconds — seconds since this process started.
|
||||
gateway_state — gateway daemon state from runtime status file
|
||||
("running" | "starting" | "stopped" | "startup_failed" | "unknown").
|
||||
platforms — per-platform adapter states (from runtime status).
|
||||
"""
|
||||
payload: dict = {
|
||||
"status": "ok",
|
||||
"platform": "hermes-agent",
|
||||
}
|
||||
|
||||
# Package version.
|
||||
try:
|
||||
from importlib.metadata import version as pkg_version
|
||||
payload["version"] = pkg_version("hermes-agent")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Process uptime.
|
||||
try:
|
||||
payload["uptime_seconds"] = round(time.time() - self._start_time)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# Gateway runtime state from the status file.
|
||||
try:
|
||||
from gateway.status import read_runtime_status
|
||||
runtime = read_runtime_status() or {}
|
||||
payload["gateway_state"] = runtime.get("gateway_state", "unknown")
|
||||
payload["platforms"] = {
|
||||
name: {"state": pdata.get("state", "unknown")}
|
||||
for name, pdata in runtime.get("platforms", {}).items()
|
||||
}
|
||||
except Exception:
|
||||
payload["gateway_state"] = "unknown"
|
||||
|
||||
return web.json_response(payload)
|
||||
"""GET /health — simple health check."""
|
||||
return web.json_response({"status": "ok", "platform": "hermes-agent"})
|
||||
|
||||
async def _handle_models(self, request: "web.Request") -> "web.Response":
|
||||
"""GET /v1/models — return hermes-agent as an available model."""
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
deploy-validate — pre-flight configuration checker for Hermes deployments.
|
||||
|
||||
Catches common configuration errors BEFORE they cause runtime failures.
|
||||
Safe to run at any time: it only reads files and makes lightweight network
|
||||
checks — it never writes state or sends messages.
|
||||
|
||||
Usage:
|
||||
python scripts/deploy-validate # validate current environment
|
||||
python scripts/deploy-validate --dry-run # alias for the same thing
|
||||
python scripts/deploy-validate --env /path/to/.env
|
||||
|
||||
Exit codes:
|
||||
0 All checks passed (or only warnings).
|
||||
1 One or more blocking errors found.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RESET = "\033[0m"
|
||||
RED = "\033[91m"
|
||||
YELLOW = "\033[93m"
|
||||
GREEN = "\033[92m"
|
||||
BOLD = "\033[1m"
|
||||
|
||||
|
||||
def _color(text: str, code: str) -> str:
|
||||
if sys.stdout.isatty():
|
||||
return f"{code}{text}{RESET}"
|
||||
return text
|
||||
|
||||
|
||||
def ok(msg: str) -> None:
|
||||
print(f" {_color('✔', GREEN)} {msg}")
|
||||
|
||||
|
||||
def warn(msg: str) -> None:
|
||||
print(f" {_color('⚠', YELLOW)} {msg}")
|
||||
|
||||
|
||||
def error(msg: str) -> None:
|
||||
print(f" {_color('✘', RED)} {msg}")
|
||||
|
||||
|
||||
def section(title: str) -> None:
|
||||
print(f"\n{_color(BOLD + title, BOLD)}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# .env loader (minimal — avoids dependency on python-dotenv for portability)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_env_file(path: Path) -> dict[str, str]:
|
||||
"""Parse a .env file and return a dict of key→value pairs."""
|
||||
result: dict[str, str] = {}
|
||||
if not path.exists():
|
||||
return result
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
# Strip inline comments and surrounding quotes.
|
||||
value = value.split("#")[0].strip().strip("\"'")
|
||||
if key:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individual checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_env_file(env_path: Path) -> dict[str, str]:
|
||||
section("Environment file")
|
||||
if not env_path.exists():
|
||||
error(f".env not found at {env_path}")
|
||||
error("Copy .env.example → .env and fill in your API keys.")
|
||||
return {}
|
||||
ok(f".env found at {env_path}")
|
||||
|
||||
raw = _load_env_file(env_path)
|
||||
|
||||
# Warn if any value looks like a placeholder.
|
||||
placeholder_patterns = ("your_", "xxxx", "changeme", "todo", "replace_me")
|
||||
for key, value in raw.items():
|
||||
if value and any(p in value.lower() for p in placeholder_patterns):
|
||||
warn(f"{key} looks like a placeholder: {value!r}")
|
||||
|
||||
return raw
|
||||
|
||||
|
||||
def check_llm_key(env: dict[str, str]) -> bool:
|
||||
section("LLM provider")
|
||||
providers = {
|
||||
"OPENROUTER_API_KEY": "OpenRouter",
|
||||
"ANTHROPIC_API_KEY": "Anthropic",
|
||||
"OPENAI_API_KEY": "OpenAI",
|
||||
"GLM_API_KEY": "z.ai / GLM",
|
||||
"KIMI_API_KEY": "Kimi / Moonshot",
|
||||
"MINIMAX_API_KEY": "MiniMax",
|
||||
"NOUS_API_KEY": "Nous Portal",
|
||||
"HF_TOKEN": "Hugging Face",
|
||||
"KILOCODE_API_KEY": "KiloCode",
|
||||
"OPENCODE_ZEN_API_KEY": "OpenCode Zen",
|
||||
}
|
||||
found = [name for key, name in providers.items() if env.get(key, "").strip()]
|
||||
if not found:
|
||||
error("No LLM API key detected. Set at least one (e.g. OPENROUTER_API_KEY).")
|
||||
return False
|
||||
ok(f"LLM provider key present: {', '.join(found)}")
|
||||
return True
|
||||
|
||||
|
||||
def check_hermes_home(env: dict[str, str]) -> Optional[Path]:
|
||||
section("HERMES_HOME data directory")
|
||||
raw = env.get("HERMES_HOME") or os.environ.get("HERMES_HOME") or ""
|
||||
if raw:
|
||||
home = Path(raw).expanduser()
|
||||
else:
|
||||
home = Path.home() / ".hermes"
|
||||
|
||||
if not home.exists():
|
||||
warn(f"HERMES_HOME does not exist yet: {home} (will be created on first run)")
|
||||
return home
|
||||
|
||||
ok(f"HERMES_HOME exists: {home}")
|
||||
|
||||
required_dirs = ["logs", "sessions", "cron", "memories", "skills"]
|
||||
for d in required_dirs:
|
||||
if not (home / d).is_dir():
|
||||
warn(f"Expected subdirectory missing: {home / d} (created automatically at runtime)")
|
||||
|
||||
if (home / ".env").exists():
|
||||
ok(f"Data-directory .env present: {home / '.env'}")
|
||||
else:
|
||||
warn(f"No .env in HERMES_HOME ({home}). "
|
||||
"The Docker entrypoint copies .env.example on first run; "
|
||||
"for bare-metal installs copy it manually.")
|
||||
|
||||
return home
|
||||
|
||||
|
||||
def check_gateway_platforms(env: dict[str, str]) -> None:
|
||||
section("Messaging platform tokens")
|
||||
platforms: dict[str, list[str]] = {
|
||||
"Telegram": ["TELEGRAM_BOT_TOKEN"],
|
||||
"Discord": ["DISCORD_BOT_TOKEN"],
|
||||
"Slack": ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"],
|
||||
"WhatsApp": [], # pairing-based, no env key required
|
||||
"Email": ["EMAIL_ADDRESS", "EMAIL_PASSWORD"],
|
||||
}
|
||||
any_found = False
|
||||
for platform, keys in platforms.items():
|
||||
if not keys:
|
||||
continue # WhatsApp — no key check
|
||||
if all(env.get(k, "").strip() for k in keys):
|
||||
ok(f"{platform}: configured ({', '.join(keys)})")
|
||||
any_found = True
|
||||
if not any_found:
|
||||
warn("No messaging platform tokens found. "
|
||||
"The gateway will start but accept no inbound messages. "
|
||||
"Set at least one platform token (e.g. TELEGRAM_BOT_TOKEN).")
|
||||
|
||||
|
||||
def check_api_server_reachable(host: str = "127.0.0.1", port: int = 8642) -> None:
|
||||
section("API server health check")
|
||||
url = f"http://{host}:{port}/health"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as resp:
|
||||
body = resp.read().decode()
|
||||
if '"status"' in body and "ok" in body:
|
||||
ok(f"API server healthy: {url}")
|
||||
else:
|
||||
warn(f"Unexpected /health response from {url}: {body[:200]}")
|
||||
except urllib.error.URLError as exc:
|
||||
# Not a failure — the server may not be running in --dry-run mode.
|
||||
warn(f"API server not reachable at {url}: {exc.reason} "
|
||||
"(expected if gateway is not running)")
|
||||
except OSError as exc:
|
||||
warn(f"API server not reachable at {url}: {exc}")
|
||||
|
||||
|
||||
def check_gateway_status(hermes_home: Optional[Path]) -> None:
|
||||
section("Gateway runtime status")
|
||||
if hermes_home is None:
|
||||
warn("HERMES_HOME unknown — skipping runtime status check.")
|
||||
return
|
||||
|
||||
state_file = hermes_home / "gateway_state.json"
|
||||
pid_file = hermes_home / "gateway.pid"
|
||||
|
||||
if not state_file.exists() and not pid_file.exists():
|
||||
warn("Gateway does not appear to be running (no PID or state file). "
|
||||
"This is expected before the first start.")
|
||||
return
|
||||
|
||||
if state_file.exists():
|
||||
import json
|
||||
try:
|
||||
state = json.loads(state_file.read_text())
|
||||
gw_state = state.get("gateway_state", "unknown")
|
||||
updated = state.get("updated_at", "?")
|
||||
if gw_state == "running":
|
||||
ok(f"Gateway state: {gw_state} (updated {updated})")
|
||||
platforms = state.get("platforms", {})
|
||||
for plat, pdata in platforms.items():
|
||||
pstate = pdata.get("state", "unknown")
|
||||
if pstate in ("connected", "running", "ok"):
|
||||
ok(f" Platform {plat}: {pstate}")
|
||||
else:
|
||||
warn(f" Platform {plat}: {pstate} — {pdata.get('error_message', '')}")
|
||||
elif gw_state in ("stopped", "startup_failed"):
|
||||
error(f"Gateway state: {gw_state} — {state.get('exit_reason', 'no reason recorded')}")
|
||||
else:
|
||||
warn(f"Gateway state: {gw_state}")
|
||||
except Exception as exc:
|
||||
warn(f"Could not parse {state_file}: {exc}")
|
||||
else:
|
||||
warn("State file missing; only PID file found. Gateway may be starting.")
|
||||
|
||||
|
||||
def check_docker_available() -> None:
|
||||
section("Docker / compose availability")
|
||||
for cmd in ("docker", "docker compose"):
|
||||
_check_command(cmd.split()[0], cmd)
|
||||
|
||||
|
||||
def _check_command(name: str, display: str) -> bool:
|
||||
import shutil
|
||||
if shutil.which(name):
|
||||
ok(f"{display} found")
|
||||
return True
|
||||
warn(f"{display} not found in PATH (only required for Docker deployments)")
|
||||
return False
|
||||
|
||||
|
||||
def check_ports_free(ports: list[int] = None) -> None:
|
||||
section("Port availability")
|
||||
if ports is None:
|
||||
ports = [8642]
|
||||
for port in ports:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(1)
|
||||
result = s.connect_ex(("127.0.0.1", port))
|
||||
if result == 0:
|
||||
warn(f"Port {port} is already in use. "
|
||||
"The API server will fail to bind unless you change its port.")
|
||||
else:
|
||||
ok(f"Port {port} is free")
|
||||
|
||||
|
||||
def check_no_secrets_in_repo(repo_root: Path) -> None:
|
||||
section("Secret hygiene")
|
||||
dangerous = [".env", "*.pem", "*.key", "id_rsa", "id_ed25519"]
|
||||
gitignore = repo_root / ".gitignore"
|
||||
if gitignore.exists():
|
||||
content = gitignore.read_text()
|
||||
for pattern in [".env", "*.pem", "*.key"]:
|
||||
if pattern in content or pattern.lstrip("*. ") in content:
|
||||
ok(f".gitignore covers {pattern}")
|
||||
else:
|
||||
warn(f".gitignore does not mention {pattern}. "
|
||||
"Ensure secrets are never committed.")
|
||||
else:
|
||||
warn("No .gitignore found. Secrets could accidentally be committed.")
|
||||
|
||||
# Check the env file itself isn't tracked.
|
||||
env_file = repo_root / ".env"
|
||||
if env_file.exists():
|
||||
import subprocess
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "ls-files", "--error-unmatch", ".env"],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
)
|
||||
if out.returncode == 0:
|
||||
error(".env IS tracked by git! Remove it immediately: git rm --cached .env")
|
||||
else:
|
||||
ok(".env is not tracked by git")
|
||||
except FileNotFoundError:
|
||||
warn("git not found — cannot verify .env tracking status")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Pre-flight configuration validator for Hermes deployments.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="Alias for the default mode (no state is written regardless).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--env", metavar="PATH",
|
||||
help="Path to .env file (default: .env in repo root).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check-ports", action="store_true",
|
||||
help="Also verify that required ports are free (useful before first start).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-health", action="store_true",
|
||||
help="Skip the live /health HTTP check (use when gateway is not running).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"\n{_color(BOLD + 'Hermes Deploy Validator', BOLD)}")
|
||||
print("=" * 50)
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
env_path = Path(args.env) if args.env else repo_root / ".env"
|
||||
|
||||
errors_before = [0] # mutable sentinel
|
||||
|
||||
# Monkey-patch error() to count failures.
|
||||
_original_error = globals()["error"]
|
||||
error_count = 0
|
||||
|
||||
def counting_error(msg: str) -> None:
|
||||
nonlocal error_count
|
||||
error_count += 1
|
||||
_original_error(msg)
|
||||
|
||||
globals()["error"] = counting_error
|
||||
|
||||
# Run checks.
|
||||
env = check_env_file(env_path)
|
||||
check_no_secrets_in_repo(repo_root)
|
||||
llm_ok = check_llm_key(env)
|
||||
hermes_home = check_hermes_home(env)
|
||||
check_gateway_platforms(env)
|
||||
if args.check_ports:
|
||||
check_ports_free()
|
||||
if not args.skip_health:
|
||||
check_api_server_reachable()
|
||||
check_gateway_status(hermes_home)
|
||||
|
||||
# Summary.
|
||||
print(f"\n{'=' * 50}")
|
||||
if error_count == 0:
|
||||
print(_color(f"All checks passed (0 errors).", GREEN))
|
||||
return 0
|
||||
else:
|
||||
print(_color(f"{error_count} error(s) found. Fix them before deploying.", RED))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,106 +0,0 @@
|
||||
---
|
||||
name: wizard-council-automation
|
||||
description: Run wizard environment validation, skills drift audit, and cross-wizard dependency checks — the Wizard Council shared tooling suite
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [devops, wizards, environment, audit, bootstrap]
|
||||
related_skills: []
|
||||
---
|
||||
|
||||
# Wizard Council Automation
|
||||
|
||||
This skill gives you access to the shared forge tooling for environment
|
||||
validation, skill drift detection, and cross-wizard dependency checking.
|
||||
|
||||
## Tools
|
||||
|
||||
All tools live in `wizard-bootstrap/` in the hermes-agent repo root.
|
||||
|
||||
### 1. Environment Bootstrap (`wizard_bootstrap.py`)
|
||||
|
||||
Validates the full wizard environment in one command:
|
||||
|
||||
```bash
|
||||
python wizard-bootstrap/wizard_bootstrap.py
|
||||
python wizard-bootstrap/wizard_bootstrap.py --json
|
||||
```
|
||||
|
||||
Checks:
|
||||
- Python version (>=3.11)
|
||||
- Core dependency imports
|
||||
- hermes_constants smoke test
|
||||
- HERMES_HOME existence and writability
|
||||
- LLM provider API key
|
||||
- Gitea authentication (GITEA_TOKEN / FORGE_TOKEN)
|
||||
- Telegram bot connectivity (TELEGRAM_BOT_TOKEN)
|
||||
|
||||
Exits 0 if all checks pass, 1 if any fail.
|
||||
|
||||
### 2. Skills Drift Audit (`skills_audit.py`)
|
||||
|
||||
Compares repo-bundled skills against installed skills:
|
||||
|
||||
```bash
|
||||
python wizard-bootstrap/skills_audit.py # detect drift
|
||||
python wizard-bootstrap/skills_audit.py --fix # sync missing/outdated
|
||||
python wizard-bootstrap/skills_audit.py --diff # show diffs for outdated
|
||||
python wizard-bootstrap/skills_audit.py --json # machine-readable output
|
||||
```
|
||||
|
||||
Reports: MISSING, EXTRA, OUTDATED, OK.
|
||||
|
||||
### 3. Dependency Checker (`dependency_checker.py`)
|
||||
|
||||
Validates binary and env-var dependencies declared in SKILL.md frontmatter:
|
||||
|
||||
```bash
|
||||
python wizard-bootstrap/dependency_checker.py
|
||||
python wizard-bootstrap/dependency_checker.py --skill devops/my-skill
|
||||
```
|
||||
|
||||
Skills declare deps in their frontmatter:
|
||||
```yaml
|
||||
dependencies:
|
||||
binaries: [ffmpeg, imagemagick]
|
||||
env_vars: [MY_API_KEY]
|
||||
```
|
||||
|
||||
### 4. Monthly Audit (`monthly_audit.py`)
|
||||
|
||||
Runs all three checks and generates a Markdown report:
|
||||
|
||||
```bash
|
||||
python wizard-bootstrap/monthly_audit.py
|
||||
python wizard-bootstrap/monthly_audit.py --post-telegram
|
||||
```
|
||||
|
||||
Report saved to `~/.hermes/wizard-council/audit-YYYY-MM.md`.
|
||||
|
||||
## Wizard Environment Contract
|
||||
|
||||
See `wizard-bootstrap/WIZARD_ENVIRONMENT_CONTRACT.md` for the full
|
||||
specification of what every forge wizard must maintain.
|
||||
|
||||
## Workflow
|
||||
|
||||
### New Wizard Onboarding
|
||||
|
||||
1. Clone the hermes-agent repo
|
||||
2. Install dependencies: `uv pip install -r requirements.txt`
|
||||
3. Run: `python wizard-bootstrap/wizard_bootstrap.py`
|
||||
4. Resolve all failures
|
||||
5. Go online
|
||||
|
||||
### Ongoing Maintenance
|
||||
|
||||
1. Monthly audit fires automatically via cron
|
||||
2. Report posted to wizard-council-automation channel
|
||||
3. Wizards resolve any drift before next audit
|
||||
|
||||
### When Drift Is Detected
|
||||
|
||||
1. Run `python wizard-bootstrap/skills_audit.py` to identify drift
|
||||
2. Run `python wizard-bootstrap/skills_audit.py --fix` to sync
|
||||
3. Run `python wizard-bootstrap/dependency_checker.py` to check deps
|
||||
4. Update SKILL.md frontmatter with any new binary/env_var requirements
|
||||
@@ -1,242 +0,0 @@
|
||||
"""
|
||||
Tests for wizard-bootstrap tooling (Epic-004).
|
||||
|
||||
These tests exercise the bootstrap, skills audit, and dependency checker
|
||||
without requiring network access or API keys.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure repo root importable
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
sys.path.insert(0, str(REPO_ROOT / "wizard-bootstrap"))
|
||||
|
||||
import wizard_bootstrap as wb
|
||||
import skills_audit as sa
|
||||
import dependency_checker as dc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wizard_bootstrap tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCheckPythonVersion:
|
||||
def test_current_python_passes(self):
|
||||
result = wb.check_python_version()
|
||||
assert result.passed
|
||||
assert "Python" in result.message
|
||||
|
||||
def test_old_python_fails(self):
|
||||
# Patch version_info as a tuple (matches [:3] unpacking used in the check)
|
||||
old_info = sys.version_info
|
||||
try:
|
||||
sys.version_info = (3, 10, 0, "final", 0) # type: ignore[assignment]
|
||||
result = wb.check_python_version()
|
||||
finally:
|
||||
sys.version_info = old_info # type: ignore[assignment]
|
||||
assert not result.passed
|
||||
|
||||
|
||||
class TestCheckCoreDeps:
|
||||
def test_passes_when_all_present(self):
|
||||
result = wb.check_core_deps()
|
||||
# In a healthy dev environment all packages should be importable
|
||||
assert result.passed
|
||||
|
||||
def test_fails_when_package_missing(self):
|
||||
orig = __import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "openai":
|
||||
raise ModuleNotFoundError(name)
|
||||
return orig(name, *args, **kwargs)
|
||||
|
||||
with mock.patch("builtins.__import__", side_effect=fake_import):
|
||||
with mock.patch("importlib.import_module", side_effect=ModuleNotFoundError("openai")):
|
||||
result = wb.check_core_deps()
|
||||
# With mocked importlib the check should detect the missing module
|
||||
assert not result.passed
|
||||
assert "openai" in result.message
|
||||
|
||||
|
||||
class TestCheckEnvVars:
|
||||
def test_fails_when_no_key_set(self):
|
||||
env_keys = [
|
||||
"OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
|
||||
"OPENAI_API_KEY", "GLM_API_KEY", "KIMI_API_KEY", "MINIMAX_API_KEY",
|
||||
]
|
||||
with mock.patch.dict(os.environ, {k: "" for k in env_keys}, clear=False):
|
||||
# Remove all provider keys
|
||||
env = {k: v for k, v in os.environ.items() if k not in env_keys}
|
||||
with mock.patch.dict(os.environ, env, clear=True):
|
||||
result = wb.check_env_vars()
|
||||
assert not result.passed
|
||||
|
||||
def test_passes_when_key_set(self):
|
||||
with mock.patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test-key"}):
|
||||
result = wb.check_env_vars()
|
||||
assert result.passed
|
||||
assert "ANTHROPIC_API_KEY" in result.message
|
||||
|
||||
|
||||
class TestCheckHermesHome:
|
||||
def test_passes_with_existing_writable_dir(self, tmp_path):
|
||||
with mock.patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
result = wb.check_hermes_home()
|
||||
assert result.passed
|
||||
|
||||
def test_fails_when_dir_missing(self, tmp_path):
|
||||
missing = tmp_path / "nonexistent"
|
||||
with mock.patch.dict(os.environ, {"HERMES_HOME": str(missing)}):
|
||||
result = wb.check_hermes_home()
|
||||
assert not result.passed
|
||||
|
||||
|
||||
class TestBootstrapReport:
|
||||
def test_passed_when_all_pass(self):
|
||||
report = wb.BootstrapReport()
|
||||
report.add(wb.CheckResult("a", True, "ok"))
|
||||
report.add(wb.CheckResult("b", True, "ok"))
|
||||
assert report.passed
|
||||
assert report.failed == []
|
||||
|
||||
def test_failed_when_any_fail(self):
|
||||
report = wb.BootstrapReport()
|
||||
report.add(wb.CheckResult("a", True, "ok"))
|
||||
report.add(wb.CheckResult("b", False, "bad", fix_hint="fix it"))
|
||||
assert not report.passed
|
||||
assert len(report.failed) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# skills_audit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSkillsAudit:
|
||||
def _make_skill(self, skills_root: Path, rel_path: str, content: str = "# skill") -> Path:
|
||||
"""Create a SKILL.md at skills_root/rel_path/SKILL.md."""
|
||||
skill_dir = skills_root / rel_path
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text(content)
|
||||
return skill_md
|
||||
|
||||
def test_no_drift_when_identical(self, tmp_path):
|
||||
# run_audit expects repo_root/skills/ and installed_root/
|
||||
repo = tmp_path / "repo"
|
||||
installed = tmp_path / "installed"
|
||||
content = "# Same content"
|
||||
self._make_skill(repo / "skills", "cat/skill-a", content)
|
||||
self._make_skill(installed, "cat/skill-a", content)
|
||||
|
||||
report = sa.run_audit(repo, installed)
|
||||
assert not report.has_drift
|
||||
assert len(report.by_status("OK")) == 1
|
||||
|
||||
def test_detects_missing_skill(self, tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
installed = tmp_path / "installed"
|
||||
installed.mkdir()
|
||||
self._make_skill(repo / "skills", "cat/skill-a")
|
||||
|
||||
report = sa.run_audit(repo, installed)
|
||||
assert report.has_drift
|
||||
assert len(report.by_status("MISSING")) == 1
|
||||
|
||||
def test_detects_extra_skill(self, tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
(repo / "skills").mkdir(parents=True)
|
||||
installed = tmp_path / "installed"
|
||||
self._make_skill(installed, "cat/skill-a")
|
||||
|
||||
report = sa.run_audit(repo, installed)
|
||||
assert report.has_drift
|
||||
assert len(report.by_status("EXTRA")) == 1
|
||||
|
||||
def test_detects_outdated_skill(self, tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
installed = tmp_path / "installed"
|
||||
self._make_skill(repo / "skills", "cat/skill-a", "# Repo version")
|
||||
self._make_skill(installed, "cat/skill-a", "# Installed version")
|
||||
|
||||
report = sa.run_audit(repo, installed)
|
||||
assert report.has_drift
|
||||
assert len(report.by_status("OUTDATED")) == 1
|
||||
|
||||
def test_fix_copies_missing_skills(self, tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
installed = tmp_path / "installed"
|
||||
installed.mkdir()
|
||||
self._make_skill(repo / "skills", "cat/skill-a", "# content")
|
||||
|
||||
report = sa.run_audit(repo, installed)
|
||||
assert len(report.by_status("MISSING")) == 1
|
||||
|
||||
sa.apply_fix(report)
|
||||
|
||||
report2 = sa.run_audit(repo, installed)
|
||||
assert not report2.has_drift
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dependency_checker tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDependencyChecker:
|
||||
def _make_skill(self, root: Path, rel_path: str, content: str) -> None:
|
||||
skill_dir = root / rel_path
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text(content)
|
||||
|
||||
def test_no_deps_when_no_frontmatter(self, tmp_path):
|
||||
self._make_skill(tmp_path, "cat/plain", "# No frontmatter")
|
||||
report = dc.run_dep_check(skills_dir=tmp_path)
|
||||
assert report.deps == []
|
||||
|
||||
def test_detects_missing_binary(self, tmp_path):
|
||||
content = "---\nname: test\ndependencies:\n binaries: [definitely_not_a_real_binary_xyz]\n---\n"
|
||||
self._make_skill(tmp_path, "cat/skill", content)
|
||||
report = dc.run_dep_check(skills_dir=tmp_path)
|
||||
assert len(report.deps) == 1
|
||||
assert not report.deps[0].satisfied
|
||||
assert report.deps[0].binary == "definitely_not_a_real_binary_xyz"
|
||||
|
||||
def test_detects_present_binary(self, tmp_path):
|
||||
content = "---\nname: test\ndependencies:\n binaries: [python3]\n---\n"
|
||||
self._make_skill(tmp_path, "cat/skill", content)
|
||||
report = dc.run_dep_check(skills_dir=tmp_path)
|
||||
assert len(report.deps) == 1
|
||||
assert report.deps[0].satisfied
|
||||
|
||||
def test_detects_missing_env_var(self, tmp_path):
|
||||
content = "---\nname: test\ndependencies:\n env_vars: [DEFINITELY_NOT_SET_XYZ_123]\n---\n"
|
||||
self._make_skill(tmp_path, "cat/skill", content)
|
||||
env = {k: v for k, v in os.environ.items() if k != "DEFINITELY_NOT_SET_XYZ_123"}
|
||||
with mock.patch.dict(os.environ, env, clear=True):
|
||||
report = dc.run_dep_check(skills_dir=tmp_path)
|
||||
assert len(report.deps) == 1
|
||||
assert not report.deps[0].satisfied
|
||||
|
||||
def test_detects_present_env_var(self, tmp_path):
|
||||
content = "---\nname: test\ndependencies:\n env_vars: [MY_TEST_VAR_WIZARD]\n---\n"
|
||||
self._make_skill(tmp_path, "cat/skill", content)
|
||||
with mock.patch.dict(os.environ, {"MY_TEST_VAR_WIZARD": "set"}):
|
||||
report = dc.run_dep_check(skills_dir=tmp_path)
|
||||
assert len(report.deps) == 1
|
||||
assert report.deps[0].satisfied
|
||||
|
||||
def test_skill_filter(self, tmp_path):
|
||||
content = "---\nname: test\ndependencies:\n binaries: [python3]\n---\n"
|
||||
self._make_skill(tmp_path, "cat/skill-a", content)
|
||||
self._make_skill(tmp_path, "cat/skill-b", content)
|
||||
|
||||
report = dc.run_dep_check(skills_dir=tmp_path, skill_filter="skill-a")
|
||||
assert len(report.deps) == 1
|
||||
assert "skill-a" in report.deps[0].skill_path
|
||||
@@ -1,162 +0,0 @@
|
||||
# Wizard Environment Contract
|
||||
|
||||
> **Version:** 1.0.0
|
||||
> **Owner:** Wizard Council (Bezalel Epic-004)
|
||||
> **Last updated:** 2026-04-06
|
||||
|
||||
This document defines the minimum viable state every forge wizard must maintain.
|
||||
A wizard that satisfies all requirements is considered **forge-ready**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Python Runtime
|
||||
|
||||
| Requirement | Minimum | Notes |
|
||||
|-------------|---------|-------|
|
||||
| Python version | 3.11 | 3.12+ recommended |
|
||||
| Virtual environment | Activated | `source venv/bin/activate` before running |
|
||||
|
||||
Run `python --version` to verify.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Package Dependencies
|
||||
|
||||
All packages in `requirements.txt` must be installed and importable.
|
||||
Critical packages: `openai`, `anthropic`, `pyyaml`, `rich`, `requests`, `pydantic`, `prompt_toolkit`.
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
python wizard-bootstrap/wizard_bootstrap.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. LLM Provider Key
|
||||
|
||||
At least one LLM provider API key must be set in `~/.hermes/.env`:
|
||||
|
||||
| Variable | Provider |
|
||||
|----------|----------|
|
||||
| `OPENROUTER_API_KEY` | OpenRouter (200+ models) |
|
||||
| `ANTHROPIC_API_KEY` | Anthropic Claude |
|
||||
| `ANTHROPIC_TOKEN` | Anthropic Claude (alt) |
|
||||
| `OPENAI_API_KEY` | OpenAI |
|
||||
| `GLM_API_KEY` | z.ai/GLM |
|
||||
| `KIMI_API_KEY` | Moonshot/Kimi |
|
||||
| `MINIMAX_API_KEY` | MiniMax |
|
||||
|
||||
---
|
||||
|
||||
## 4. Gitea Authentication
|
||||
|
||||
| Requirement | Details |
|
||||
|-------------|---------|
|
||||
| Variable | `GITEA_TOKEN` or `FORGE_TOKEN` |
|
||||
| Scope | Must have repo read/write access |
|
||||
| Forge URL | `https://forge.alexanderwhitestone.com` (or `FORGE_URL` env var) |
|
||||
|
||||
The wizard must be able to create and merge PRs on the forge.
|
||||
|
||||
---
|
||||
|
||||
## 5. Telegram Connectivity (Gateway Wizards)
|
||||
|
||||
Wizards that operate via the messaging gateway must also satisfy:
|
||||
|
||||
| Requirement | Details |
|
||||
|-------------|---------|
|
||||
| Variable | `TELEGRAM_BOT_TOKEN` |
|
||||
| Home channel | `TELEGRAM_HOME_CHANNEL` |
|
||||
| API reachability | `api.telegram.org` must be reachable |
|
||||
|
||||
CLI-only wizards may skip Telegram checks.
|
||||
|
||||
---
|
||||
|
||||
## 6. HERMES_HOME
|
||||
|
||||
| Requirement | Details |
|
||||
|-------------|---------|
|
||||
| Default | `~/.hermes` |
|
||||
| Override | `HERMES_HOME` env var |
|
||||
| Permissions | Owner-writable (700 recommended) |
|
||||
|
||||
The directory must exist and be writable before any hermes command runs.
|
||||
|
||||
---
|
||||
|
||||
## 7. Skill Dependencies (Per-Skill)
|
||||
|
||||
Each skill may declare binary and environment-variable dependencies in its
|
||||
`SKILL.md` frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: my-skill
|
||||
dependencies:
|
||||
binaries: [ffmpeg, imagemagick]
|
||||
env_vars: [MY_API_KEY]
|
||||
---
|
||||
```
|
||||
|
||||
A wizard must satisfy all dependencies for any skill it intends to run.
|
||||
|
||||
**Check all skill deps:**
|
||||
```bash
|
||||
python wizard-bootstrap/dependency_checker.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Enforcement
|
||||
|
||||
### New Wizard Onboarding
|
||||
|
||||
Run the bootstrap script before going online:
|
||||
|
||||
```bash
|
||||
python wizard-bootstrap/wizard_bootstrap.py
|
||||
```
|
||||
|
||||
Resolve all failures before beginning work.
|
||||
|
||||
### Ongoing Compliance
|
||||
|
||||
A monthly audit runs automatically (see `wizard-bootstrap/monthly_audit.py`).
|
||||
The report is saved to `~/.hermes/wizard-council/audit-YYYY-MM.md` and posted
|
||||
to the `wizard-council-automation` Telegram channel.
|
||||
|
||||
### Skill Drift
|
||||
|
||||
Run the skills audit to detect and fix drift:
|
||||
|
||||
```bash
|
||||
python wizard-bootstrap/skills_audit.py # detect
|
||||
python wizard-bootstrap/skills_audit.py --fix # sync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Contract Versioning
|
||||
|
||||
Changes to this contract require a PR reviewed by at least one wizard council
|
||||
member. Bump the version number and update the date above with each change.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Full environment validation
|
||||
python wizard-bootstrap/wizard_bootstrap.py
|
||||
|
||||
# Skills drift check
|
||||
python wizard-bootstrap/skills_audit.py
|
||||
|
||||
# Dependency check
|
||||
python wizard-bootstrap/dependency_checker.py
|
||||
|
||||
# Full monthly audit (all three checks, saves report)
|
||||
python wizard-bootstrap/monthly_audit.py
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
# wizard-bootstrap package
|
||||
@@ -1,300 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
dependency_checker.py — Cross-Wizard Dependency Validator
|
||||
|
||||
Each skill may declare binary or environment-variable dependencies in its
|
||||
SKILL.md frontmatter under a `dependencies` key:
|
||||
|
||||
---
|
||||
name: my-skill
|
||||
dependencies:
|
||||
binaries: [ffmpeg, imagemagick]
|
||||
env_vars: [MY_API_KEY, MY_SECRET]
|
||||
---
|
||||
|
||||
This script scans all installed skills, extracts declared dependencies, and
|
||||
checks whether each is satisfied in the current environment.
|
||||
|
||||
Usage:
|
||||
python wizard-bootstrap/dependency_checker.py
|
||||
python wizard-bootstrap/dependency_checker.py --json
|
||||
python wizard-bootstrap/dependency_checker.py --skill software-development/code-review
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import yaml
|
||||
HAS_YAML = True
|
||||
except ImportError:
|
||||
HAS_YAML = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class SkillDep:
|
||||
skill_path: str
|
||||
skill_name: str
|
||||
binary: Optional[str] = None
|
||||
env_var: Optional[str] = None
|
||||
satisfied: bool = False
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DepReport:
|
||||
deps: list[SkillDep] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def all_satisfied(self) -> bool:
|
||||
return all(d.satisfied for d in self.deps)
|
||||
|
||||
@property
|
||||
def unsatisfied(self) -> list[SkillDep]:
|
||||
return [d for d in self.deps if not d.satisfied]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frontmatter parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_frontmatter(text: str) -> dict:
|
||||
"""Extract YAML frontmatter from a SKILL.md file."""
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
end = text.find("\n---", 3)
|
||||
if end == -1:
|
||||
return {}
|
||||
fm_text = text[3:end].strip()
|
||||
if not HAS_YAML:
|
||||
return {}
|
||||
try:
|
||||
return yaml.safe_load(fm_text) or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _load_skill_deps(skill_md: Path) -> tuple[str, list[str], list[str]]:
|
||||
"""
|
||||
Returns (skill_name, binaries, env_vars) from a SKILL.md frontmatter.
|
||||
"""
|
||||
text = skill_md.read_text(encoding="utf-8", errors="replace")
|
||||
fm = _parse_frontmatter(text)
|
||||
skill_name = fm.get("name", skill_md.parent.name)
|
||||
deps = fm.get("dependencies", {})
|
||||
if not isinstance(deps, dict):
|
||||
return skill_name, [], []
|
||||
binaries = deps.get("binaries") or []
|
||||
env_vars = deps.get("env_vars") or []
|
||||
if isinstance(binaries, str):
|
||||
binaries = [binaries]
|
||||
if isinstance(env_vars, str):
|
||||
env_vars = [env_vars]
|
||||
return skill_name, list(binaries), list(env_vars)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _check_binary(binary: str) -> tuple[bool, str]:
|
||||
path = shutil.which(binary)
|
||||
if path:
|
||||
return True, f"found at {path}"
|
||||
return False, f"not found in PATH"
|
||||
|
||||
|
||||
def _check_env_var(var: str) -> tuple[bool, str]:
|
||||
val = os.environ.get(var)
|
||||
if val:
|
||||
return True, "set"
|
||||
return False, "not set"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scanner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find_skills_dir() -> Optional[Path]:
|
||||
"""Resolve skills directory: prefer repo root, fall back to HERMES_HOME."""
|
||||
# Check if we're inside the repo
|
||||
repo_root = Path(__file__).parent.parent
|
||||
repo_skills = repo_root / "skills"
|
||||
if repo_skills.exists():
|
||||
return repo_skills
|
||||
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
for candidate in [hermes_home / "skills", hermes_home / "hermes-agent" / "skills"]:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def run_dep_check(skills_dir: Optional[Path] = None, skill_filter: Optional[str] = None) -> DepReport:
|
||||
resolved = skills_dir or _find_skills_dir()
|
||||
report = DepReport()
|
||||
|
||||
if resolved is None or not resolved.exists():
|
||||
return report
|
||||
|
||||
# Load ~/.hermes/.env so env var checks work
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
env_path = hermes_home / ".env"
|
||||
if env_path.exists():
|
||||
try:
|
||||
from dotenv import load_dotenv # noqa: PLC0415
|
||||
load_dotenv(env_path, override=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for skill_md in sorted(resolved.rglob("SKILL.md")):
|
||||
rel = str(skill_md.parent.relative_to(resolved))
|
||||
if skill_filter and skill_filter not in rel:
|
||||
continue
|
||||
|
||||
skill_name, binaries, env_vars = _load_skill_deps(skill_md)
|
||||
|
||||
for binary in binaries:
|
||||
ok, detail = _check_binary(binary)
|
||||
report.deps.append(SkillDep(
|
||||
skill_path=rel,
|
||||
skill_name=skill_name,
|
||||
binary=binary,
|
||||
satisfied=ok,
|
||||
detail=detail,
|
||||
))
|
||||
|
||||
for var in env_vars:
|
||||
ok, detail = _check_env_var(var)
|
||||
report.deps.append(SkillDep(
|
||||
skill_path=rel,
|
||||
skill_name=skill_name,
|
||||
env_var=var,
|
||||
satisfied=ok,
|
||||
detail=detail,
|
||||
))
|
||||
|
||||
return report
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_GREEN = "\033[32m"
|
||||
_RED = "\033[31m"
|
||||
_YELLOW = "\033[33m"
|
||||
_BOLD = "\033[1m"
|
||||
_RESET = "\033[0m"
|
||||
|
||||
|
||||
def _render_terminal(report: DepReport) -> None:
|
||||
print(f"\n{_BOLD}=== Cross-Wizard Dependency Check ==={_RESET}\n")
|
||||
|
||||
if not report.deps:
|
||||
print("No skill dependencies declared. Skills use implicit deps only.\n")
|
||||
print(
|
||||
f"{_YELLOW}Tip:{_RESET} Declare binary/env_var deps in SKILL.md frontmatter "
|
||||
"under a 'dependencies' key to make them checkable.\n"
|
||||
)
|
||||
return
|
||||
|
||||
for dep in report.deps:
|
||||
icon = f"{_GREEN}✓{_RESET}" if dep.satisfied else f"{_RED}✗{_RESET}"
|
||||
if dep.binary:
|
||||
dep_type = "binary"
|
||||
dep_name = dep.binary
|
||||
else:
|
||||
dep_type = "env_var"
|
||||
dep_name = dep.env_var
|
||||
|
||||
print(f" {icon} [{dep.skill_path}] {dep_type}:{dep_name} — {dep.detail}")
|
||||
|
||||
total = len(report.deps)
|
||||
satisfied = sum(1 for d in report.deps if d.satisfied)
|
||||
print()
|
||||
if report.all_satisfied:
|
||||
print(f"{_GREEN}{_BOLD}All {total} dependencies satisfied.{_RESET}\n")
|
||||
else:
|
||||
failed = total - satisfied
|
||||
print(
|
||||
f"{_RED}{_BOLD}{failed}/{total} dependencies unsatisfied.{_RESET} "
|
||||
"Install missing binaries and set missing env vars.\n"
|
||||
)
|
||||
|
||||
|
||||
def _render_json(report: DepReport) -> None:
|
||||
out = {
|
||||
"all_satisfied": report.all_satisfied,
|
||||
"summary": {
|
||||
"total": len(report.deps),
|
||||
"satisfied": sum(1 for d in report.deps if d.satisfied),
|
||||
"unsatisfied": len(report.unsatisfied),
|
||||
},
|
||||
"deps": [
|
||||
{
|
||||
"skill_path": d.skill_path,
|
||||
"skill_name": d.skill_name,
|
||||
"type": "binary" if d.binary else "env_var",
|
||||
"name": d.binary or d.env_var,
|
||||
"satisfied": d.satisfied,
|
||||
"detail": d.detail,
|
||||
}
|
||||
for d in report.deps
|
||||
],
|
||||
}
|
||||
print(json.dumps(out, indent=2))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
if not HAS_YAML:
|
||||
print("WARNING: pyyaml not installed — cannot parse SKILL.md frontmatter. "
|
||||
"Dependency declarations will be skipped.", file=sys.stderr)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check cross-wizard skill dependencies (binaries, env vars)."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skills-dir",
|
||||
default=None,
|
||||
help="Skills directory to scan (default: auto-detect)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skill",
|
||||
default=None,
|
||||
help="Filter to a specific skill path substring",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output results as JSON",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
skills_dir = Path(args.skills_dir).resolve() if args.skills_dir else None
|
||||
report = run_dep_check(skills_dir=skills_dir, skill_filter=args.skill)
|
||||
|
||||
if args.json:
|
||||
_render_json(report)
|
||||
else:
|
||||
_render_terminal(report)
|
||||
|
||||
sys.exit(0 if report.all_satisfied else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,259 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
monthly_audit.py — Wizard Council Monthly Environment Audit
|
||||
|
||||
Runs all three checks (bootstrap, skills audit, dependency check) and
|
||||
produces a combined Markdown report. Designed to be invoked by cron or
|
||||
manually.
|
||||
|
||||
Usage:
|
||||
python wizard-bootstrap/monthly_audit.py
|
||||
python wizard-bootstrap/monthly_audit.py --output /path/to/report.md
|
||||
python wizard-bootstrap/monthly_audit.py --post-telegram # post to configured channel
|
||||
|
||||
The report is also written to ~/.hermes/wizard-council/audit-YYYY-MM.md
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from contextlib import redirect_stdout
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure repo root is importable
|
||||
_REPO_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from wizard_bootstrap import run_all_checks
|
||||
from skills_audit import run_audit
|
||||
from dependency_checker import run_dep_check
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Report builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _emoji(ok: bool) -> str:
|
||||
return "✅" if ok else "❌"
|
||||
|
||||
|
||||
def build_report(repo_root: Path) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
lines = [
|
||||
f"# Wizard Council Environment Audit",
|
||||
f"",
|
||||
f"**Date:** {now.strftime('%Y-%m-%d %H:%M UTC')}",
|
||||
f"",
|
||||
f"---",
|
||||
f"",
|
||||
]
|
||||
|
||||
# 1. Bootstrap checks
|
||||
lines.append("## 1. Environment Bootstrap")
|
||||
lines.append("")
|
||||
bootstrap = run_all_checks()
|
||||
for check in bootstrap.checks:
|
||||
icon = _emoji(check.passed)
|
||||
label = check.name.replace("_", " ").title()
|
||||
lines.append(f"- {icon} **{label}**: {check.message}")
|
||||
if not check.passed and check.fix_hint:
|
||||
lines.append(f" - _Fix_: {check.fix_hint}")
|
||||
lines.append("")
|
||||
if bootstrap.passed:
|
||||
lines.append("**Environment: READY** ✅")
|
||||
else:
|
||||
failed = len(bootstrap.failed)
|
||||
lines.append(f"**Environment: {failed} check(s) FAILED** ❌")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
# 2. Skills audit
|
||||
lines.append("## 2. Skills Drift Audit")
|
||||
lines.append("")
|
||||
skills_report = run_audit(repo_root)
|
||||
missing = skills_report.by_status("MISSING")
|
||||
extra = skills_report.by_status("EXTRA")
|
||||
outdated = skills_report.by_status("OUTDATED")
|
||||
ok_count = len(skills_report.by_status("OK"))
|
||||
total = len(skills_report.drifts)
|
||||
|
||||
lines.append(f"| Status | Count |")
|
||||
lines.append(f"|--------|-------|")
|
||||
lines.append(f"| ✅ OK | {ok_count} |")
|
||||
lines.append(f"| ❌ Missing | {len(missing)} |")
|
||||
lines.append(f"| ⚠️ Extra | {len(extra)} |")
|
||||
lines.append(f"| 🔄 Outdated | {len(outdated)} |")
|
||||
lines.append(f"| **Total** | **{total}** |")
|
||||
lines.append("")
|
||||
|
||||
if missing:
|
||||
lines.append("### Missing Skills (in repo, not installed)")
|
||||
for d in missing:
|
||||
lines.append(f"- `{d.skill_path}`")
|
||||
lines.append("")
|
||||
|
||||
if outdated:
|
||||
lines.append("### Outdated Skills")
|
||||
for d in outdated:
|
||||
lines.append(f"- `{d.skill_path}` (repo: `{d.repo_hash}`, installed: `{d.installed_hash}`)")
|
||||
lines.append("")
|
||||
|
||||
if extra:
|
||||
lines.append("### Extra Skills (installed, not in repo)")
|
||||
for d in extra:
|
||||
lines.append(f"- `{d.skill_path}`")
|
||||
lines.append("")
|
||||
|
||||
if not skills_report.has_drift:
|
||||
lines.append("**Skills: IN SYNC** ✅")
|
||||
else:
|
||||
lines.append("**Skills: DRIFT DETECTED** ❌ — run `python wizard-bootstrap/skills_audit.py --fix`")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
# 3. Dependency check
|
||||
lines.append("## 3. Cross-Wizard Dependency Check")
|
||||
lines.append("")
|
||||
dep_report = run_dep_check()
|
||||
|
||||
if not dep_report.deps:
|
||||
lines.append("No explicit dependencies declared in SKILL.md frontmatter.")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"_Tip: Add a `dependencies` block to SKILL.md to make binary/env_var "
|
||||
"requirements checkable automatically._"
|
||||
)
|
||||
else:
|
||||
satisfied = sum(1 for d in dep_report.deps if d.satisfied)
|
||||
total_deps = len(dep_report.deps)
|
||||
lines.append(f"**{satisfied}/{total_deps} dependencies satisfied.**")
|
||||
lines.append("")
|
||||
if dep_report.unsatisfied:
|
||||
lines.append("### Unsatisfied Dependencies")
|
||||
for dep in dep_report.unsatisfied:
|
||||
dep_type = "binary" if dep.binary else "env_var"
|
||||
dep_name = dep.binary or dep.env_var
|
||||
lines.append(f"- `[{dep.skill_path}]` {dep_type}:`{dep_name}` — {dep.detail}")
|
||||
lines.append("")
|
||||
|
||||
if dep_report.all_satisfied:
|
||||
lines.append("**Dependencies: ALL SATISFIED** ✅")
|
||||
else:
|
||||
lines.append("**Dependencies: ISSUES FOUND** ❌")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
# Summary
|
||||
overall_ok = bootstrap.passed and not skills_report.has_drift and dep_report.all_satisfied
|
||||
lines.append("## Summary")
|
||||
lines.append("")
|
||||
lines.append(f"| Check | Status |")
|
||||
lines.append(f"|-------|--------|")
|
||||
lines.append(f"| Environment Bootstrap | {_emoji(bootstrap.passed)} |")
|
||||
lines.append(f"| Skills Drift | {_emoji(not skills_report.has_drift)} |")
|
||||
lines.append(f"| Dependency Check | {_emoji(dep_report.all_satisfied)} |")
|
||||
lines.append("")
|
||||
if overall_ok:
|
||||
lines.append("**Overall: FORGE READY** ✅")
|
||||
else:
|
||||
lines.append("**Overall: ACTION REQUIRED** ❌")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output / delivery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _save_report(report: str, output_path: Path) -> None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(report, encoding="utf-8")
|
||||
print(f"Report saved to: {output_path}")
|
||||
|
||||
|
||||
def _post_telegram(report: str) -> None:
|
||||
"""Post the report summary to Telegram via hermes gateway if configured."""
|
||||
token = os.environ.get("TELEGRAM_BOT_TOKEN")
|
||||
channel = os.environ.get("TELEGRAM_HOME_CHANNEL") or os.environ.get("TELEGRAM_CHANNEL_ID")
|
||||
if not (token and channel):
|
||||
print("Telegram not configured (need TELEGRAM_BOT_TOKEN + TELEGRAM_HOME_CHANNEL).", file=sys.stderr)
|
||||
return
|
||||
|
||||
try:
|
||||
import requests # noqa: PLC0415
|
||||
|
||||
# Extract just the summary section for Telegram (keep it brief)
|
||||
summary_start = report.find("## Summary")
|
||||
summary_text = report[summary_start:] if summary_start != -1 else report[-1000:]
|
||||
payload = {
|
||||
"chat_id": channel,
|
||||
"text": f"🧙 **Wizard Council Monthly Audit**\n\n{summary_text}",
|
||||
"parse_mode": "Markdown",
|
||||
}
|
||||
resp = requests.post(
|
||||
f"https://api.telegram.org/bot{token}/sendMessage",
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
print("Report summary posted to Telegram.")
|
||||
else:
|
||||
print(f"Telegram post failed: HTTP {resp.status_code}", file=sys.stderr)
|
||||
except Exception as exc:
|
||||
print(f"Telegram post error: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the monthly Wizard Council environment audit."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default=None,
|
||||
help="Path to save the Markdown report (default: ~/.hermes/wizard-council/audit-YYYY-MM.md)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-root",
|
||||
default=str(_REPO_ROOT),
|
||||
help="Root of the hermes-agent repo",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--post-telegram",
|
||||
action="store_true",
|
||||
help="Post the report summary to Telegram",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = Path(args.repo_root).resolve()
|
||||
report = build_report(repo_root)
|
||||
|
||||
# Print to stdout
|
||||
print(report)
|
||||
|
||||
# Save to default location
|
||||
now = datetime.now(timezone.utc)
|
||||
if args.output:
|
||||
output_path = Path(args.output)
|
||||
else:
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
output_path = hermes_home / "wizard-council" / f"audit-{now.strftime('%Y-%m')}.md"
|
||||
|
||||
_save_report(report, output_path)
|
||||
|
||||
if args.post_telegram:
|
||||
_post_telegram(report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,342 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
skills_audit.py — Skills Drift Detector
|
||||
|
||||
Compares the skills bundled in the repo against those installed in
|
||||
HERMES_HOME/skills/, then reports any drift:
|
||||
|
||||
- MISSING — skill in repo but not in installed location
|
||||
- EXTRA — skill installed but not in repo (local-only)
|
||||
- OUTDATED — repo skill.md differs from installed skill.md
|
||||
|
||||
Usage:
|
||||
python wizard-bootstrap/skills_audit.py
|
||||
python wizard-bootstrap/skills_audit.py --fix # copy missing skills
|
||||
python wizard-bootstrap/skills_audit.py --json
|
||||
python wizard-bootstrap/skills_audit.py --repo-root /path/to/hermes-agent
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class SkillDrift:
|
||||
skill_path: str # e.g. "software-development/code-review"
|
||||
status: str # "MISSING" | "EXTRA" | "OUTDATED" | "OK"
|
||||
repo_hash: Optional[str] = None
|
||||
installed_hash: Optional[str] = None
|
||||
diff_lines: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditReport:
|
||||
drifts: list[SkillDrift] = field(default_factory=list)
|
||||
repo_root: Path = Path(".")
|
||||
installed_root: Path = Path(".")
|
||||
|
||||
@property
|
||||
def has_drift(self) -> bool:
|
||||
return any(d.status != "OK" for d in self.drifts)
|
||||
|
||||
def by_status(self, status: str) -> list[SkillDrift]:
|
||||
return [d for d in self.drifts if d.status == status]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
h.update(path.read_bytes())
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
|
||||
def _find_skills(root: Path) -> dict[str, Path]:
|
||||
"""Return {relative_skill_path: SKILL.md path} for every skill under root."""
|
||||
skills: dict[str, Path] = {}
|
||||
for skill_md in root.rglob("SKILL.md"):
|
||||
# skill path is relative to root, e.g. "software-development/code-review"
|
||||
rel = skill_md.parent.relative_to(root)
|
||||
skills[str(rel)] = skill_md
|
||||
return skills
|
||||
|
||||
|
||||
def _diff_skills(repo_md: Path, installed_md: Path) -> list[str]:
|
||||
repo_lines = repo_md.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
inst_lines = installed_md.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
diff = list(
|
||||
difflib.unified_diff(
|
||||
inst_lines,
|
||||
repo_lines,
|
||||
fromfile="installed",
|
||||
tofile="repo",
|
||||
lineterm="",
|
||||
)
|
||||
)
|
||||
return diff
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core audit logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_installed_skills_root() -> Optional[Path]:
|
||||
"""Return the installed skills directory, or None if not found."""
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
candidates = [
|
||||
hermes_home / "skills",
|
||||
hermes_home / "hermes-agent" / "skills",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def run_audit(repo_root: Path, installed_root: Optional[Path] = None) -> AuditReport:
|
||||
repo_skills_dir = repo_root / "skills"
|
||||
if not repo_skills_dir.exists():
|
||||
print(f"ERROR: Repo skills directory not found: {repo_skills_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
resolved_installed = installed_root or _resolve_installed_skills_root()
|
||||
report = AuditReport(
|
||||
repo_root=repo_root,
|
||||
installed_root=resolved_installed or Path("/not-found"),
|
||||
)
|
||||
|
||||
repo_map = _find_skills(repo_skills_dir)
|
||||
|
||||
if resolved_installed is None or not resolved_installed.exists():
|
||||
# All repo skills are "MISSING" from the installation
|
||||
for skill_path in sorted(repo_map):
|
||||
report.drifts.append(
|
||||
SkillDrift(
|
||||
skill_path=skill_path,
|
||||
status="MISSING",
|
||||
repo_hash=_sha256_file(repo_map[skill_path]),
|
||||
)
|
||||
)
|
||||
return report
|
||||
|
||||
installed_map = _find_skills(resolved_installed)
|
||||
|
||||
all_paths = sorted(set(repo_map) | set(installed_map))
|
||||
for skill_path in all_paths:
|
||||
in_repo = skill_path in repo_map
|
||||
in_installed = skill_path in installed_map
|
||||
|
||||
if in_repo and not in_installed:
|
||||
report.drifts.append(
|
||||
SkillDrift(
|
||||
skill_path=skill_path,
|
||||
status="MISSING",
|
||||
repo_hash=_sha256_file(repo_map[skill_path]),
|
||||
)
|
||||
)
|
||||
elif in_installed and not in_repo:
|
||||
report.drifts.append(
|
||||
SkillDrift(
|
||||
skill_path=skill_path,
|
||||
status="EXTRA",
|
||||
installed_hash=_sha256_file(installed_map[skill_path]),
|
||||
)
|
||||
)
|
||||
else:
|
||||
rh = _sha256_file(repo_map[skill_path])
|
||||
ih = _sha256_file(installed_map[skill_path])
|
||||
if rh != ih:
|
||||
diff = _diff_skills(repo_map[skill_path], installed_map[skill_path])
|
||||
report.drifts.append(
|
||||
SkillDrift(
|
||||
skill_path=skill_path,
|
||||
status="OUTDATED",
|
||||
repo_hash=rh,
|
||||
installed_hash=ih,
|
||||
diff_lines=diff,
|
||||
)
|
||||
)
|
||||
else:
|
||||
report.drifts.append(
|
||||
SkillDrift(skill_path=skill_path, status="OK", repo_hash=rh, installed_hash=ih)
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fix: copy missing skills into installed location
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def apply_fix(report: AuditReport) -> None:
|
||||
if report.installed_root == Path("/not-found"):
|
||||
print("Cannot fix: installed skills directory not found.", file=sys.stderr)
|
||||
return
|
||||
|
||||
repo_skills_dir = report.repo_root / "skills"
|
||||
for drift in report.by_status("MISSING"):
|
||||
src = repo_skills_dir / drift.skill_path / "SKILL.md"
|
||||
dst = report.installed_root / drift.skill_path / "SKILL.md"
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
print(f" Installed: {drift.skill_path}")
|
||||
|
||||
for drift in report.by_status("OUTDATED"):
|
||||
src = repo_skills_dir / drift.skill_path / "SKILL.md"
|
||||
dst = report.installed_root / drift.skill_path / "SKILL.md"
|
||||
shutil.copy2(src, dst)
|
||||
print(f" Updated: {drift.skill_path}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_GREEN = "\033[32m"
|
||||
_RED = "\033[31m"
|
||||
_YELLOW = "\033[33m"
|
||||
_CYAN = "\033[36m"
|
||||
_BOLD = "\033[1m"
|
||||
_RESET = "\033[0m"
|
||||
|
||||
_STATUS_COLOR = {
|
||||
"OK": _GREEN,
|
||||
"MISSING": _RED,
|
||||
"EXTRA": _YELLOW,
|
||||
"OUTDATED": _CYAN,
|
||||
}
|
||||
|
||||
|
||||
def _render_terminal(report: AuditReport, show_diff: bool = False) -> None:
|
||||
print(f"\n{_BOLD}=== Wizard Skills Audit ==={_RESET}")
|
||||
print(f" Repo skills: {report.repo_root / 'skills'}")
|
||||
print(f" Installed skills: {report.installed_root}\n")
|
||||
|
||||
if not report.drifts:
|
||||
print(f"{_GREEN}No skills found to compare.{_RESET}\n")
|
||||
return
|
||||
|
||||
total = len(report.drifts)
|
||||
ok = len(report.by_status("OK"))
|
||||
missing = len(report.by_status("MISSING"))
|
||||
extra = len(report.by_status("EXTRA"))
|
||||
outdated = len(report.by_status("OUTDATED"))
|
||||
|
||||
for drift in sorted(report.drifts, key=lambda d: (d.status == "OK", d.skill_path)):
|
||||
color = _STATUS_COLOR.get(drift.status, _RESET)
|
||||
print(f" {color}{drift.status:8}{_RESET} {drift.skill_path}")
|
||||
if show_diff and drift.diff_lines:
|
||||
for line in drift.diff_lines[:20]:
|
||||
print(f" {line}")
|
||||
if len(drift.diff_lines) > 20:
|
||||
print(f" ... ({len(drift.diff_lines) - 20} more lines)")
|
||||
|
||||
print()
|
||||
print(f" Total: {total} OK: {_GREEN}{ok}{_RESET} "
|
||||
f"Missing: {_RED}{missing}{_RESET} "
|
||||
f"Extra: {_YELLOW}{extra}{_RESET} "
|
||||
f"Outdated: {_CYAN}{outdated}{_RESET}")
|
||||
print()
|
||||
|
||||
if not report.has_drift:
|
||||
print(f"{_GREEN}{_BOLD}No drift detected. Skills are in sync.{_RESET}\n")
|
||||
else:
|
||||
print(f"{_YELLOW}{_BOLD}Drift detected. Run with --fix to sync missing/outdated skills.{_RESET}\n")
|
||||
|
||||
|
||||
def _render_json(report: AuditReport) -> None:
|
||||
out = {
|
||||
"has_drift": report.has_drift,
|
||||
"repo_skills_dir": str(report.repo_root / "skills"),
|
||||
"installed_skills_dir": str(report.installed_root),
|
||||
"summary": {
|
||||
"total": len(report.drifts),
|
||||
"ok": len(report.by_status("OK")),
|
||||
"missing": len(report.by_status("MISSING")),
|
||||
"extra": len(report.by_status("EXTRA")),
|
||||
"outdated": len(report.by_status("OUTDATED")),
|
||||
},
|
||||
"drifts": [
|
||||
{
|
||||
"skill_path": d.skill_path,
|
||||
"status": d.status,
|
||||
"repo_hash": d.repo_hash,
|
||||
"installed_hash": d.installed_hash,
|
||||
"diff_line_count": len(d.diff_lines),
|
||||
}
|
||||
for d in report.drifts
|
||||
if d.status != "OK"
|
||||
],
|
||||
}
|
||||
print(json.dumps(out, indent=2))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Audit wizard skills for drift between repo and installed location."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-root",
|
||||
default=str(Path(__file__).parent.parent),
|
||||
help="Root of the hermes-agent repo (default: parent of this script)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--installed-root",
|
||||
default=None,
|
||||
help="Installed skills directory (default: auto-detect from HERMES_HOME)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fix",
|
||||
action="store_true",
|
||||
help="Copy missing/outdated skills from repo to installed location",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diff",
|
||||
action="store_true",
|
||||
help="Show diff for outdated skills",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output results as JSON",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = Path(args.repo_root).resolve()
|
||||
installed_root = Path(args.installed_root).resolve() if args.installed_root else None
|
||||
|
||||
report = run_audit(repo_root, installed_root)
|
||||
|
||||
if args.fix:
|
||||
apply_fix(report)
|
||||
# Re-run audit after fix to show updated state
|
||||
report = run_audit(repo_root, installed_root)
|
||||
|
||||
if args.json:
|
||||
_render_json(report)
|
||||
else:
|
||||
_render_terminal(report, show_diff=args.diff)
|
||||
|
||||
sys.exit(0 if not report.has_drift else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,378 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
wizard_bootstrap.py — Wizard Environment Validator
|
||||
|
||||
Validates that a new wizard's forge environment is ready:
|
||||
1. Python version check (>=3.11)
|
||||
2. Core dependencies installed
|
||||
3. Gitea authentication
|
||||
4. Telegram connectivity
|
||||
5. Smoke test (hermes import)
|
||||
|
||||
Usage:
|
||||
python wizard-bootstrap/wizard_bootstrap.py
|
||||
python wizard-bootstrap/wizard_bootstrap.py --fix
|
||||
python wizard-bootstrap/wizard_bootstrap.py --json
|
||||
|
||||
Exits 0 if all checks pass, 1 if any check fails.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
name: str
|
||||
passed: bool
|
||||
message: str
|
||||
fix_hint: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BootstrapReport:
|
||||
checks: list[CheckResult] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
return all(c.passed for c in self.checks)
|
||||
|
||||
@property
|
||||
def failed(self) -> list[CheckResult]:
|
||||
return [c for c in self.checks if not c.passed]
|
||||
|
||||
def add(self, result: CheckResult) -> None:
|
||||
self.checks.append(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individual checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_python_version() -> CheckResult:
|
||||
"""Require Python >= 3.11."""
|
||||
major, minor, micro = sys.version_info[:3]
|
||||
ok = (major, minor) >= (3, 11)
|
||||
return CheckResult(
|
||||
name="python_version",
|
||||
passed=ok,
|
||||
message=f"Python {major}.{minor}.{micro}",
|
||||
fix_hint="Install Python 3.11+ via uv, pyenv, or your OS package manager.",
|
||||
)
|
||||
|
||||
|
||||
def check_core_deps() -> CheckResult:
|
||||
"""Verify that hermes core Python packages are importable."""
|
||||
required = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"dotenv",
|
||||
"yaml",
|
||||
"rich",
|
||||
"requests",
|
||||
"pydantic",
|
||||
"prompt_toolkit",
|
||||
]
|
||||
missing = []
|
||||
for pkg in required:
|
||||
# dotenv ships as 'python-dotenv' but imports as 'dotenv'
|
||||
try:
|
||||
importlib.import_module(pkg)
|
||||
except ModuleNotFoundError:
|
||||
missing.append(pkg)
|
||||
|
||||
if missing:
|
||||
return CheckResult(
|
||||
name="core_deps",
|
||||
passed=False,
|
||||
message=f"Missing packages: {', '.join(missing)}",
|
||||
fix_hint="Run: uv pip install -r requirements.txt (or: pip install -r requirements.txt)",
|
||||
)
|
||||
return CheckResult(name="core_deps", passed=True, message="All core packages importable")
|
||||
|
||||
|
||||
def check_hermes_importable() -> CheckResult:
|
||||
"""Smoke-test: import hermes_constants (no side effects)."""
|
||||
# Add repo root to sys.path so we can import regardless of cwd
|
||||
repo_root = str(Path(__file__).parent.parent)
|
||||
if repo_root not in sys.path:
|
||||
sys.path.insert(0, repo_root)
|
||||
try:
|
||||
import hermes_constants # noqa: F401
|
||||
|
||||
return CheckResult(name="hermes_smoke", passed=True, message="hermes_constants imported OK")
|
||||
except Exception as exc:
|
||||
return CheckResult(
|
||||
name="hermes_smoke",
|
||||
passed=False,
|
||||
message=f"Import error: {exc}",
|
||||
fix_hint="Ensure you are in the hermes-agent repo root and your venv is active.",
|
||||
)
|
||||
|
||||
|
||||
def check_gitea_auth() -> CheckResult:
|
||||
"""Verify Gitea token env var is set and the API responds."""
|
||||
token = os.environ.get("GITEA_TOKEN") or os.environ.get("FORGE_TOKEN")
|
||||
if not token:
|
||||
return CheckResult(
|
||||
name="gitea_auth",
|
||||
passed=False,
|
||||
message="GITEA_TOKEN / FORGE_TOKEN not set",
|
||||
fix_hint="Export GITEA_TOKEN=<your-token> in your shell or ~/.hermes/.env",
|
||||
)
|
||||
|
||||
# Attempt a lightweight API call — list repos endpoint returns quickly
|
||||
forge_url = os.environ.get("FORGE_URL", "https://forge.alexanderwhitestone.com")
|
||||
try:
|
||||
import requests # noqa: PLC0415
|
||||
|
||||
resp = requests.get(
|
||||
f"{forge_url}/api/v1/repos/search",
|
||||
headers={"Authorization": f"token {token}"},
|
||||
params={"limit": 1},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return CheckResult(name="gitea_auth", passed=True, message="Gitea API reachable and token valid")
|
||||
return CheckResult(
|
||||
name="gitea_auth",
|
||||
passed=False,
|
||||
message=f"Gitea API returned HTTP {resp.status_code}",
|
||||
fix_hint="Check that your GITEA_TOKEN is correct and not expired.",
|
||||
)
|
||||
except Exception as exc:
|
||||
return CheckResult(
|
||||
name="gitea_auth",
|
||||
passed=False,
|
||||
message=f"Gitea API unreachable: {exc}",
|
||||
fix_hint="Check network connectivity and FORGE_URL env var.",
|
||||
)
|
||||
|
||||
|
||||
def check_telegram_connectivity() -> CheckResult:
|
||||
"""Verify Telegram bot token is set and the Bot API responds."""
|
||||
token = os.environ.get("TELEGRAM_BOT_TOKEN")
|
||||
if not token:
|
||||
return CheckResult(
|
||||
name="telegram",
|
||||
passed=False,
|
||||
message="TELEGRAM_BOT_TOKEN not set",
|
||||
fix_hint="Export TELEGRAM_BOT_TOKEN=<token> in your shell or ~/.hermes/.env",
|
||||
)
|
||||
|
||||
try:
|
||||
import requests # noqa: PLC0415
|
||||
|
||||
resp = requests.get(
|
||||
f"https://api.telegram.org/bot{token}/getMe",
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
username = data.get("result", {}).get("username", "?")
|
||||
return CheckResult(
|
||||
name="telegram",
|
||||
passed=True,
|
||||
message=f"Telegram bot @{username} reachable",
|
||||
)
|
||||
return CheckResult(
|
||||
name="telegram",
|
||||
passed=False,
|
||||
message=f"Telegram API returned HTTP {resp.status_code}",
|
||||
fix_hint="Check that TELEGRAM_BOT_TOKEN is valid.",
|
||||
)
|
||||
except Exception as exc:
|
||||
return CheckResult(
|
||||
name="telegram",
|
||||
passed=False,
|
||||
message=f"Telegram unreachable: {exc}",
|
||||
fix_hint="Check network connectivity.",
|
||||
)
|
||||
|
||||
|
||||
def check_env_vars() -> CheckResult:
|
||||
"""Check that at least one LLM provider key is configured."""
|
||||
provider_keys = [
|
||||
"OPENROUTER_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
"GLM_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
]
|
||||
found = [k for k in provider_keys if os.environ.get(k)]
|
||||
if found:
|
||||
return CheckResult(
|
||||
name="llm_provider",
|
||||
passed=True,
|
||||
message=f"LLM provider key(s) present: {', '.join(found)}",
|
||||
)
|
||||
return CheckResult(
|
||||
name="llm_provider",
|
||||
passed=False,
|
||||
message="No LLM provider API key found",
|
||||
fix_hint=(
|
||||
"Set at least one of: OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY "
|
||||
"in ~/.hermes/.env or your shell."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def check_hermes_home() -> CheckResult:
|
||||
"""Verify HERMES_HOME directory exists and is writable."""
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
if not hermes_home.exists():
|
||||
return CheckResult(
|
||||
name="hermes_home",
|
||||
passed=False,
|
||||
message=f"HERMES_HOME does not exist: {hermes_home}",
|
||||
fix_hint="Run 'hermes setup' or create the directory manually.",
|
||||
)
|
||||
if not os.access(hermes_home, os.W_OK):
|
||||
return CheckResult(
|
||||
name="hermes_home",
|
||||
passed=False,
|
||||
message=f"HERMES_HOME not writable: {hermes_home}",
|
||||
fix_hint=f"Fix permissions: chmod u+w {hermes_home}",
|
||||
)
|
||||
return CheckResult(
|
||||
name="hermes_home",
|
||||
passed=True,
|
||||
message=f"HERMES_HOME OK: {hermes_home}",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_dotenv_if_available() -> None:
|
||||
"""Load ~/.hermes/.env so token checks work without manual export."""
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
env_path = hermes_home / ".env"
|
||||
if env_path.exists():
|
||||
try:
|
||||
from dotenv import load_dotenv # noqa: PLC0415
|
||||
|
||||
load_dotenv(env_path, override=False)
|
||||
except Exception:
|
||||
pass # dotenv not installed yet — that's fine
|
||||
|
||||
|
||||
def run_all_checks() -> BootstrapReport:
|
||||
report = BootstrapReport()
|
||||
_load_dotenv_if_available()
|
||||
|
||||
checks = [
|
||||
check_python_version,
|
||||
check_core_deps,
|
||||
check_hermes_importable,
|
||||
check_hermes_home,
|
||||
check_env_vars,
|
||||
check_gitea_auth,
|
||||
check_telegram_connectivity,
|
||||
]
|
||||
for fn in checks:
|
||||
result = fn()
|
||||
report.add(result)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_GREEN = "\033[32m"
|
||||
_RED = "\033[31m"
|
||||
_YELLOW = "\033[33m"
|
||||
_BOLD = "\033[1m"
|
||||
_RESET = "\033[0m"
|
||||
|
||||
|
||||
def _render_terminal(report: BootstrapReport) -> None:
|
||||
print(f"\n{_BOLD}=== Wizard Bootstrap — Environment Check ==={_RESET}\n")
|
||||
for check in report.checks:
|
||||
icon = f"{_GREEN}✓{_RESET}" if check.passed else f"{_RED}✗{_RESET}"
|
||||
label = check.name.replace("_", " ").title()
|
||||
print(f" {icon} {_BOLD}{label}{_RESET}: {check.message}")
|
||||
if not check.passed and check.fix_hint:
|
||||
print(f" {_YELLOW}→ {check.fix_hint}{_RESET}")
|
||||
if check.detail:
|
||||
print(f" {check.detail}")
|
||||
|
||||
total = len(report.checks)
|
||||
passed = sum(1 for c in report.checks if c.passed)
|
||||
print()
|
||||
if report.passed:
|
||||
print(f"{_GREEN}{_BOLD}All {total} checks passed. Forge is ready.{_RESET}\n")
|
||||
else:
|
||||
failed = total - passed
|
||||
print(
|
||||
f"{_RED}{_BOLD}{failed}/{total} check(s) failed.{_RESET} "
|
||||
f"Resolve the issues above before going online.\n"
|
||||
)
|
||||
|
||||
|
||||
def _render_json(report: BootstrapReport) -> None:
|
||||
out = {
|
||||
"passed": report.passed,
|
||||
"summary": {
|
||||
"total": len(report.checks),
|
||||
"passed": sum(1 for c in report.checks if c.passed),
|
||||
"failed": sum(1 for c in report.checks if not c.passed),
|
||||
},
|
||||
"checks": [
|
||||
{
|
||||
"name": c.name,
|
||||
"passed": c.passed,
|
||||
"message": c.message,
|
||||
"fix_hint": c.fix_hint,
|
||||
"detail": c.detail,
|
||||
}
|
||||
for c in report.checks
|
||||
],
|
||||
}
|
||||
print(json.dumps(out, indent=2))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate the forge wizard environment."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output results as JSON",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
report = run_all_checks()
|
||||
|
||||
if args.json:
|
||||
_render_json(report)
|
||||
else:
|
||||
_render_terminal(report)
|
||||
|
||||
sys.exit(0 if report.passed else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user