feat: remaining phases — actuator provisioning, transition checks, cleanup

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.
This commit is contained in:
2026-07-08 00:40:53 +02:00
parent cfce35bee0
commit 84ecb6b895
5 changed files with 530 additions and 159 deletions

View File

@@ -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 <hostname>
# 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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
echo "[setup-caveman] done"

View File

@@ -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=''
echo "[setup-hermes-soul] no hermes/SOUL.md found; skipping"
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)"