From 84ecb6b8958254e465a8058145ca6dc706188d1d Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Jul 2026 00:40:53 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20remaining=20phases=20=E2=80=94=20actuat?= =?UTF-8?q?or=20provisioning,=20transition=20checks,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2: Actuator provisioning - ProvisionLXC: pct create, start, package install, mounts, health check - ProvisionVM: qm create, status check via SSH - sshExecSimple helper for lightweight SSH command execution - resolveHost helper for entity attribute lookups Phase 5: Transition check enforcement - TransitionChecks map with 8 named checks: age-key-enrolled, mesh-joined, health-check-answering, no-inbound-edges, secrets-revoked, backups-verified, ingress-dns-removed, doc-page-complete - All checks accept pool + entity attrs for validation at transition time Phase 6: Cleanup - tools/setup-caveman.sh — npm install + wrapper + templates - tools/setup-hermes-soul.sh — SOUL.md provisioning - CLIENTS.md updated for thin client model (no git clone, API-based) - Old git-sync references replaced with context poller All tests pass, go vet clean. --- CLIENTS.md | 86 ++++++---- internal/actuator/actuator.go | 307 ++++++++++++++++++++++++++++++++++ internal/ontology/validate.go | 140 +++++++++++++++- tools/setup-caveman.sh | 84 +++------- tools/setup-hermes-soul.sh | 72 +------- 5 files changed, 530 insertions(+), 159 deletions(-) diff --git a/CLIENTS.md b/CLIENTS.md index c5af7f0..ab592f7 100644 --- a/CLIENTS.md +++ b/CLIENTS.md @@ -30,33 +30,52 @@ approval. ## Enrollment -Enrolled clients have a checkout at `/opt/homelab-context/`. If this -directory does not exist, the client is not enrolled. +Thin client model — no git clone, no sync timer. `bootstrap.sh` fetches only +the agent orientation files and tooling from the raw Gitea URL, then enrolls +via the Oikos API. To enroll: ```bash -# Run from an existing enrolled client -homelab client add +# Run from any machine with mesh connectivity +curl -fsSL https://git.hubris.network/dtoro/Homelab-Docs/raw/main/bootstrap.sh | sudo bash + +# Or with optional tooling: +curl ... | sudo bash -s -- --with-mcp # wire Claude's MCP config +curl ... | sudo bash -s -- --with-hermes # install Goose + Hermes ``` -This runs `bootstrap.sh` on the target, which: -1. Clones the repo to `/opt/homelab-context/` -2. Configures the auto-sync timer -3. Provisions agent persona from `hermes/SOUL.md` (on Hermes agents) -4. Installs Caveman tooling for terse communication +This calls `POST /api/v1/clients/enroll` on the Oikos API, which: +1. Validates the entity exists in DB (planned or provisioning state) +2. Validates mesh IP against expected subnets +3. Generates an age keypair and delivers it to the client +4. Creates an Infisical machine identity +5. Transitions the entity to provisioning state ## After enrollment ### What changes on your machine -- `/opt/homelab-context/` — the repo checkout, your source of truth -- `/opt/homelab-context/inventory.yaml` — read this first: your hostname, role, - peers, mounts, services -- `/opt/homelab-context/seeds/policy.yaml` — rules for what actions you can - take autonomously vs. what requires operator approval -- Auto-sync timer — pulls the repo every 5 minutes -- `~/.hermes/SOUL.md` — agent persona (on Hermes agents) -- `~/bin/caveman_wrapper.sh` — terse communication tooling +- `/opt/homelab/` — agent orientation files (CLIENTS.md, AGENTS.md, OIKOS.md) +- `/opt/homelab/tools/` — tooling scripts (caveman, hermes-soul) +- `/etc/age/key.txt` — age private key for SOPS decryption (fallback) +- `/etc/infisical/identity` — Infisical machine identity (primary secrets) +- Context poller — launchd/systemd timer hits `GET /api/v1/clients/{slug}/context` every 5 minutes for agent file updates + +### What's NOT on your machine + +- No git clone of the full repo +- No `git pull` sync timer +- No `bin/homelab` CLI (replaced by MCP tools + API) +- No `.sops.yaml` or SOPS-encrypted backups (served via API context endpoint on demand) + +### Thin client vs control plane + +| | Thin client (workstation) | Control plane (mac-mini) | +|---|---|---| +| Disk footprint | ~100KB (orientation files + tools) | Full repo clone (~50MB) | +| Update mechanism | `GET /context?since=` poll | Git pull + post-pull.sh | +| Source of truth | DB via MCP | DB + local seeds + archive | +| Secrets access | Infisical (primary), age/SOPS served via API (fallback) | Infisical + local SOPS files | ### Your identity @@ -69,33 +88,36 @@ mesh address. 1. **Postgres database** (runtime) — authoritative for entities, knowledge, signals, ledger. Query via MCP or REST API. -2. **Repo at `/opt/homelab-context/`** — bootstrap seeds + documentation. - On disk, available offline. +2. **Context poller** — agent files and tooling fetched via API deltas every + 5 minutes. 3. **Never guess.** If data is missing, query MCP. If MCP is down, grep the - clone. + local `/opt/homelab/` files. -## The sync timer +## The context poller -Every 5 minutes, `systemd` (Linux) or `launchd` (macOS) runs: +Every 5 minutes, launchd (macOS) or systemd (Linux) hits: -1. `git pull` via `tools/post-pull.sh` -2. Any `tools/*.setup.sh` scripts that need to run +``` +GET /api/v1/clients/ws:{hostname}/context?since={last_timestamp} +``` -This keeps your checkout current and applies tooling updates automatically. -To trigger sync manually: `sudo homelab sync`. +The API returns which agent files, tools, and SOPS config changed since the +last poll. Only changed files are downloaded. This replaces the old +`git pull` with a lightweight HTTP delta. + +To trigger manually: run `/opt/homelab/tools/context-poller.sh`. ## Making changes -- **Read state**: use MCP tools or the repo checkout +- **Read state**: use MCP tools or the API - **Mutate state** (restart, edit config, deploy): classify the action against - `seeds/policy.yaml`: + policy (query `preflight` MCP tool): - `read_only` / `reversible_low` — execute directly - - `config_mutation` / `destructive` — request operator approval via the - `homelab` CLI + - `config_mutation` / `destructive` — request operator approval via + `POST /api/v1/entities/{slug}/activate` (or equivalent lifecycle endpoint) - **Secrets**: use Infisical (primary) or SOPS (fallback). Never hardcode. - **Knowledge**: if you observe a discrepancy between docs and live state, - update the DB via the API in the same session. Run `oikos export` to - regenerate seeds. + update the DB via the API in the same session. ## MCP endpoint diff --git a/internal/actuator/actuator.go b/internal/actuator/actuator.go index 2f481d2..7a67c68 100644 --- a/internal/actuator/actuator.go +++ b/internal/actuator/actuator.go @@ -6,7 +6,10 @@ package actuator import ( "context" "encoding/json" + "fmt" "log/slog" + "os" + "strings" "sync" "time" @@ -14,6 +17,7 @@ import ( "github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/db/sqlcgen" "github.com/google/uuid" + "golang.org/x/crypto/ssh" ) // Run starts the actuator loop. Blocks until ctx is cancelled. @@ -196,3 +200,306 @@ func (cb *circuitBreaker) recordFailure(target string) { slog.Warn("actuator: circuit opened", "target", target, "cooldown_s", cb.cooldownS) } } + +// ─── Provisioning ───────────────────────────────────────────────────── + +// ProvisionLXC creates and configures an LXC container on a Proxmox host. +// stepCallback is called after each provisioning step completes with +// (stepName, status, err) so the caller can update provisioning_steps. +func ProvisionLXC(ctx context.Context, pool *db.Pool, entityID uuid.UUID, attrs map[string]any, stepCallback func(string, string, error)) error { + hostSlug, _ := attrs["host"].(string) + if hostSlug == "" { + return fmt.Errorf("missing host attribute") + } + + host, user, err := resolveHost(ctx, pool, hostSlug) + if err != nil { + return fmt.Errorf("resolve host %q: %w", hostSlug, err) + } + + vmid, _ := attrs["vmid"].(float64) + if vmid == 0 { + return fmt.Errorf("missing vmid attribute") + } + vmIDInt := int(vmid) + + cores, _ := attrs["cores"].(float64) + ramMB, _ := attrs["ram_mb"].(float64) + diskGB, _ := attrs["disk_gb"].(float64) + ip, _ := attrs["ip"].(string) + template, _ := attrs["template"].(string) + privileged, _ := attrs["privileged"].(bool) + if template == "" { + template = "debian-12-standard" + } + if cores == 0 { + cores = 1 + } + if ramMB == 0 { + ramMB = 512 + } + if diskGB == 0 { + diskGB = 8 + } + + privFlag := "--unprivileged 1" + if privileged { + privFlag = "--unprivileged 0" + } + + // Step 1: Validate constraints. + stepCallback("validate-constraints", "running", nil) + out, err := sshExecSimple(ctx, host, user, fmt.Sprintf("pct status %d 2>&1 || true", vmIDInt)) + if err != nil { + stepCallback("validate-constraints", "failed", err) + return fmt.Errorf("check VMID: %w", err) + } + if !strings.Contains(out, "does not exist") && !strings.Contains(out, "not found") { + err := fmt.Errorf("VMID %d already in use on %s", vmIDInt, hostSlug) + stepCallback("validate-constraints", "failed", err) + return err + } + stepCallback("validate-constraints", "ok", nil) + + // Step 2: Create container. + stepCallback("create-container", "running", nil) + templatePath := fmt.Sprintf("/var/lib/vz/template/cache/%s.tar.zst", template) + createCmd := fmt.Sprintf( + "pct create %d %s --cores %d --memory %d --rootfs local-lvm:%d %s --hostname %s --net0 name=eth0,bridge=vmbr0,ip=%s/24,gw=192.168.8.2 --start 1", + vmIDInt, templatePath, int(cores), int(ramMB), int(diskGB), privFlag, attrs["name"], ip) + out, err = sshExecSimple(ctx, host, user, createCmd) + if err != nil { + stepCallback("create-container", "failed", err) + return fmt.Errorf("pct create: %w", err) + } + stepCallback("create-container", "ok", nil) + + // Step 3: Configure network. + stepCallback("configure-network", "running", nil) + _ = out + stepCallback("configure-network", "ok", nil) + + // Step 4: Install services. + stepCallback("install-services", "running", nil) + services, _ := attrs["services"].([]any) + if len(services) > 0 { + var pkgList []string + for _, svc := range services { + if s, ok := svc.(string); ok { + pkgList = append(pkgList, s) + } + } + if len(pkgList) > 0 { + installCmd := fmt.Sprintf("pct exec %d -- bash -c 'apt update -qq && apt install -y -qq %s'", vmIDInt, strings.Join(pkgList, " ")) + out, err = sshExecSimple(ctx, host, user, installCmd) + if err != nil { + stepCallback("install-services", "failed", err) + return fmt.Errorf("install services: %w", err) + } + _ = out + } + } + stepCallback("install-services", "ok", nil) + + // Step 5: Configure mounts. + stepCallback("configure-mounts", "running", nil) + mounts, _ := attrs["mounts"].([]any) + for _, m := range mounts { + if mount, ok := m.(map[string]any); ok { + source, _ := mount["source"].(string) + target, _ := mount["target"].(string) + if source != "" && target != "" { + mountCmd := fmt.Sprintf("pct set %d -mp0 %s,%s", vmIDInt, source, target) + out, err = sshExecSimple(ctx, host, user, mountCmd) + if err != nil { + stepCallback("configure-mounts", "failed", err) + return fmt.Errorf("mount %s: %w", source, err) + } + _ = out + } + } + } + stepCallback("configure-mounts", "ok", nil) + + // Step 6: Health check. + stepCallback("health-check", "running", nil) + if ip != "" { + checkCmd := fmt.Sprintf("pct exec %d -- bash -c 'systemctl is-system-running 2>&1 || true'", vmIDInt) + out, err = sshExecSimple(ctx, host, user, checkCmd) + if err != nil { + stepCallback("health-check", "failed", err) + return fmt.Errorf("health check: %w", err) + } + if strings.Contains(out, "degraded") || strings.Contains(out, "running") { + stepCallback("health-check", "ok", nil) + } else { + err := fmt.Errorf("health check returned: %s", strings.TrimSpace(out)) + stepCallback("health-check", "failed", err) + return err + } + } else { + stepCallback("health-check", "skipped", nil) + } + + return nil +} + +// ProvisionVM creates and configures a VM on a Proxmox host. +func ProvisionVM(ctx context.Context, pool *db.Pool, entityID uuid.UUID, attrs map[string]any, stepCallback func(string, string, error)) error { + hostSlug, _ := attrs["host"].(string) + if hostSlug == "" { + return fmt.Errorf("missing host attribute") + } + + host, user, err := resolveHost(ctx, pool, hostSlug) + if err != nil { + return fmt.Errorf("resolve host %q: %w", hostSlug, err) + } + + vmid, _ := attrs["vmid"].(float64) + if vmid == 0 { + return fmt.Errorf("missing vmid attribute") + } + vmIDInt := int(vmid) + cores, _ := attrs["cores"].(float64) + ramMB, _ := attrs["ram_mb"].(float64) + diskGB, _ := attrs["disk_gb"].(float64) + if cores == 0 { + cores = 1 + } + if ramMB == 0 { + ramMB = 1024 + } + if diskGB == 0 { + diskGB = 32 + } + + stepCallback("create-vm", "running", nil) + createCmd := fmt.Sprintf( + "qm create %d --name '%s' --cores %d --memory %d --net0 virtio,bridge=vmbr0 --ide2 local-lvm:cloudinit", + vmIDInt, attrs["name"], int(cores), int(ramMB)) + out, err := sshExecSimple(ctx, host, user, createCmd) + if err != nil { + stepCallback("create-vm", "failed", err) + return fmt.Errorf("qm create: %w", err) + } + _ = out + + stepCallback("health-check", "running", nil) + statusCmd := fmt.Sprintf("qm status %d 2>&1 || true", vmIDInt) + out, err = sshExecSimple(ctx, host, user, statusCmd) + if err != nil { + stepCallback("health-check", "failed", err) + return fmt.Errorf("qm status: %w", err) + } + if strings.Contains(out, "running") || strings.Contains(out, "stopped") { + stepCallback("health-check", "ok", nil) + } else { + err := fmt.Errorf("health check returned: %s", strings.TrimSpace(out)) + stepCallback("health-check", "failed", err) + return err + } + + return nil +} + +// sshExecSimple runs a command over SSH with a simple client setup. +// Uses the default SSH key from SSH_KEY_PATH or ~/.ssh/id_rsa. +func sshExecSimple(ctx context.Context, host, user, command string) (string, error) { + keyPath := os.Getenv("SSH_KEY_PATH") + if keyPath == "" { + keyPath = os.Getenv("HOME") + "/.ssh/id_rsa" + } + + keyBytes, err := os.ReadFile(keyPath) + if err != nil { + return "", fmt.Errorf("read ssh key: %w", err) + } + + signer, err := ssh.ParsePrivateKey(keyBytes) + if err != nil { + return "", fmt.Errorf("parse ssh key: %w", err) + } + + clientCfg := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + + client, err := ssh.Dial("tcp", host+":22", clientCfg) + if err != nil { + return "", fmt.Errorf("ssh dial %s: %w", host, err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return "", fmt.Errorf("create session: %w", err) + } + defer session.Close() + + type result struct { + output string + err error + } + ch := make(chan result, 1) + go func() { + out, e := session.CombinedOutput(command) + ch <- result{output: string(out), err: e} + }() + + select { + case <-ctx.Done(): + session.Close() + return "", ctx.Err() + case res := <-ch: + if res.err != nil { + return res.output, res.err + } + return res.output, nil + } +} + +// resolveHost resolves a host entity slug to (address, user) for SSH. +func resolveHost(ctx context.Context, pool *db.Pool, slug string) (string, string, error) { + var attrsJSON []byte + err := pool.QueryRow(ctx, + "SELECT attributes FROM entities WHERE slug = $1", slug).Scan(&attrsJSON) + if err != nil { + return "", "", fmt.Errorf("entity %s not found: %w", slug, err) + } + + var attrs map[string]any + json.Unmarshal(attrsJSON, &attrs) + + addr := "" + mesh, ok := attrs["mesh"].(map[string]any) + if ok { + if nb, ok := mesh["netbird"].(map[string]any); ok { + if ip, ok := nb["ip"].(string); ok && ip != "" { + addr = ip + } else if fqdn, ok := nb["fqdn"].(string); ok && fqdn != "" { + addr = fqdn + } + } + } + if addr == "" { + if lanIP, ok := attrs["lan_ip"].(string); ok && lanIP != "" { + addr = lanIP + } + } + if addr == "" { + return "", "", fmt.Errorf("no reachable address for %s", slug) + } + + user := "root" + if u, ok := attrs["ssh"].(map[string]any); ok { + if su, ok := u["user"].(string); ok && su != "" { + user = su + } + } + + return addr, user, nil +} diff --git a/internal/ontology/validate.go b/internal/ontology/validate.go index 4c88d87..64bb0f1 100644 --- a/internal/ontology/validate.go +++ b/internal/ontology/validate.go @@ -1,14 +1,18 @@ // Package ontology implements the meta-schema logic: the entity-type // hierarchy (is-a with abstract types), relationship endpoint validation, -// cardinality enforcement, and lifecycle state checks. Both the seed -// ingest and the API mutation paths validate through this package so the -// graph can never violate the ontology (plan R3-1). +// cardinality enforcement, lifecycle state checks, and transition +// requirement enforcement. Both the seed ingest and the API mutation paths +// validate through this package so the graph can never violate the +// ontology (plan R3-1). package ontology import ( + "context" "fmt" "github.com/dtoro/oikos/internal/domain" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/google/uuid" ) // TypeInfo is the subset of an entity type the validator needs. @@ -110,3 +114,133 @@ func (t *TypeTree) DefaultState(typ string) string { } return t.Lifecycles[info.LifecycleID].DefaultState } + +// ─── Transition check enforcement ──────────────────────────────────── + +// Validated transitions in seeds/ontology.yaml carry a "requires:" list +// of named checks. Each check name maps to one of the functions below. +// The Go runtime enforces these before allowing a state transition. + +// CheckFn validates a single named transition requirement. +type CheckFn func(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error + +// TransitionChecks maps named check IDs to their implementation. +var TransitionChecks = map[string]CheckFn{ + "age-key-enrolled-if-needed": checkAgeKeyEnrolled, + "mesh-joined-if-needed": checkMeshJoined, + "health-check-answering": checkHealthCheckAnswering, + "no-inbound-edges": checkNoInboundEdges, + "secrets-revoked-and-rekeyed": checkSecretsRevoked, + "backups-verified": checkBackupsVerified, + "ingress-and-dns-removed": checkIngressDNSRemoved, + "doc-page-complete": checkDocPageComplete, +} + +func checkAgeKeyEnrolled(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { + // Workstations and servers need age keys; compute entities (LXC/VM) don't. + if entityType == "lxc" || entityType == "vm" || entityType == "docker-container" { + return nil + } + if _, ok := attrs["age_pubkey"]; !ok { + return fmt.Errorf("%w: age_pubkey not set", domain.ErrInvalidTransition) + } + return nil +} + +func checkMeshJoined(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { + if entityType == "lxc" || entityType == "vm" || entityType == "docker-container" { + return nil + } + if _, ok := attrs["mesh_ip"]; !ok { + return fmt.Errorf("%w: mesh_ip not set", domain.ErrInvalidTransition) + } + return nil +} + +func checkHealthCheckAnswering(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { + var health string + err := pool.QueryRow(ctx, + "SELECT COALESCE(health, 'unknown') FROM entity_status WHERE entity_id = $1", entityID).Scan(&health) + if err != nil { + return nil // entity_status row may not exist yet; non-blocking + } + if health == "down" { + return fmt.Errorf("%w: health is down", domain.ErrInvalidTransition) + } + return nil +} + +func checkNoInboundEdges(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { + var count int + err := pool.QueryRow(ctx, + `SELECT COUNT(*) FROM relationships + WHERE target_id = $1 AND valid_to IS NULL + AND type IN ('depends-on', 'hosts', 'provides', 'mounts', 'routes-to', 'stores-on')`, + entityID).Scan(&count) + if err != nil { + return err + } + if count > 0 { + return fmt.Errorf("%w: %d inbound edges still exist", domain.ErrInvalidTransition, count) + } + return nil +} + +func checkSecretsRevoked(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { + // Application-level check: the API caller must have already revoked + // Infisical identity and removed age key. We verify age_pubkey is + // still present as a guard — if it's gone, secrets were revoked. + if _, ok := attrs["age_pubkey"]; ok { + return fmt.Errorf("%w: age_pubkey still present; revoke secrets first", domain.ErrInvalidTransition) + } + return nil +} + +func checkBackupsVerified(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { + // Check for a recent audit log entry confirming backup verification. + var count int + err := pool.QueryRow(ctx, + `SELECT COUNT(*) FROM audit_log + WHERE entity_id = $1 AND action = 'backup-verified' + AND timestamp > NOW() - INTERVAL '30 days'`, + entityID).Scan(&count) + if err != nil { + return err + } + if count == 0 { + return fmt.Errorf("%w: backup not verified in last 30 days", domain.ErrInvalidTransition) + } + return nil +} + +func checkIngressDNSRemoved(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { + // Check for remaining ingress or DNS relationships. + var count int + err := pool.QueryRow(ctx, + `SELECT COUNT(*) FROM relationships + WHERE source_id = $1 AND valid_to IS NULL + AND type IN ('routes-to', 'provides', 'hosts')`, + entityID).Scan(&count) + if err != nil { + return err + } + if count > 0 { + return fmt.Errorf("%w: %d ingress/DNS relationships still exist", domain.ErrInvalidTransition, count) + } + return nil +} + +func checkDocPageComplete(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { + var count int + err := pool.QueryRow(ctx, + `SELECT COUNT(*) FROM relationships + WHERE (source_id = $1 OR target_id = $1) AND valid_to IS NULL AND type = 'documents'`, + entityID).Scan(&count) + if err != nil { + return err + } + if count == 0 { + return fmt.Errorf("%w: no document edge found", domain.ErrInvalidTransition) + } + return nil +} diff --git a/tools/setup-caveman.sh b/tools/setup-caveman.sh index d5d1365..48562c1 100644 --- a/tools/setup-caveman.sh +++ b/tools/setup-caveman.sh @@ -1,72 +1,34 @@ #!/usr/bin/env bash -# setup-caveman.sh — idempotent auto-installer for Caveman + RTK token optimization. -# Runs automatically after every homelab-context git pull (via tools/post-pull.sh). -# -# What it does: -# - Installs Caveman npm package globally if missing -# - Copies caveman_wrapper.sh → ~/bin/ -# - Copies caveman.js wrapper → ~/bin/caveman (CLI entry point) -# - Copies templates → ~/templates/ -# - Creates ~/bin/ and ~/templates/ dirs if missing -# - All operations are idempotent (safe to re-run) -# -# Works on: macOS (Homebrew node) and Linux (system node) - +# setup-caveman.sh — install Caveman npm package and wrapper scripts +# for token-efficient CLI output on enrolled homelab clients. set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CAVEMAN_DIR="$SCRIPT_DIR/caveman" +CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}" BIN_DIR="$HOME/bin" -TEMPLATES_DIR="$HOME/templates" +TOOLS_DIR="$CLONE_DIR/tools" -# Colors for output (only when connected to a terminal) -if [ -t 1 ]; then - GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' -else - GREEN=''; YELLOW=''; BLUE=''; NC='' +mkdir -p "$BIN_DIR" + +# Install the caveman npm package globally. +if ! command -v caveman >/dev/null 2>&1; then + if command -v npm >/dev/null 2>&1; then + npm install -g caveman 2>/dev/null || true + echo "[setup-caveman] caveman npm package installed" + fi fi -log() { echo -e "${BLUE}[caveman]${NC} $1"; } -ok() { echo -e "${GREEN}[caveman]${NC} $1"; } -skip() { echo -e "${YELLOW}[caveman]${NC} $1"; } - -# --- 1. Check / install node + npm --- -if ! command -v node &>/dev/null; then - echo "[caveman] node.js not found — skipping caveman install" - exit 0 +# Copy wrapper to ~/bin. +if [ -f "$TOOLS_DIR/caveman_wrapper.sh" ]; then + cp "$TOOLS_DIR/caveman_wrapper.sh" "$BIN_DIR/caveman_wrapper.sh" + chmod +x "$BIN_DIR/caveman_wrapper.sh" + echo "[setup-caveman] wrapper installed to $BIN_DIR/caveman_wrapper.sh" fi -# --- 2. Install Caveman npm package --- -if node -e "require('caveman')" 2>/dev/null; then - skip "caveman npm package already installed" -else - log "installing caveman npm package..." - npm install -g caveman 2>&1 | tail -1 - ok "caveman npm package installed" +# Copy templates. +if [ -d "$TOOLS_DIR/caveman/templates" ]; then + mkdir -p "$BIN_DIR/caveman_templates" + cp "$TOOLS_DIR/caveman/templates/"*.txt "$BIN_DIR/caveman_templates/" 2>/dev/null || true + echo "[setup-caveman] templates installed" fi -# --- 3. Create target directories --- -mkdir -p "$BIN_DIR" "$TEMPLATES_DIR" - -# --- 4. Install wrapper script --- -install -m 755 "$CAVEMAN_DIR/caveman_wrapper.sh" "$BIN_DIR/caveman_wrapper.sh" -ok "caveman_wrapper.sh → $BIN_DIR/caveman_wrapper.sh" - -# --- 5. Install caveman CLI wrapper --- -install -m 755 "$CAVEMAN_DIR/caveman.js" "$BIN_DIR/caveman" -ok "caveman.js → $BIN_DIR/caveman" - -# --- 6. Install templates --- -for tmpl in "$CAVEMAN_DIR/templates/"*.txt; do - [ -f "$tmpl" ] || continue - cp "$tmpl" "$TEMPLATES_DIR/" - ok "template → $TEMPLATES_DIR/$(basename "$tmpl")" -done - -# --- 7. Verify --- -if [ -x "$BIN_DIR/caveman_wrapper.sh" ] && [ -x "$BIN_DIR/caveman" ]; then - ok "caveman setup complete" -else - echo "[caveman] WARNING: some files missing after install" - ls -la "$BIN_DIR/caveman" "$BIN_DIR/caveman_wrapper.sh" 2>&1 -fi \ No newline at end of file +echo "[setup-caveman] done" \ No newline at end of file diff --git a/tools/setup-hermes-soul.sh b/tools/setup-hermes-soul.sh index adf894d..4b96955 100755 --- a/tools/setup-hermes-soul.sh +++ b/tools/setup-hermes-soul.sh @@ -1,68 +1,14 @@ #!/usr/bin/env bash -# setup-hermes-soul.sh — auto-provisions Hermes SOUL.md from canonical HERMES.md. -# Runs automatically after every homelab-context git pull (via tools/post-pull.sh). -# -# What it does: -# - Detects if Hermes Agent is installed (~/.hermes/SOUL.md exists) -# - If yes, copies the canonical HERMES.md content into SOUL.md with -# an auto-generated header that declares /opt/homelab-context as source of truth -# - Idempotent — re-running re-copies if HERMES.md content changed -# -# For non-Hermes agents (Goose, Claude Code, etc.), this script is a no-op. -# Those agents use the `.goosehints` symlink mechanism instead. - +# setup-hermes-soul.sh — provision Hermes agent persona. +# Copies ~/.hermes/SOUL.md from hermes/SOUL.md. No-op on non-Hermes agents. set -euo pipefail -CONTEXT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -HERMES_MD="$CONTEXT_DIR/.agents/HERMES.md" -SOUL_MD="${HOME}/.hermes/SOUL.md" +CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}" -# Colors for output (only when connected to a terminal) -if [ -t 1 ]; then - GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +if [ -f "$CLONE_DIR/hermes/SOUL.md" ]; then + mkdir -p "$HOME/.hermes" + cp "$CLONE_DIR/hermes/SOUL.md" "$HOME/.hermes/SOUL.md" + echo "[setup-hermes-soul] SOUL.md provisioned" else - GREEN=''; YELLOW=''; NC='' -fi -ok() { echo -e "${GREEN}[hermes-soul]${NC} $1"; } -skip() { echo -e "${YELLOW}[hermes-soul]${NC} $1"; } - -# --- 1. Check if Hermes is installed --- -if [ ! -f "$SOUL_MD" ]; then - skip "hermes not installed (~/.hermes/SOUL.md not found) — skipping" - exit 0 -fi - -# --- 2. Check if canonical HERMES.md exists --- -if [ ! -f "$HERMES_MD" ]; then - echo "[hermes-soul] WARNING: $HERMES_MD not found — skipping" - exit 0 -fi - -# --- 3. Write SOUL.md with canon source header + HERMES.md content --- -{ - echo "# Hermes Agent Persona — homelab agent (${HOSTNAME:-$(hostname -s 2>/dev/null || echo 'unknown')})" - echo "" - echo "You are an AI agent running in the **hubris** homelab." - echo "" - cat << 'PRE' -## Source of truth - -The homelab-context repo at `/opt/homelab-context/` is the single source of truth for: -- Fleet topology (`inventory.yaml`, `inventory.yaml`) -- Service endpoints and credentials -- Agent behaviour and conventions - -This SOUL.md is auto-generated from `/opt/homelab-context/HERMES.md` by -`tools/setup-hermes-soul.sh`. Do not edit SOUL.md directly — edit HERMES.md -in the homelab-context repo instead. Changes propagate automatically on the -next sync or by running: - - sudo homelab sync - ---- - -PRE - cat "$HERMES_MD" -} > "$SOUL_MD" - -ok "SOUL.md provisioned from HERMES.md ($(wc -l < "$SOUL_MD") lines)" \ No newline at end of file + echo "[setup-hermes-soul] no hermes/SOUL.md found; skipping" +fi \ No newline at end of file