From da2d588d09d97b5709059136b43620fbfa1cc183 Mon Sep 17 00:00:00 2001 From: Will Griffin Date: Sun, 24 May 2026 10:43:50 -0600 Subject: [PATCH 1/3] Install shared agent workflows --- README.md | 13 ++++ bash/.bashrc | 9 ++- install.sh | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++ zsh/.zshrc | 7 +- 4 files changed, 221 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 42e1a02..dab244d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ cd ~/dotfiles ``` dotfiles/ +├── .agents/ # Cross-agent skills +│ └── skills/ +├── .codex/ # Codex defaults +│ └── AGENTS.md ├── zsh/ # Zsh configuration │ └── .zshrc ├── bash/ # Bash configuration @@ -45,6 +49,14 @@ stow zsh bash nushell git To pick up new workstation dependencies later, use `update-home` or rerun `./install.sh`. +Agent skills and Codex defaults are installed separately from the normal home-directory stow packages: + +- `~/.agents/skills/ship` +- `~/.agents/skills/resolve` +- `~/.codex/AGENTS.md` + +Restart Codex after installing or updating skills; running sessions do not hot-load newly installed skills. + ### On NixOS The NixOS config uses `mkOutOfStoreSymlink` to point to this dotfiles repo: @@ -99,5 +111,6 @@ These files are sourced at the end of the main configs. - `claude` - `~/.claude/local/claude` - `codex` - installed with npm into `~/.npm-global/bin` - `gh copilot` - downloads the GitHub Copilot CLI via GitHub CLI +- `pr-review` - cloned/updated at `~/Work/happyvertical/repos/pr-review` and added to `PATH` - HappyVertical agent workflows - `~/Work/happyvertical/repos/have-config/install.sh --live` - `rebuild` / `update` - Platform-specific rebuild command diff --git a/bash/.bashrc b/bash/.bashrc index 37dc53d..9bda664 100644 --- a/bash/.bashrc +++ b/bash/.bashrc @@ -16,8 +16,13 @@ esac # ============================================================================== # PATH Setup # ============================================================================== -# Add ~/.local/bin to PATH (for locally installed tools like claude-code) -export PATH="$HOME/.local/bin:$PATH" +# Add local bin and agent CLIs to PATH +export PATH="$HOME/.local/bin:$HOME/.claude/local:$PATH" + +export PR_REVIEW_DIR="${PR_REVIEW_DIR:-$HOME/Work/happyvertical/repos/pr-review}" +if [[ -d "$PR_REVIEW_DIR/bin" ]]; then + export PATH="$PR_REVIEW_DIR/bin:$PATH" +fi # Configure npm to use home directory for global packages (avoids read-only Nix store) export NPM_CONFIG_PREFIX="$HOME/.npm-global" diff --git a/install.sh b/install.sh index 2f637cc..a5cc553 100755 --- a/install.sh +++ b/install.sh @@ -262,6 +262,11 @@ ensure_agent_paths() { if [[ -d "$HOME/.claude/local" ]] && [[ ":$PATH:" != *":$HOME/.claude/local:"* ]]; then export PATH="$HOME/.claude/local:$PATH" fi + + local pr_review_bin="${PR_REVIEW_DIR:-$HOME/Work/happyvertical/repos/pr-review}/bin" + if [[ -d "$pr_review_bin" ]] && [[ ":$PATH:" != *":$pr_review_bin:"* ]]; then + export PATH="$pr_review_bin:$PATH" + fi } # ============================================================================== @@ -350,6 +355,140 @@ install_copilot_cli() { fi } +install_pr_review() { + ensure_agent_paths + + local pr_review_dir="${PR_REVIEW_DIR:-$HOME/Work/happyvertical/repos/pr-review}" + local repo_url="${PR_REVIEW_REPO_URL:-https://github.com/happyvertical/pr-review.git}" + + if [[ -d "$pr_review_dir/.git" ]]; then + echo "Updating pr-review..." + if ! git -C "$pr_review_dir" pull --ff-only --quiet; then + echo " Could not fast-forward pr-review; using existing checkout." + fi + else + echo "Cloning pr-review..." + mkdir -p "$(dirname "$pr_review_dir")" + git clone --quiet "$repo_url" "$pr_review_dir" + fi + + ensure_agent_paths + + if command -v pr-review &> /dev/null; then + echo "pr-review on PATH: $(command -v pr-review)" + else + echo "pr-review not on PATH. Add this to your shell rc:" + echo " export PATH=\"$pr_review_dir/bin:\$PATH\"" + fi +} + +repair_have_config_codex_marketplace() { + if ! command -v codex &> /dev/null || ! command -v python3 &> /dev/null; then + return 0 + fi + + local expected_source="$1/codex" + local existing_source + local inspect_status + + if existing_source=$(python3 - "$expected_source" <<'PY' +import os +import sys + +expected = os.path.realpath(sys.argv[1]) +path = os.path.expanduser("~/.codex/config.toml") + +if not os.path.exists(path): + sys.exit(0) + +in_section = False +source = None + +with open(path, encoding="utf-8") as f: + for raw_line in f: + line = raw_line.strip() + if line.startswith("[") and line.endswith("]"): + in_section = line == "[marketplaces.have-config]" + continue + if in_section and "=" in line: + key, value = line.split("=", 1) + if key.strip() != "source": + continue + source = value.strip().strip('"') + break + +if source and os.path.realpath(os.path.expanduser(source)) != expected: + print(source) + sys.exit(42) +PY + ); then + return 0 + else + inspect_status="$?" + fi + + if [[ "$inspect_status" -eq 42 ]]; then + echo "Codex marketplace 'have-config' points at $existing_source; re-registering." + codex plugin marketplace remove have-config &> /dev/null || true + fi +} + +repair_have_config_claude_plugin() { + if ! command -v claude &> /dev/null || ! command -v python3 &> /dev/null; then + return 0 + fi + + local plugin_json="$1/claude/have/.claude-plugin/plugin.json" + local installed_json="$HOME/.claude/plugins/installed_plugins.json" + local stale_reason + local inspect_status + + if [[ ! -f "$plugin_json" || ! -f "$installed_json" ]]; then + return 0 + fi + + if stale_reason=$(python3 - "$plugin_json" "$installed_json" <<'PY' +import json +import os +import sys + +plugin_json, installed_json = sys.argv[1], os.path.expanduser(sys.argv[2]) + +with open(plugin_json, encoding="utf-8") as f: + expected_version = json.load(f).get("version") + +with open(installed_json, encoding="utf-8") as f: + installed_data = json.load(f) + +installed = installed_data.get("plugins", installed_data).get("have@have-config", []) + +if not installed: + sys.exit(0) + +entry = installed[0] +installed_version = entry.get("version") +install_path = entry.get("installPath") + +if expected_version and installed_version != expected_version: + print(f"version {installed_version} != {expected_version}") + sys.exit(42) + +if install_path and not os.path.isdir(os.path.expanduser(install_path)): + print(f"cache missing at {install_path}") + sys.exit(42) +PY + ); then + return 0 + else + inspect_status="$?" + fi + + if [[ "$inspect_status" -eq 42 ]]; then + echo "Claude have@have-config install is stale ($stale_reason); reinstalling." + claude plugin uninstall have@have-config &> /dev/null || true + fi +} + install_have_config() { ensure_agent_paths @@ -385,10 +524,61 @@ install_have_config() { return 1 fi + repair_have_config_claude_plugin "$have_config_dir" + repair_have_config_codex_marketplace "$have_config_dir" + echo "Installing HappyVertical agent workflows..." (cd "$have_config_dir" && ./install.sh "${install_args[@]}") } +link_managed_path() { + local source="$1" + local target="$2" + local relative="${target#$HOME/}" + + if [[ -L "$target" ]]; then + local current + current="$(readlink "$target")" + if [[ "$current" == "$source" ]]; then + echo " Already linked: $relative" + return 0 + fi + fi + + if [[ -e "$target" || -L "$target" ]]; then + if [[ -f "$source" && -f "$target" ]] && cmp -s "$source" "$target"; then + rm "$target" + else + local backup_dir="$HOME/.dotfiles-backup-$(date +%Y%m%d-%H%M%S)" + local backup_path="$backup_dir/$relative" + mkdir -p "$(dirname "$backup_path")" + mv "$target" "$backup_path" + echo " Backed up existing: $relative -> $backup_path" + fi + fi + + mkdir -p "$(dirname "$target")" + ln -s "$source" "$target" + echo " Linked: $relative" +} + +install_agent_configs() { + echo "Installing agent configs..." + + if [[ -d "$DOTFILES_DIR/.agents/skills" ]]; then + mkdir -p "$HOME/.agents/skills" + for skill_dir in "$DOTFILES_DIR"/.agents/skills/*; do + [[ -d "$skill_dir" ]] || continue + link_managed_path "$skill_dir" "$HOME/.agents/skills/$(basename "$skill_dir")" + done + fi + + if [[ -f "$DOTFILES_DIR/.codex/AGENTS.md" ]]; then + mkdir -p "$HOME/.codex" + link_managed_path "$DOTFILES_DIR/.codex/AGENTS.md" "$HOME/.codex/AGENTS.md" + fi +} + install_gemini_cli() { ensure_agent_paths @@ -630,12 +820,17 @@ main() { install_claude_code install_claude_plugins install_copilot_cli + install_pr_review install_have_config install_kimi_code install_gemini_cli install_ralph echo + # Install cross-agent skills and Codex defaults managed by this repo. + install_agent_configs + echo + # Install Oh My Zsh install_oh_my_zsh echo diff --git a/zsh/.zshrc b/zsh/.zshrc index cd456aa..61de20d 100644 --- a/zsh/.zshrc +++ b/zsh/.zshrc @@ -94,9 +94,14 @@ if [[ -f /opt/homebrew/bin/brew ]]; then eval "$(/opt/homebrew/bin/brew shellenv)" fi -# Add local bin and claude to PATH +# Add local bin and agent CLIs to PATH export PATH="$HOME/.local/bin:$HOME/.claude/local:$PATH" +export PR_REVIEW_DIR="${PR_REVIEW_DIR:-$HOME/Work/happyvertical/repos/pr-review}" +if [[ -d "$PR_REVIEW_DIR/bin" ]]; then + export PATH="$PR_REVIEW_DIR/bin:$PATH" +fi + # PostgreSQL client tools (keg-only on macOS) if [[ -d /opt/homebrew/opt/libpq/bin ]]; then export PATH="/opt/homebrew/opt/libpq/bin:$PATH" From cbd98962fa63966109ea88c3447ebf1d617a79a3 Mon Sep 17 00:00:00 2001 From: Will Griffin Date: Sun, 24 May 2026 12:35:40 -0600 Subject: [PATCH 2/3] Add harmonized agent bootstrap resolver --- README.md | 29 +- hv/README.md | 48 ++ hv/contextforge-manifest.example.json | 29 + hv/manifest.json | 35 ++ install.sh | 365 +++++++++--- scripts/hv-agent-resolver.py | 770 ++++++++++++++++++++++++++ scripts/test-hv-agent-resolver.sh | 149 +++++ 7 files changed, 1347 insertions(+), 78 deletions(-) create mode 100644 hv/README.md create mode 100644 hv/contextforge-manifest.example.json create mode 100644 hv/manifest.json create mode 100755 scripts/hv-agent-resolver.py create mode 100755 scripts/test-hv-agent-resolver.sh diff --git a/README.md b/README.md index dab244d..640ea9e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ dotfiles/ │ └── skills/ ├── .codex/ # Codex defaults │ └── AGENTS.md +├── hv/ # Personal baseline manifest for agent resolver +│ └── manifest.json +├── scripts/ # Bootstrap helper scripts +│ └── hv-agent-resolver.py ├── zsh/ # Zsh configuration │ └── .zshrc ├── bash/ # Bash configuration @@ -49,14 +53,29 @@ stow zsh bash nushell git To pick up new workstation dependencies later, use `update-home` or rerun `./install.sh`. -Agent skills and Codex defaults are installed separately from the normal home-directory stow packages: +Agent skills and global agent docs are resolved separately from the normal +home-directory stow packages. The resolver composes these layers: -- `~/.agents/skills/ship` -- `~/.agents/skills/resolve` -- `~/.codex/AGENTS.md` +1. this dotfiles repo as your personal baseline +2. `have-config` as the HappyVertical organization standard +3. an optional Context Forge snapshot from `HV_CONTEXTFORGE_SNAPSHOT_DIR` +4. machine-local overrides from `~/.config/hv/overrides` + +Commands and skills use this precedence: local override, Context Forge snapshot, +`have-config`, then dotfiles. AGENTS and CLAUDE docs are cumulative. + +Generated files are written under `~/.config/hv/generated`, with an +`~/.config/hv/agent-lock.json` and `~/.config/hv/install-report.md` explaining +sources, selected winners, overrides, missing env vars, and skipped tooling. Restart Codex after installing or updating skills; running sessions do not hot-load newly installed skills. +Audit without mutating packages or links: + +```bash +./install.sh --dry-run +``` + ### On NixOS The NixOS config uses `mkOutOfStoreSymlink` to point to this dotfiles repo: @@ -113,4 +132,6 @@ These files are sourced at the end of the main configs. - `gh copilot` - downloads the GitHub Copilot CLI via GitHub CLI - `pr-review` - cloned/updated at `~/Work/happyvertical/repos/pr-review` and added to `PATH` - HappyVertical agent workflows - `~/Work/happyvertical/repos/have-config/install.sh --live` +- `sops` / `age` / `gnupg` - local encrypted environment tooling where available +- `rclone` - WebDAV-capable client for OxiCloud where available - `rebuild` / `update` - Platform-specific rebuild command diff --git a/hv/README.md b/hv/README.md new file mode 100644 index 0000000..44006f3 --- /dev/null +++ b/hv/README.md @@ -0,0 +1,48 @@ +# HappyVertical Agent Resolver + +`scripts/hv-agent-resolver.py` composes agent behavior from four layers: + +1. `dotfiles` personal baseline (`hv/manifest.json`) +2. HappyVertical organization standards from `have-config` +3. optional Context Forge snapshot from `HV_CONTEXTFORGE_SNAPSHOT_DIR` +4. machine-local overrides from `~/.config/hv/overrides` + +Command and skill conflicts resolve in this order: + +`local override > Context Forge snapshot > have-config > dotfiles` + +AGENTS and CLAUDE docs are cumulative and assembled in layer order. + +## Local Overrides + +Local overrides are never rewritten by the installer. Use these conventions: + +- `~/.config/hv/overrides/skills//SKILL.md` +- `~/.config/hv/overrides/skills/codex//SKILL.md` +- `~/.config/hv/overrides/skills/claude//SKILL.md` +- `~/.config/hv/overrides/commands/claude/.md` +- `~/.config/hv/overrides/commands/codex/.md` +- `~/.config/hv/overrides/agent-docs/AGENTS.md` +- `~/.config/hv/overrides/agent-docs/CLAUDE.md` + +For more control, create `~/.config/hv/overrides/manifest.json` using the same +shape as `hv/manifest.json`. + +## Context Forge Snapshots + +The installer cannot call MCP prompts directly from shell. Export Context Forge +material into a local directory with a `manifest.json`, then set: + +```bash +export HV_CONTEXTFORGE_SNAPSHOT_DIR="$HOME/.config/hv/contextforge" +``` + +Rerun `./install.sh` to materialize the snapshot into local runtime files and +record hashes in `~/.config/hv/agent-lock.json`. + +## Local Environment + +If present, `~/.config/hv/env` is sourced by the installer before resolving +agent configuration. If `~/.config/hv/env.sops.env` exists, install decrypts it +with SOPS into `~/.config/hv/env` first. Keep real values local and use Warden +as the sharing standard. diff --git a/hv/contextforge-manifest.example.json b/hv/contextforge-manifest.example.json new file mode 100644 index 0000000..0c4b664 --- /dev/null +++ b/hv/contextforge-manifest.example.json @@ -0,0 +1,29 @@ +{ + "schema": "https://happyvertical.com/hv-agent-manifest/v1", + "layer": "contextforge", + "priority": 30, + "commands": [ + { + "agent": "claude", + "name": "review", + "path": "commands/claude/review.md", + "source_uri": "have://happyvertical/workflows/review" + } + ], + "skills": [ + { + "agent": "codex", + "name": "review", + "path": "skills/review", + "source_uri": "have://happyvertical/workflows/review" + } + ], + "agent_docs": [ + { + "id": "contextforge.dynamic-policy", + "targets": ["agents", "claude"], + "path": "agent-docs/dynamic-policy.md", + "source_uri": "have://happyvertical/agent-docs/dynamic-policy" + } + ] +} diff --git a/hv/manifest.json b/hv/manifest.json new file mode 100644 index 0000000..58fcde7 --- /dev/null +++ b/hv/manifest.json @@ -0,0 +1,35 @@ +{ + "schema": "https://happyvertical.com/hv-agent-manifest/v1", + "layer": "dotfiles", + "priority": 10, + "agent_docs": [ + { + "id": "dotfiles.codex-defaults", + "targets": ["agents", "codex"], + "path": ".codex/AGENTS.md" + } + ], + "skills": [ + { + "agent": "codex", + "name": "ship", + "path": ".agents/skills/ship", + "description": "Personal baseline shipping workflow" + }, + { + "agent": "codex", + "name": "resolve", + "path": ".agents/skills/resolve", + "description": "Personal baseline review-comment resolution workflow" + } + ], + "env_requirements": [ + { + "capability": "identity", + "vars": ["HV_AGENT_EMAIL"], + "default_enabled": false, + "description": "Per-user or per-agent HappyVertical account identity." + } + ], + "services": [] +} diff --git a/install.sh b/install.sh index a5cc553..0c5b194 100755 --- a/install.sh +++ b/install.sh @@ -6,6 +6,40 @@ set -e DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DRY_RUN=0 + +usage() { + cat <<'EOF' +Usage: ./install.sh [--dry-run|--audit] [-h|--help] + +Installs workstation and agent tooling, then resolves HappyVertical agent +configuration from dotfiles, have-config, Context Forge snapshots, and local +machine overrides. + +Options: + --dry-run, --audit Report what would be installed or resolved without + changing packages, symlinks, or generated agent files. + -h, --help Show this help. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run|--audit) + DRY_RUN=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done # ============================================================================== # Platform Detection @@ -29,6 +63,25 @@ detect_platform() { fi } +run_privileged() { + if [[ "$(id -u)" -eq 0 ]]; then + "$@" + return $? + fi + + if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then + sudo -n "$@" + return $? + fi + + echo "Skipping privileged command (sudo unavailable or requires a password): $*" + return 0 +} + +can_run_privileged() { + [[ "$(id -u)" -eq 0 ]] || (command -v sudo &> /dev/null && sudo -n true 2>/dev/null) +} + # ============================================================================== # Package Installation # ============================================================================== @@ -56,12 +109,6 @@ install_packages() { jq # json processor ) - # Cloud CLI tools - local cloud_packages=( - gh # GitHub CLI - awscli # AWS CLI (awscli2 on some distros) - ) - case "$PLATFORM" in macos) if ! command -v brew &> /dev/null; then @@ -92,11 +139,11 @@ install_packages() { linux) case "$DISTRO" in ubuntu|debian|pop) - sudo apt-get update - sudo apt-get install -y "${packages[@]}" + run_privileged apt-get update + run_privileged apt-get install -y "${packages[@]}" # Optional packages (some may not be in default repos) - sudo apt-get install -y zsh-autosuggestions zsh-syntax-highlighting 2>/dev/null || true - sudo apt-get install -y fzf bat ripgrep fd-find jq unzip 2>/dev/null || true + run_privileged apt-get install -y zsh-autosuggestions zsh-syntax-highlighting 2>/dev/null || true + run_privileged apt-get install -y fzf bat ripgrep fd-find jq unzip 2>/dev/null || true # Starship, zoxide need manual install on Debian/Ubuntu install_starship install_zoxide @@ -106,9 +153,9 @@ install_packages() { install_gcloud ;; fedora|rhel|centos) - sudo dnf install -y "${packages[@]}" - sudo dnf install -y zsh-autosuggestions zsh-syntax-highlighting 2>/dev/null || true - sudo dnf install -y fzf bat ripgrep fd-find jq eza unzip 2>/dev/null || true + run_privileged dnf install -y "${packages[@]}" + run_privileged dnf install -y zsh-autosuggestions zsh-syntax-highlighting 2>/dev/null || true + run_privileged dnf install -y fzf bat ripgrep fd-find jq eza unzip 2>/dev/null || true install_starship install_zoxide # Cloud CLI tools @@ -117,21 +164,21 @@ install_packages() { install_gcloud ;; alpine) - sudo apk add "${packages[@]}" - sudo apk add zsh-autosuggestions zsh-syntax-highlighting 2>/dev/null || true - sudo apk add fzf bat ripgrep fd jq unzip 2>/dev/null || true + run_privileged apk add "${packages[@]}" + run_privileged apk add zsh-autosuggestions zsh-syntax-highlighting 2>/dev/null || true + run_privileged apk add fzf bat ripgrep fd jq unzip 2>/dev/null || true install_starship install_zoxide # Cloud CLI tools - sudo apk add github-cli aws-cli 2>/dev/null || true + run_privileged apk add github-cli aws-cli 2>/dev/null || true install_gcloud ;; arch|manjaro) - sudo pacman -S --noconfirm "${packages[@]}" - sudo pacman -S --noconfirm zsh-autosuggestions zsh-syntax-highlighting 2>/dev/null || true - sudo pacman -S --noconfirm starship zoxide fzf bat eza ripgrep fd jq direnv 2>/dev/null || true + run_privileged pacman -S --noconfirm "${packages[@]}" + run_privileged pacman -S --noconfirm zsh-autosuggestions zsh-syntax-highlighting 2>/dev/null || true + run_privileged pacman -S --noconfirm starship zoxide fzf bat eza ripgrep fd jq direnv 2>/dev/null || true # Cloud CLI tools - sudo pacman -S --noconfirm github-cli aws-cli 2>/dev/null || true + run_privileged pacman -S --noconfirm github-cli aws-cli 2>/dev/null || true install_gcloud ;; nixos) @@ -146,6 +193,158 @@ install_packages() { esac } +install_sops_tools() { + echo "Installing SOPS tooling..." + + case "$PLATFORM" in + macos) + brew install sops age gnupg 2>/dev/null || true + ;; + linux) + case "$DISTRO" in + ubuntu|debian|pop) + run_privileged apt-get install -y age gnupg 2>/dev/null || true + run_privileged apt-get install -y sops 2>/dev/null || true + ;; + fedora|rhel|centos) + run_privileged dnf install -y age gnupg2 sops 2>/dev/null || true + ;; + alpine) + run_privileged apk add age gnupg sops 2>/dev/null || true + ;; + arch|manjaro) + run_privileged pacman -S --noconfirm age gnupg sops 2>/dev/null || true + ;; + nixos) + echo "NixOS detected - SOPS tooling should be managed by Nix" + ;; + esac + ;; + esac + + if command -v sops &> /dev/null; then + echo "SOPS available: $(command -v sops)" + else + echo "SOPS not found after package install; configure it via your platform package manager." + fi +} + +process_sops_env() { + local hv_config_dir="${HV_CONFIG_DIR:-$HOME/.config/hv}" + local sops_file="${HV_SOPS_ENV_FILE:-$hv_config_dir/env.sops.env}" + local env_file="${HV_ENV_FILE:-$hv_config_dir/env}" + local example_file="$hv_config_dir/env.example" + + mkdir -p "$hv_config_dir" + + if [[ ! -f "$example_file" ]]; then + cat > "$example_file" <<'EOF' +# HappyVertical local environment template. +# Copy values from Warden or your machine-local secret store. +# +# HV_AGENT_EMAIL=agent@example.com +# HV_ENABLED_CAPABILITIES=happyvertical-identity +# HV_CONTEXTFORGE_SNAPSHOT_DIR=$HOME/.config/hv/contextforge +EOF + fi + + if [[ -f "$sops_file" ]]; then + if ! command -v sops &> /dev/null; then + echo "SOPS env file exists at $sops_file but sops is not installed." + return 1 + fi + + echo "Decrypting local SOPS environment file..." + local tmp_file + tmp_file="$(mktemp)" + sops -d "$sops_file" > "$tmp_file" + chmod 600 "$tmp_file" + mv "$tmp_file" "$env_file" + echo "Wrote local environment file: $env_file" + else + echo "No local SOPS environment file at $sops_file; leaving secrets to Warden/local env." + fi +} + +load_hv_env() { + local hv_config_dir="${HV_CONFIG_DIR:-$HOME/.config/hv}" + local env_file="${HV_ENV_FILE:-$hv_config_dir/env}" + + if [[ -f "$env_file" ]]; then + echo "Loading HappyVertical local environment: $env_file" + set -a + # shellcheck disable=SC1090 + . "$env_file" + set +a + fi +} + +install_report_path() { + local hv_config_dir="${HV_CONFIG_DIR:-$HOME/.config/hv}" + echo "${HV_INSTALL_REPORT:-$hv_config_dir/install-report.md}" +} + +append_tool_audit() { + local report_path + report_path="$(install_report_path)" + mkdir -p "$(dirname "$report_path")" + + { + echo + echo "## Bootstrap Tool Audit" + echo + echo "- Platform: ${PLATFORM:-unknown}${DISTRO:+/$DISTRO}" + echo "- Package mutation: $([[ "$DRY_RUN" -eq 1 ]] && echo "skipped by dry-run" || echo "attempted where supported")" + echo + echo "| Tool | Status |" + echo "| --- | --- |" + for tool in git zsh stow python3 sops age gpg rclone pr-review claude codex gh aws gcloud; do + if command -v "$tool" &> /dev/null; then + echo "| \`$tool\` | available at \`$(command -v "$tool")\` |" + elif [[ "$DRY_RUN" -eq 1 ]]; then + echo "| \`$tool\` | skipped by dry-run |" + else + echo "| \`$tool\` | not available after install attempt |" + fi + done + } >> "$report_path" +} + +install_service_clis() { + echo "Installing service CLIs..." + + case "$PLATFORM" in + macos) + brew install rclone 2>/dev/null || true + ;; + linux) + case "$DISTRO" in + ubuntu|debian|pop) + run_privileged apt-get install -y rclone 2>/dev/null || true + ;; + fedora|rhel|centos) + run_privileged dnf install -y rclone 2>/dev/null || true + ;; + alpine) + run_privileged apk add rclone 2>/dev/null || true + ;; + arch|manjaro) + run_privileged pacman -S --noconfirm rclone 2>/dev/null || true + ;; + nixos) + echo "NixOS detected - service CLIs should be managed by Nix" + ;; + esac + ;; + esac + + if command -v rclone &> /dev/null; then + echo "OxiCloud/WebDAV CLI available: $(command -v rclone)" + else + echo "rclone not found; OxiCloud CLI support will be documented but not configured." + fi +} + install_starship() { if command -v starship &> /dev/null; then echo "Starship already installed" @@ -184,7 +383,7 @@ install_awscli() { if [[ "$PLATFORM" == "linux" ]]; then curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "/tmp/awscliv2.zip" unzip -q /tmp/awscliv2.zip -d /tmp - sudo /tmp/aws/install + run_privileged /tmp/aws/install rm -rf /tmp/awscliv2.zip /tmp/aws fi } @@ -197,12 +396,16 @@ install_gh() { echo "Installing GitHub CLI..." case "$DISTRO" in ubuntu|debian|pop) - curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null - sudo apt-get update && sudo apt-get install -y gh + if can_run_privileged; then + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | run_privileged dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | run_privileged tee /etc/apt/sources.list.d/github-cli.list > /dev/null + run_privileged apt-get update && run_privileged apt-get install -y gh + else + echo "Skipping gh apt setup; root/sudo is not available noninteractively." + fi ;; fedora|rhel|centos) - sudo dnf install -y gh 2>/dev/null || true + run_privileged dnf install -y gh 2>/dev/null || true ;; *) echo "Please install gh manually: https://cli.github.com/" @@ -223,17 +426,17 @@ ensure_npm() { linux) case "$DISTRO" in ubuntu|debian|pop) - sudo apt-get update - sudo apt-get install -y nodejs npm + run_privileged apt-get update + run_privileged apt-get install -y nodejs npm ;; fedora|rhel|centos) - sudo dnf install -y nodejs npm + run_privileged dnf install -y nodejs npm ;; alpine) - sudo apk add nodejs npm + run_privileged apk add nodejs npm ;; arch|manjaro) - sudo pacman -S --noconfirm nodejs npm + run_privileged pacman -S --noconfirm nodejs npm ;; nixos) echo "NixOS detected - Node.js and npm should be managed by Nix" @@ -250,6 +453,11 @@ ensure_npm() { return 1 ;; esac + + if ! command -v npm &> /dev/null; then + echo "npm still not available after install attempt" + return 1 + fi } ensure_agent_paths() { @@ -496,11 +704,22 @@ install_have_config() { local repo_url="${HAVE_CONFIG_REPO_URL:-git@github.com:happyvertical/have-config.git}" local fallback_repo_url="https://github.com/happyvertical/have-config.git" local install_args=() + HAVE_CONFIG_DIR_RESOLVED="$have_config_dir" if [[ "${HAVE_CONFIG_LIVE:-1}" != "0" ]]; then install_args+=(--live) fi + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "Dry-run: would install/update have-config at $have_config_dir" + if [[ -x "$have_config_dir/install.sh" ]]; then + (cd "$have_config_dir" && ./install.sh --dry-run) + else + echo "Dry-run: have-config installer not found at $have_config_dir/install.sh" + fi + return 0 + fi + if [[ -d "$have_config_dir/.git" ]]; then echo "Updating have-config..." if ! git -C "$have_config_dir" pull --ff-only --quiet; then @@ -531,52 +750,24 @@ install_have_config() { (cd "$have_config_dir" && ./install.sh "${install_args[@]}") } -link_managed_path() { - local source="$1" - local target="$2" - local relative="${target#$HOME/}" - - if [[ -L "$target" ]]; then - local current - current="$(readlink "$target")" - if [[ "$current" == "$source" ]]; then - echo " Already linked: $relative" - return 0 - fi - fi - - if [[ -e "$target" || -L "$target" ]]; then - if [[ -f "$source" && -f "$target" ]] && cmp -s "$source" "$target"; then - rm "$target" - else - local backup_dir="$HOME/.dotfiles-backup-$(date +%Y%m%d-%H%M%S)" - local backup_path="$backup_dir/$relative" - mkdir -p "$(dirname "$backup_path")" - mv "$target" "$backup_path" - echo " Backed up existing: $relative -> $backup_path" - fi +run_agent_resolver() { + if ! command -v python3 &> /dev/null; then + echo "python3 not found; cannot resolve HappyVertical agent configuration" + return 1 fi - mkdir -p "$(dirname "$target")" - ln -s "$source" "$target" - echo " Linked: $relative" -} - -install_agent_configs() { - echo "Installing agent configs..." + local have_config_dir="${HAVE_CONFIG_DIR_RESOLVED:-${HAVE_CONFIG_DIR:-$HOME/Work/happyvertical/repos/have-config}}" + local args=( + --dotfiles-dir "$DOTFILES_DIR" + --have-config-dir "$have_config_dir" + ) - if [[ -d "$DOTFILES_DIR/.agents/skills" ]]; then - mkdir -p "$HOME/.agents/skills" - for skill_dir in "$DOTFILES_DIR"/.agents/skills/*; do - [[ -d "$skill_dir" ]] || continue - link_managed_path "$skill_dir" "$HOME/.agents/skills/$(basename "$skill_dir")" - done + if [[ "$DRY_RUN" -eq 1 ]]; then + args+=(--dry-run) fi - if [[ -f "$DOTFILES_DIR/.codex/AGENTS.md" ]]; then - mkdir -p "$HOME/.codex" - link_managed_path "$DOTFILES_DIR/.codex/AGENTS.md" "$HOME/.codex/AGENTS.md" - fi + echo "Resolving HappyVertical agent configuration..." + python3 "$DOTFILES_DIR/scripts/hv-agent-resolver.py" "${args[@]}" } install_gemini_cli() { @@ -779,6 +970,11 @@ set_default_shell() { return 0 fi + if [[ "${HV_NONINTERACTIVE:-1}" == "1" || ! -t 0 ]]; then + echo "Skipping default shell prompt (noninteractive install)." + return 0 + fi + local zsh_path zsh_path=$(which zsh) @@ -809,10 +1005,30 @@ main() { echo "Platform: $PLATFORM" [[ -n "$DISTRO" ]] && echo "Distro: $DISTRO" echo "Dotfiles directory: $DOTFILES_DIR" + [[ "$DRY_RUN" -eq 1 ]] && echo "Mode: dry-run" echo + load_hv_env + + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "Dry-run: skipping package, CLI, shell, and stow mutations." + install_have_config + echo + run_agent_resolver + append_tool_audit + echo + echo "========================================" + echo "Dry-run complete!" + echo "========================================" + return 0 + fi + # Install packages install_packages + install_sops_tools + process_sops_env + load_hv_env + install_service_clis echo # Install AI CLI tools @@ -827,8 +1043,9 @@ main() { install_ralph echo - # Install cross-agent skills and Codex defaults managed by this repo. - install_agent_configs + # Compose and install cross-agent skills/docs from all configured layers. + run_agent_resolver + append_tool_audit echo # Install Oh My Zsh diff --git a/scripts/hv-agent-resolver.py b/scripts/hv-agent-resolver.py new file mode 100755 index 0000000..678421e --- /dev/null +++ b/scripts/hv-agent-resolver.py @@ -0,0 +1,770 @@ +#!/usr/bin/env python3 +"""Resolve HappyVertical agent definitions into local generated files. + +The resolver composes four layers: + +1. dotfiles personal baseline +2. have-config organization standard +3. Context Forge install-time snapshot +4. machine-local overrides + +Commands and skills use winner-takes-all resolution by layer priority. Agent +documents are cumulative and assembled in layer order. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import sys +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +LAYER_PRIORITIES = { + "dotfiles": 10, + "have-config": 20, + "contextforge": 30, + "local": 40, +} + +TARGETS = { + "agents": "AGENTS.md", + "codex": "AGENTS.md", + "claude": "CLAUDE.md", +} + + +@dataclass(frozen=True) +class SourceLayer: + name: str + root: Path + manifest: Path | None + priority: int + available: bool + notes: list[str] = field(default_factory=list) + + +@dataclass +class Candidate: + kind: str + agent: str + name: str + layer: str + priority: int + source: str + path: Path | None = None + content: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + @property + def key(self) -> str: + return f"{self.agent}:{self.kind}:{self.name}" + + def digest(self) -> str: + if self.content is not None: + return sha256_text(self.content) + if self.path is None: + return sha256_text("") + return sha256_path(self.path) + + +@dataclass +class DocSnippet: + snippet_id: str + targets: list[str] + layer: str + priority: int + source: str + path: Path | None = None + content: str | None = None + + def read(self) -> str: + if self.content is not None: + return self.content.rstrip() + "\n" + if self.path is None: + return "" + return self.path.read_text(encoding="utf-8").rstrip() + "\n" + + def digest(self) -> str: + if self.content is not None: + return sha256_text(self.content) + if self.path is None: + return sha256_text("") + return sha256_path(self.path) + + +def sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def sha256_path(path: Path) -> str: + if path.is_file(): + return hashlib.sha256(path.read_bytes()).hexdigest() + + digest = hashlib.sha256() + if path.is_dir(): + for child in sorted(p for p in path.rglob("*") if p.is_file()): + digest.update(str(child.relative_to(path)).encode("utf-8")) + digest.update(b"\0") + digest.update(child.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + +def load_json(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as f: + return json.load(f) + + +def resolve_path(root: Path, value: str | None) -> Path | None: + if not value: + return None + raw = Path(os.path.expandvars(os.path.expanduser(value))) + if raw.is_absolute(): + return raw + return root / raw + + +def source_label(layer: str, root: Path, path: Path | None, content: str | None) -> str: + if path is None: + return f"{layer}:inline:{sha256_text(content or '')[:12]}" + try: + rel = path.relative_to(root) + return f"{layer}:{rel}" + except ValueError: + return f"{layer}:{path}" + + +def expand_agents(agent: str, kind: str) -> list[str]: + if agent != "all": + return [agent] + if kind == "command": + return ["claude", "codex"] + return ["codex"] + + +def doc_target_matches(target: str, snippet_targets: list[str]) -> bool: + if "all" in snippet_targets: + return True + if target == "agents": + return "agents" in snippet_targets or "codex" in snippet_targets + if target == "codex": + return "codex" in snippet_targets or "agents" in snippet_targets + return target in snippet_targets + + +def manifest_layer(name: str, root: Path, manifest_name: str, default_priority: int) -> SourceLayer: + manifest = root / manifest_name + if manifest.exists(): + data = load_json(manifest) + notes: list[str] = [] + declared_priority = data.get("priority") + if declared_priority is not None and int(declared_priority) != default_priority: + notes.append(f"declared priority {declared_priority} ignored; using fixed {default_priority}") + return SourceLayer(name, root, manifest, default_priority, True, notes) + if name == "local" and root.exists(): + return SourceLayer(name, root, None, default_priority, True, [f"no {manifest}; using convention-based overrides"]) + return SourceLayer(name, root, None, default_priority, False, [f"missing {manifest}"]) + + +def collect_manifest(layer: SourceLayer) -> tuple[list[Candidate], list[DocSnippet], list[dict[str, Any]], list[dict[str, Any]]]: + if not layer.available or layer.manifest is None: + return [], [], [], [] + + data = load_json(layer.manifest) + root = layer.root + priority = layer.priority + layer_name = layer.name + + candidates: list[Candidate] = [] + docs: list[DocSnippet] = [] + + for item in data.get("skills", []): + path = resolve_path(root, item.get("path")) + content = item.get("content") + name = item["name"] + agent = item.get("agent", "codex") + for expanded_agent in expand_agents(agent, "skill"): + candidates.append( + Candidate( + kind="skill", + agent=expanded_agent, + name=name, + layer=layer_name, + priority=priority, + path=path, + content=content, + source=source_label(layer_name, root, path, content), + metadata={k: v for k, v in item.items() if k not in {"path", "content"}}, + ) + ) + + for item in data.get("commands", []): + path = resolve_path(root, item.get("path")) + content = item.get("content") + name = item["name"] + agent = item.get("agent", "all") + for expanded_agent in expand_agents(agent, "command"): + candidates.append( + Candidate( + kind="command", + agent=expanded_agent, + name=name, + layer=layer_name, + priority=priority, + path=path, + content=content, + source=source_label(layer_name, root, path, content), + metadata={k: v for k, v in item.items() if k not in {"path", "content"}}, + ) + ) + + for item in data.get("agent_docs", []): + path = resolve_path(root, item.get("path")) + content = item.get("content") + docs.append( + DocSnippet( + snippet_id=item["id"], + targets=list(item.get("targets", ["agents"])), + layer=layer_name, + priority=priority, + path=path, + content=content, + source=source_label(layer_name, root, path, content), + ) + ) + + return candidates, docs, data.get("env_requirements", []), data.get("services", []) + + +def collect_local_conventions(root: Path, priority: int) -> tuple[list[Candidate], list[DocSnippet]]: + candidates: list[Candidate] = [] + docs: list[DocSnippet] = [] + + skill_roots = [ + ("codex", root / "skills"), + ("codex", root / "skills" / "codex"), + ("claude", root / "skills" / "claude"), + ] + for agent, skill_root in skill_roots: + if not skill_root.is_dir(): + continue + for skill_dir in sorted(p for p in skill_root.iterdir() if p.is_dir()): + if (skill_dir / "SKILL.md").exists(): + candidates.append( + Candidate( + kind="skill", + agent=agent, + name=skill_dir.name, + layer="local", + priority=priority, + path=skill_dir, + source=source_label("local", root, skill_dir, None), + ) + ) + + commands_root = root / "commands" + for agent in ["claude", "codex"]: + agent_root = commands_root / agent + if not agent_root.is_dir(): + continue + for command_file in sorted(agent_root.glob("*.md")): + candidates.append( + Candidate( + kind="command", + agent=agent, + name=command_file.stem, + layer="local", + priority=priority, + path=command_file, + source=source_label("local", root, command_file, None), + ) + ) + + doc_root = root / "agent-docs" + doc_map = [ + ("local.agents", ["agents", "codex"], doc_root / "AGENTS.md"), + ("local.claude", ["claude"], doc_root / "CLAUDE.md"), + ] + for snippet_id, targets, path in doc_map: + if path.exists(): + docs.append( + DocSnippet( + snippet_id=snippet_id, + targets=targets, + layer="local", + priority=priority, + path=path, + source=source_label("local", root, path, None), + ) + ) + + return candidates, docs + + +def ensure_parent(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def replace_tree(src: Path, dest: Path) -> None: + if dest.exists() or dest.is_symlink(): + if dest.is_dir() and not dest.is_symlink(): + shutil.rmtree(dest) + else: + dest.unlink() + if src.is_dir(): + shutil.copytree(src, dest, symlinks=True) + else: + ensure_parent(dest) + shutil.copy2(src, dest) + + +def write_candidate(candidate: Candidate, dest: Path) -> None: + if candidate.content is not None: + if dest.suffix: + ensure_parent(dest) + dest.write_text(candidate.content.rstrip() + "\n", encoding="utf-8") + else: + dest.mkdir(parents=True, exist_ok=True) + (dest / "SKILL.md").write_text(candidate.content.rstrip() + "\n", encoding="utf-8") + return + if candidate.path is None: + return + replace_tree(candidate.path, dest) + + +def is_managed_target(path: Path, generated_root: Path, repo_roots: list[Path]) -> bool: + if not path.exists() and not path.is_symlink(): + return True + if not path.is_symlink(): + return False + target = Path(os.readlink(path)) + if not target.is_absolute(): + target = (path.parent / target).resolve() + try: + target.resolve().relative_to(generated_root.resolve()) + return True + except ValueError: + pass + for root in repo_roots: + try: + target.resolve().relative_to(root.resolve()) + return True + except ValueError: + continue + parts = set(target.parts) + if ".agents" in parts and "skills" in parts: + return True + if target.name == "AGENTS.md" and ".codex" in parts: + return True + if target.name == "CLAUDE.md" and ".claude" in parts: + return True + if "commands" in parts and (".claude" in parts or ".codex" in parts): + return True + return False + + +def link_target(src: Path, target: Path, generated_root: Path, repo_roots: list[Path], dry_run: bool, report: list[str]) -> None: + if not is_managed_target(target, generated_root, repo_roots): + report.append(f"- blocked managed link `{target}`; existing file is not managed by hv") + return + if dry_run: + report.append(f"- would link `{target}` -> `{src}`") + return + ensure_parent(target) + if target.exists() or target.is_symlink(): + if target.is_dir() and not target.is_symlink(): + shutil.rmtree(target) + else: + target.unlink() + target.symlink_to(src) + + +def resolve_candidates(candidates: list[Candidate]) -> dict[str, list[Candidate]]: + by_key: dict[str, list[Candidate]] = {} + for candidate in candidates: + by_key.setdefault(candidate.key, []).append(candidate) + for items in by_key.values(): + items.sort(key=lambda c: (c.priority, c.layer, c.source)) + return by_key + + +def selected_candidate(items: list[Candidate]) -> Candidate: + return sorted(items, key=lambda c: (c.priority, c.layer, c.source))[-1] + + +def candidate_record(candidate: Candidate) -> dict[str, Any]: + return { + "kind": candidate.kind, + "agent": candidate.agent, + "name": candidate.name, + "layer": candidate.layer, + "priority": candidate.priority, + "source": candidate.source, + "sha256": candidate.digest(), + "metadata": candidate.metadata, + } + + +def parse_enabled_capabilities(value: str | None, defaults: list[str]) -> set[str]: + enabled = {item.strip() for item in defaults if item.strip()} + if value: + enabled.update(item.strip() for item in value.split(",") if item.strip()) + return enabled + + +def validate_env(requirements: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + defaults = [ + req["capability"] + for req in requirements + if req.get("default_enabled") is True and req.get("capability") + ] + enabled = parse_enabled_capabilities(os.environ.get("HV_ENABLED_CAPABILITIES"), defaults) + if "all" in enabled: + enabled.update(req.get("capability", "") for req in requirements) + + checked: list[dict[str, Any]] = [] + missing: list[dict[str, Any]] = [] + for req in requirements: + capability = req.get("capability") + vars_required = list(req.get("vars", [])) + if not capability or capability not in enabled: + continue + absent = [name for name in vars_required if not os.environ.get(name)] + record = { + "capability": capability, + "vars": vars_required, + "missing": absent, + "source": req.get("source"), + } + checked.append(record) + if absent: + missing.append(record) + return checked, missing + + +def assemble_doc(target: str, snippets: list[DocSnippet]) -> str: + title = "Global Agent Instructions" if target in {"agents", "codex"} else "Global Claude Instructions" + lines = [ + f"# {title}", + "", + "", + "", + ] + for snippet in sorted(snippets, key=lambda s: (s.priority, s.layer, s.snippet_id)): + if not doc_target_matches(target, snippet.targets): + continue + lines.extend( + [ + f"", + snippet.read().rstrip(), + "", + ] + ) + return "\n".join(lines).rstrip() + "\n" + + +def doc_conflicts(content: str) -> list[str]: + must: set[str] = set() + must_not: set[str] = set() + for line in content.splitlines(): + cleaned = line.strip().lower().strip("-* ") + if "must not " in cleaned: + must_not.add(cleaned.split("must not ", 1)[1]) + elif "must " in cleaned: + must.add(cleaned.split("must ", 1)[1]) + return sorted(must.intersection(must_not)) + + +def write_report( + path: Path, + layers: list[SourceLayer], + resolved: dict[str, list[Candidate]], + doc_outputs: dict[str, str], + env_checked: list[dict[str, Any]], + env_missing: list[dict[str, Any]], + services: list[dict[str, Any]], + link_report: list[str], + dry_run: bool, +) -> None: + lines = [ + "# HappyVertical Agent Install Report", + "", + f"- Generated: {datetime.now(timezone.utc).isoformat()}", + f"- Mode: {'dry-run' if dry_run else 'install'}", + "", + "## Source Layers", + "", + ] + for layer in layers: + status = "available" if layer.available else "missing" + lines.append(f"- `{layer.name}` priority {layer.priority}: {status} at `{layer.root}`") + for note in layer.notes: + lines.append(f" - {note}") + + lines.extend(["", "## Resolved Commands And Skills", ""]) + for key in sorted(resolved): + items = resolved[key] + winner = selected_candidate(items) + lines.append(f"- `{key}` -> `{winner.source}` ({winner.layer})") + for item in items: + if item is winner: + continue + lines.append(f" - overrides `{item.source}` ({item.layer})") + + lines.extend(["", "## Agent Docs", ""]) + for target, content in doc_outputs.items(): + conflicts = doc_conflicts(content) + lines.append(f"- `{target}` generated ({len(content.splitlines())} lines)") + for conflict in conflicts: + lines.append(f" - potential must/must-not conflict: `{conflict}`") + + lines.extend(["", "## Environment Requirements", ""]) + if not env_checked: + lines.append("- No enabled capabilities required env validation.") + for item in env_checked: + if item["missing"]: + lines.append(f"- `{item['capability']}` missing: {', '.join(item['missing'])}") + else: + lines.append(f"- `{item['capability']}` satisfied.") + + lines.extend(["", "## Services", ""]) + if not services: + lines.append("- No service registry entries found.") + for service in services: + cli = service.get("cli", {}) + status = cli.get("status", "documented") + lines.append(f"- `{service.get('id')}` {service.get('url', '')} CLI: {status}") + + if link_report: + lines.extend(["", "## Managed Links", ""]) + lines.extend(link_report) + + ensure_parent(path) + path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + + +def write_lock( + path: Path, + layers: list[SourceLayer], + resolved: dict[str, list[Candidate]], + docs: list[DocSnippet], + env_checked: list[dict[str, Any]], + services: list[dict[str, Any]], +) -> None: + data = { + "schema": "https://happyvertical.com/hv-agent-lock/v1", + "generated_at": datetime.now(timezone.utc).isoformat(), + "layers": [ + { + "name": layer.name, + "root": str(layer.root), + "manifest": str(layer.manifest) if layer.manifest else None, + "priority": layer.priority, + "available": layer.available, + } + for layer in layers + ], + "definitions": [], + "docs": [ + { + "id": doc.snippet_id, + "targets": doc.targets, + "layer": doc.layer, + "priority": doc.priority, + "source": doc.source, + "sha256": doc.digest(), + } + for doc in sorted(docs, key=lambda d: (d.priority, d.layer, d.snippet_id)) + ], + "env": env_checked, + "services": services, + } + for key in sorted(resolved): + items = resolved[key] + winner = selected_candidate(items) + data["definitions"].append( + { + "key": key, + "winner": candidate_record(winner), + "candidates": [candidate_record(item) for item in items], + } + ) + ensure_parent(path) + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def materialize( + resolved: dict[str, list[Candidate]], + doc_outputs: dict[str, str], + output_dir: Path, + home_dir: Path, + repo_roots: list[Path], + dry_run: bool, +) -> list[str]: + report: list[str] = [] + if not dry_run: + output_dir.mkdir(parents=True, exist_ok=True) + for child in ["skills", "commands"]: + target = output_dir / child + if target.exists(): + shutil.rmtree(target) + + for key in sorted(resolved): + winner = selected_candidate(resolved[key]) + if winner.kind == "skill": + dest = output_dir / "skills" / winner.name + if not dry_run: + write_candidate(winner, dest) + link_target(dest, home_dir / ".agents" / "skills" / winner.name, output_dir, repo_roots, dry_run, report) + elif winner.kind == "command": + suffix = ".md" if winner.path is None or winner.path.is_file() else "" + dest = output_dir / "commands" / winner.agent / f"{winner.name}{suffix}" + if not dry_run: + write_candidate(winner, dest) + if winner.agent == "claude": + link_target(dest, home_dir / ".claude" / "commands" / f"{winner.name}.md", output_dir, repo_roots, dry_run, report) + elif winner.agent == "codex": + link_target(dest, home_dir / ".codex" / "commands" / f"{winner.name}.md", output_dir, repo_roots, dry_run, report) + + for target, content in doc_outputs.items(): + filename = TARGETS[target] + dest = output_dir / "docs" / target / filename + if not dry_run: + ensure_parent(dest) + dest.write_text(content, encoding="utf-8") + if target in {"agents", "codex"}: + link_target(dest, home_dir / ".codex" / "AGENTS.md", output_dir, repo_roots, dry_run, report) + if target == "claude": + link_target(dest, home_dir / ".claude" / "CLAUDE.md", output_dir, repo_roots, dry_run, report) + return report + + +def ensure_local_override_templates(local_dir: Path, dry_run: bool) -> list[str]: + report: list[str] = [] + directories = [ + local_dir / "skills", + local_dir / "skills" / "codex", + local_dir / "skills" / "claude", + local_dir / "commands" / "claude", + local_dir / "commands" / "codex", + local_dir / "agent-docs", + ] + readme = local_dir / "README.md" + + if dry_run: + report.append(f"- would ensure local override directories under `{local_dir}`") + return report + + for directory in directories: + directory.mkdir(parents=True, exist_ok=True) + + if not readme.exists(): + readme.write_text( + "\n".join( + [ + "# HappyVertical Local Overrides", + "", + "Files in this directory are machine-local and are never overwritten by", + "the dotfiles installer.", + "", + "- `skills//SKILL.md` overrides Codex skills.", + "- `commands/claude/.md` overrides Claude commands.", + "- `commands/codex/.md` overrides Codex commands.", + "- `agent-docs/AGENTS.md` and `agent-docs/CLAUDE.md` are appended", + " to generated global instructions.", + "", + "Local overrides win over Context Forge snapshots, have-config, and", + "dotfiles. Keep them intentional and review the install report after", + "each update.", + "", + ] + ), + encoding="utf-8", + ) + return report + + +def main() -> int: + hv_config_dir = os.environ.get("HV_CONFIG_DIR", "~/.config/hv") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dotfiles-dir", default=os.getcwd()) + parser.add_argument("--have-config-dir", default=os.environ.get("HAVE_CONFIG_DIR", "~/Work/happyvertical/repos/have-config")) + parser.add_argument("--contextforge-dir", default=os.environ.get("HV_CONTEXTFORGE_SNAPSHOT_DIR", f"{hv_config_dir}/contextforge")) + parser.add_argument("--local-overrides-dir", default=os.environ.get("HV_LOCAL_OVERRIDES_DIR", f"{hv_config_dir}/overrides")) + parser.add_argument("--output-dir", default=os.environ.get("HV_GENERATED_DIR", f"{hv_config_dir}/generated")) + parser.add_argument("--home-dir", default="~") + parser.add_argument("--lock-path", default=os.environ.get("HV_AGENT_LOCK", f"{hv_config_dir}/agent-lock.json")) + parser.add_argument("--report-path", default=os.environ.get("HV_INSTALL_REPORT", f"{hv_config_dir}/install-report.md")) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + dotfiles = Path(args.dotfiles_dir).expanduser().resolve() + have_config = Path(args.have_config_dir).expanduser().resolve() + contextforge = Path(args.contextforge_dir).expanduser().resolve() + local = Path(args.local_overrides_dir).expanduser().resolve() + output_dir = Path(args.output_dir).expanduser().resolve() + home_dir = Path(args.home_dir).expanduser().resolve() + lock_path = Path(args.lock_path).expanduser().resolve() + report_path = Path(args.report_path).expanduser().resolve() + + link_report = ensure_local_override_templates(local, args.dry_run) + + layers = [ + manifest_layer("dotfiles", dotfiles, "hv/manifest.json", LAYER_PRIORITIES["dotfiles"]), + manifest_layer("have-config", have_config, "hv/manifest.json", LAYER_PRIORITIES["have-config"]), + manifest_layer("contextforge", contextforge, "manifest.json", LAYER_PRIORITIES["contextforge"]), + manifest_layer("local", local, "manifest.json", LAYER_PRIORITIES["local"]), + ] + + candidates: list[Candidate] = [] + docs: list[DocSnippet] = [] + env_requirements: list[dict[str, Any]] = [] + services: list[dict[str, Any]] = [] + + for layer in layers: + layer_candidates, layer_docs, layer_env, layer_services = collect_manifest(layer) + candidates.extend(layer_candidates) + docs.extend(layer_docs) + env_requirements.extend({**item, "source": layer.name} for item in layer_env) + services.extend({**item, "source": layer.name} for item in layer_services) + + local_candidates, local_docs = collect_local_conventions(local, LAYER_PRIORITIES["local"]) + candidates.extend(local_candidates) + docs.extend(local_docs) + + resolved = resolve_candidates(candidates) + env_checked, env_missing = validate_env(env_requirements) + doc_outputs = { + target: assemble_doc(target, docs) + for target in ["agents", "claude"] + if any(doc_target_matches(target, snippet.targets) for snippet in docs) + } + + repo_roots = [dotfiles, have_config] + link_report.extend(materialize(resolved, doc_outputs, output_dir, home_dir, repo_roots, args.dry_run)) + + if not args.dry_run: + write_lock(lock_path, layers, resolved, docs, env_checked, services) + write_report(report_path, layers, resolved, doc_outputs, env_checked, env_missing, services, link_report, args.dry_run) + + print(f"HappyVertical agent report: {report_path}") + if not args.dry_run: + print(f"HappyVertical agent lock: {lock_path}") + + if env_missing: + print("Missing required environment variables for enabled capabilities:", file=sys.stderr) + for item in env_missing: + print(f" {item['capability']}: {', '.join(item['missing'])}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-hv-agent-resolver.sh b/scripts/test-hv-agent-resolver.sh new file mode 100755 index 0000000..01c2587 --- /dev/null +++ b/scripts/test-hv-agent-resolver.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +DOTFILES_DIR="$TMP_DIR/dotfiles" +HAVE_CONFIG_DIR="$TMP_DIR/have-config" +CONTEXTFORGE_DIR="$TMP_DIR/contextforge" +LOCAL_DIR="$TMP_DIR/overrides" +HOME_DIR="$TMP_DIR/home" +OUTPUT_DIR="$TMP_DIR/generated" +LOCK_PATH="$TMP_DIR/agent-lock.json" +REPORT_PATH="$TMP_DIR/install-report.md" + +mkdir -p "$DOTFILES_DIR/hv" "$HAVE_CONFIG_DIR/hv" "$CONTEXTFORGE_DIR" \ + "$LOCAL_DIR/commands/codex" "$LOCAL_DIR/skills/codex/ship" "$LOCAL_DIR/agent-docs" "$HOME_DIR" + +cat > "$DOTFILES_DIR/hv/manifest.json" <<'JSON' +{ + "schema": "https://happyvertical.com/hv-agent-manifest/v1", + "layer": "dotfiles", + "priority": 10, + "commands": [ + { + "agent": "all", + "name": "review", + "content": "dotfiles review" + } + ], + "skills": [ + { + "agent": "codex", + "name": "ship", + "content": "dotfiles ship" + } + ], + "agent_docs": [ + { + "id": "dotfiles.test", + "targets": ["agents"], + "content": "Agents must use fixture-order." + } + ] +} +JSON + +cat > "$HAVE_CONFIG_DIR/hv/manifest.json" <<'JSON' +{ + "schema": "https://happyvertical.com/hv-agent-manifest/v1", + "layer": "have-config", + "priority": 20, + "commands": [ + { + "agent": "all", + "name": "review", + "content": "have-config review" + } + ], + "skills": [ + { + "agent": "codex", + "name": "ship", + "content": "have-config ship" + } + ], + "env_requirements": [ + { + "capability": "identity", + "vars": ["HV_AGENT_EMAIL"], + "default_enabled": false + } + ] +} +JSON + +cat > "$CONTEXTFORGE_DIR/manifest.json" <<'JSON' +{ + "schema": "https://happyvertical.com/hv-agent-manifest/v1", + "layer": "contextforge", + "priority": 30, + "commands": [ + { + "agent": "codex", + "name": "review", + "content": "contextforge review" + } + ], + "skills": [ + { + "agent": "codex", + "name": "ship", + "content": "contextforge ship" + } + ], + "agent_docs": [ + { + "id": "contextforge.test", + "targets": ["codex"], + "content": "Agents must not use fixture-order." + } + ] +} +JSON + +cat > "$LOCAL_DIR/commands/codex/review.md" <<'EOF' +local review +EOF + +cat > "$LOCAL_DIR/skills/codex/ship/SKILL.md" <<'EOF' +local ship +EOF + +cat > "$LOCAL_DIR/agent-docs/AGENTS.md" <<'EOF' +Local machine note. +EOF + +python3 "$ROOT_DIR/scripts/hv-agent-resolver.py" \ + --dotfiles-dir "$DOTFILES_DIR" \ + --have-config-dir "$HAVE_CONFIG_DIR" \ + --contextforge-dir "$CONTEXTFORGE_DIR" \ + --local-overrides-dir "$LOCAL_DIR" \ + --output-dir "$OUTPUT_DIR" \ + --home-dir "$HOME_DIR" \ + --lock-path "$LOCK_PATH" \ + --report-path "$REPORT_PATH" >/dev/null + +grep -q "local review" "$HOME_DIR/.codex/commands/review.md" +grep -q "have-config review" "$HOME_DIR/.claude/commands/review.md" +grep -q "local ship" "$HOME_DIR/.agents/skills/ship/SKILL.md" +grep -q "Local machine note." "$HOME_DIR/.codex/AGENTS.md" +grep -q "potential must/must-not conflict" "$REPORT_PATH" +grep -q '"key": "codex:command:review"' "$LOCK_PATH" + +if HV_ENABLED_CAPABILITIES=identity python3 "$ROOT_DIR/scripts/hv-agent-resolver.py" \ + --dotfiles-dir "$DOTFILES_DIR" \ + --have-config-dir "$HAVE_CONFIG_DIR" \ + --contextforge-dir "$CONTEXTFORGE_DIR" \ + --local-overrides-dir "$LOCAL_DIR" \ + --output-dir "$TMP_DIR/generated-env-failure" \ + --home-dir "$TMP_DIR/home-env-failure" \ + --lock-path "$TMP_DIR/env-failure-lock.json" \ + --report-path "$TMP_DIR/env-failure-report.md" >/dev/null 2>&1; then + echo "Expected missing HV_AGENT_EMAIL to fail when identity capability is enabled" >&2 + exit 1 +fi + +echo "hv-agent-resolver tests passed" From 6326da82d591b14155993f4d3ef948739d038893 Mon Sep 17 00:00:00 2001 From: Will Griffin Date: Sun, 24 May 2026 13:53:52 -0600 Subject: [PATCH 3/3] Remove HappyVertical bootstrap from dotfiles --- README.md | 24 - hv/README.md | 48 -- hv/contextforge-manifest.example.json | 29 - hv/manifest.json | 35 -- install.sh | 359 +----------- scripts/hv-agent-resolver.py | 770 -------------------------- scripts/test-hv-agent-resolver.sh | 149 ----- 7 files changed, 8 insertions(+), 1406 deletions(-) delete mode 100644 hv/README.md delete mode 100644 hv/contextforge-manifest.example.json delete mode 100644 hv/manifest.json delete mode 100755 scripts/hv-agent-resolver.py delete mode 100755 scripts/test-hv-agent-resolver.sh diff --git a/README.md b/README.md index 640ea9e..7fe4f98 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,6 @@ dotfiles/ │ └── skills/ ├── .codex/ # Codex defaults │ └── AGENTS.md -├── hv/ # Personal baseline manifest for agent resolver -│ └── manifest.json -├── scripts/ # Bootstrap helper scripts -│ └── hv-agent-resolver.py ├── zsh/ # Zsh configuration │ └── .zshrc ├── bash/ # Bash configuration @@ -53,23 +49,6 @@ stow zsh bash nushell git To pick up new workstation dependencies later, use `update-home` or rerun `./install.sh`. -Agent skills and global agent docs are resolved separately from the normal -home-directory stow packages. The resolver composes these layers: - -1. this dotfiles repo as your personal baseline -2. `have-config` as the HappyVertical organization standard -3. an optional Context Forge snapshot from `HV_CONTEXTFORGE_SNAPSHOT_DIR` -4. machine-local overrides from `~/.config/hv/overrides` - -Commands and skills use this precedence: local override, Context Forge snapshot, -`have-config`, then dotfiles. AGENTS and CLAUDE docs are cumulative. - -Generated files are written under `~/.config/hv/generated`, with an -`~/.config/hv/agent-lock.json` and `~/.config/hv/install-report.md` explaining -sources, selected winners, overrides, missing env vars, and skipped tooling. - -Restart Codex after installing or updating skills; running sessions do not hot-load newly installed skills. - Audit without mutating packages or links: ```bash @@ -130,8 +109,5 @@ These files are sourced at the end of the main configs. - `claude` - `~/.claude/local/claude` - `codex` - installed with npm into `~/.npm-global/bin` - `gh copilot` - downloads the GitHub Copilot CLI via GitHub CLI -- `pr-review` - cloned/updated at `~/Work/happyvertical/repos/pr-review` and added to `PATH` -- HappyVertical agent workflows - `~/Work/happyvertical/repos/have-config/install.sh --live` - `sops` / `age` / `gnupg` - local encrypted environment tooling where available -- `rclone` - WebDAV-capable client for OxiCloud where available - `rebuild` / `update` - Platform-specific rebuild command diff --git a/hv/README.md b/hv/README.md deleted file mode 100644 index 44006f3..0000000 --- a/hv/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# HappyVertical Agent Resolver - -`scripts/hv-agent-resolver.py` composes agent behavior from four layers: - -1. `dotfiles` personal baseline (`hv/manifest.json`) -2. HappyVertical organization standards from `have-config` -3. optional Context Forge snapshot from `HV_CONTEXTFORGE_SNAPSHOT_DIR` -4. machine-local overrides from `~/.config/hv/overrides` - -Command and skill conflicts resolve in this order: - -`local override > Context Forge snapshot > have-config > dotfiles` - -AGENTS and CLAUDE docs are cumulative and assembled in layer order. - -## Local Overrides - -Local overrides are never rewritten by the installer. Use these conventions: - -- `~/.config/hv/overrides/skills//SKILL.md` -- `~/.config/hv/overrides/skills/codex//SKILL.md` -- `~/.config/hv/overrides/skills/claude//SKILL.md` -- `~/.config/hv/overrides/commands/claude/.md` -- `~/.config/hv/overrides/commands/codex/.md` -- `~/.config/hv/overrides/agent-docs/AGENTS.md` -- `~/.config/hv/overrides/agent-docs/CLAUDE.md` - -For more control, create `~/.config/hv/overrides/manifest.json` using the same -shape as `hv/manifest.json`. - -## Context Forge Snapshots - -The installer cannot call MCP prompts directly from shell. Export Context Forge -material into a local directory with a `manifest.json`, then set: - -```bash -export HV_CONTEXTFORGE_SNAPSHOT_DIR="$HOME/.config/hv/contextforge" -``` - -Rerun `./install.sh` to materialize the snapshot into local runtime files and -record hashes in `~/.config/hv/agent-lock.json`. - -## Local Environment - -If present, `~/.config/hv/env` is sourced by the installer before resolving -agent configuration. If `~/.config/hv/env.sops.env` exists, install decrypts it -with SOPS into `~/.config/hv/env` first. Keep real values local and use Warden -as the sharing standard. diff --git a/hv/contextforge-manifest.example.json b/hv/contextforge-manifest.example.json deleted file mode 100644 index 0c4b664..0000000 --- a/hv/contextforge-manifest.example.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "schema": "https://happyvertical.com/hv-agent-manifest/v1", - "layer": "contextforge", - "priority": 30, - "commands": [ - { - "agent": "claude", - "name": "review", - "path": "commands/claude/review.md", - "source_uri": "have://happyvertical/workflows/review" - } - ], - "skills": [ - { - "agent": "codex", - "name": "review", - "path": "skills/review", - "source_uri": "have://happyvertical/workflows/review" - } - ], - "agent_docs": [ - { - "id": "contextforge.dynamic-policy", - "targets": ["agents", "claude"], - "path": "agent-docs/dynamic-policy.md", - "source_uri": "have://happyvertical/agent-docs/dynamic-policy" - } - ] -} diff --git a/hv/manifest.json b/hv/manifest.json deleted file mode 100644 index 58fcde7..0000000 --- a/hv/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "schema": "https://happyvertical.com/hv-agent-manifest/v1", - "layer": "dotfiles", - "priority": 10, - "agent_docs": [ - { - "id": "dotfiles.codex-defaults", - "targets": ["agents", "codex"], - "path": ".codex/AGENTS.md" - } - ], - "skills": [ - { - "agent": "codex", - "name": "ship", - "path": ".agents/skills/ship", - "description": "Personal baseline shipping workflow" - }, - { - "agent": "codex", - "name": "resolve", - "path": ".agents/skills/resolve", - "description": "Personal baseline review-comment resolution workflow" - } - ], - "env_requirements": [ - { - "capability": "identity", - "vars": ["HV_AGENT_EMAIL"], - "default_enabled": false, - "description": "Per-user or per-agent HappyVertical account identity." - } - ], - "services": [] -} diff --git a/install.sh b/install.sh index 0c5b194..50ed8df 100755 --- a/install.sh +++ b/install.sh @@ -12,13 +12,11 @@ usage() { cat <<'EOF' Usage: ./install.sh [--dry-run|--audit] [-h|--help] -Installs workstation and agent tooling, then resolves HappyVertical agent -configuration from dotfiles, have-config, Context Forge snapshots, and local -machine overrides. +Installs workstation tooling, shells, CLI tools, and personal dotfiles. Options: - --dry-run, --audit Report what would be installed or resolved without - changing packages, symlinks, or generated agent files. + --dry-run, --audit Report platform/install intent without changing packages, + symlinks, or generated files. -h, --help Show this help. EOF } @@ -229,122 +227,6 @@ install_sops_tools() { fi } -process_sops_env() { - local hv_config_dir="${HV_CONFIG_DIR:-$HOME/.config/hv}" - local sops_file="${HV_SOPS_ENV_FILE:-$hv_config_dir/env.sops.env}" - local env_file="${HV_ENV_FILE:-$hv_config_dir/env}" - local example_file="$hv_config_dir/env.example" - - mkdir -p "$hv_config_dir" - - if [[ ! -f "$example_file" ]]; then - cat > "$example_file" <<'EOF' -# HappyVertical local environment template. -# Copy values from Warden or your machine-local secret store. -# -# HV_AGENT_EMAIL=agent@example.com -# HV_ENABLED_CAPABILITIES=happyvertical-identity -# HV_CONTEXTFORGE_SNAPSHOT_DIR=$HOME/.config/hv/contextforge -EOF - fi - - if [[ -f "$sops_file" ]]; then - if ! command -v sops &> /dev/null; then - echo "SOPS env file exists at $sops_file but sops is not installed." - return 1 - fi - - echo "Decrypting local SOPS environment file..." - local tmp_file - tmp_file="$(mktemp)" - sops -d "$sops_file" > "$tmp_file" - chmod 600 "$tmp_file" - mv "$tmp_file" "$env_file" - echo "Wrote local environment file: $env_file" - else - echo "No local SOPS environment file at $sops_file; leaving secrets to Warden/local env." - fi -} - -load_hv_env() { - local hv_config_dir="${HV_CONFIG_DIR:-$HOME/.config/hv}" - local env_file="${HV_ENV_FILE:-$hv_config_dir/env}" - - if [[ -f "$env_file" ]]; then - echo "Loading HappyVertical local environment: $env_file" - set -a - # shellcheck disable=SC1090 - . "$env_file" - set +a - fi -} - -install_report_path() { - local hv_config_dir="${HV_CONFIG_DIR:-$HOME/.config/hv}" - echo "${HV_INSTALL_REPORT:-$hv_config_dir/install-report.md}" -} - -append_tool_audit() { - local report_path - report_path="$(install_report_path)" - mkdir -p "$(dirname "$report_path")" - - { - echo - echo "## Bootstrap Tool Audit" - echo - echo "- Platform: ${PLATFORM:-unknown}${DISTRO:+/$DISTRO}" - echo "- Package mutation: $([[ "$DRY_RUN" -eq 1 ]] && echo "skipped by dry-run" || echo "attempted where supported")" - echo - echo "| Tool | Status |" - echo "| --- | --- |" - for tool in git zsh stow python3 sops age gpg rclone pr-review claude codex gh aws gcloud; do - if command -v "$tool" &> /dev/null; then - echo "| \`$tool\` | available at \`$(command -v "$tool")\` |" - elif [[ "$DRY_RUN" -eq 1 ]]; then - echo "| \`$tool\` | skipped by dry-run |" - else - echo "| \`$tool\` | not available after install attempt |" - fi - done - } >> "$report_path" -} - -install_service_clis() { - echo "Installing service CLIs..." - - case "$PLATFORM" in - macos) - brew install rclone 2>/dev/null || true - ;; - linux) - case "$DISTRO" in - ubuntu|debian|pop) - run_privileged apt-get install -y rclone 2>/dev/null || true - ;; - fedora|rhel|centos) - run_privileged dnf install -y rclone 2>/dev/null || true - ;; - alpine) - run_privileged apk add rclone 2>/dev/null || true - ;; - arch|manjaro) - run_privileged pacman -S --noconfirm rclone 2>/dev/null || true - ;; - nixos) - echo "NixOS detected - service CLIs should be managed by Nix" - ;; - esac - ;; - esac - - if command -v rclone &> /dev/null; then - echo "OxiCloud/WebDAV CLI available: $(command -v rclone)" - else - echo "rclone not found; OxiCloud CLI support will be documented but not configured." - fi -} - install_starship() { if command -v starship &> /dev/null; then echo "Starship already installed" @@ -471,10 +353,6 @@ ensure_agent_paths() { export PATH="$HOME/.claude/local:$PATH" fi - local pr_review_bin="${PR_REVIEW_DIR:-$HOME/Work/happyvertical/repos/pr-review}/bin" - if [[ -d "$pr_review_bin" ]] && [[ ":$PATH:" != *":$pr_review_bin:"* ]]; then - export PATH="$pr_review_bin:$PATH" - fi } # ============================================================================== @@ -563,213 +441,6 @@ install_copilot_cli() { fi } -install_pr_review() { - ensure_agent_paths - - local pr_review_dir="${PR_REVIEW_DIR:-$HOME/Work/happyvertical/repos/pr-review}" - local repo_url="${PR_REVIEW_REPO_URL:-https://github.com/happyvertical/pr-review.git}" - - if [[ -d "$pr_review_dir/.git" ]]; then - echo "Updating pr-review..." - if ! git -C "$pr_review_dir" pull --ff-only --quiet; then - echo " Could not fast-forward pr-review; using existing checkout." - fi - else - echo "Cloning pr-review..." - mkdir -p "$(dirname "$pr_review_dir")" - git clone --quiet "$repo_url" "$pr_review_dir" - fi - - ensure_agent_paths - - if command -v pr-review &> /dev/null; then - echo "pr-review on PATH: $(command -v pr-review)" - else - echo "pr-review not on PATH. Add this to your shell rc:" - echo " export PATH=\"$pr_review_dir/bin:\$PATH\"" - fi -} - -repair_have_config_codex_marketplace() { - if ! command -v codex &> /dev/null || ! command -v python3 &> /dev/null; then - return 0 - fi - - local expected_source="$1/codex" - local existing_source - local inspect_status - - if existing_source=$(python3 - "$expected_source" <<'PY' -import os -import sys - -expected = os.path.realpath(sys.argv[1]) -path = os.path.expanduser("~/.codex/config.toml") - -if not os.path.exists(path): - sys.exit(0) - -in_section = False -source = None - -with open(path, encoding="utf-8") as f: - for raw_line in f: - line = raw_line.strip() - if line.startswith("[") and line.endswith("]"): - in_section = line == "[marketplaces.have-config]" - continue - if in_section and "=" in line: - key, value = line.split("=", 1) - if key.strip() != "source": - continue - source = value.strip().strip('"') - break - -if source and os.path.realpath(os.path.expanduser(source)) != expected: - print(source) - sys.exit(42) -PY - ); then - return 0 - else - inspect_status="$?" - fi - - if [[ "$inspect_status" -eq 42 ]]; then - echo "Codex marketplace 'have-config' points at $existing_source; re-registering." - codex plugin marketplace remove have-config &> /dev/null || true - fi -} - -repair_have_config_claude_plugin() { - if ! command -v claude &> /dev/null || ! command -v python3 &> /dev/null; then - return 0 - fi - - local plugin_json="$1/claude/have/.claude-plugin/plugin.json" - local installed_json="$HOME/.claude/plugins/installed_plugins.json" - local stale_reason - local inspect_status - - if [[ ! -f "$plugin_json" || ! -f "$installed_json" ]]; then - return 0 - fi - - if stale_reason=$(python3 - "$plugin_json" "$installed_json" <<'PY' -import json -import os -import sys - -plugin_json, installed_json = sys.argv[1], os.path.expanduser(sys.argv[2]) - -with open(plugin_json, encoding="utf-8") as f: - expected_version = json.load(f).get("version") - -with open(installed_json, encoding="utf-8") as f: - installed_data = json.load(f) - -installed = installed_data.get("plugins", installed_data).get("have@have-config", []) - -if not installed: - sys.exit(0) - -entry = installed[0] -installed_version = entry.get("version") -install_path = entry.get("installPath") - -if expected_version and installed_version != expected_version: - print(f"version {installed_version} != {expected_version}") - sys.exit(42) - -if install_path and not os.path.isdir(os.path.expanduser(install_path)): - print(f"cache missing at {install_path}") - sys.exit(42) -PY - ); then - return 0 - else - inspect_status="$?" - fi - - if [[ "$inspect_status" -eq 42 ]]; then - echo "Claude have@have-config install is stale ($stale_reason); reinstalling." - claude plugin uninstall have@have-config &> /dev/null || true - fi -} - -install_have_config() { - ensure_agent_paths - - local have_config_dir="${HAVE_CONFIG_DIR:-$HOME/Work/happyvertical/repos/have-config}" - local repo_url="${HAVE_CONFIG_REPO_URL:-git@github.com:happyvertical/have-config.git}" - local fallback_repo_url="https://github.com/happyvertical/have-config.git" - local install_args=() - HAVE_CONFIG_DIR_RESOLVED="$have_config_dir" - - if [[ "${HAVE_CONFIG_LIVE:-1}" != "0" ]]; then - install_args+=(--live) - fi - - if [[ "$DRY_RUN" -eq 1 ]]; then - echo "Dry-run: would install/update have-config at $have_config_dir" - if [[ -x "$have_config_dir/install.sh" ]]; then - (cd "$have_config_dir" && ./install.sh --dry-run) - else - echo "Dry-run: have-config installer not found at $have_config_dir/install.sh" - fi - return 0 - fi - - if [[ -d "$have_config_dir/.git" ]]; then - echo "Updating have-config..." - if ! git -C "$have_config_dir" pull --ff-only --quiet; then - echo " Could not fast-forward have-config; using existing checkout." - fi - else - echo "Cloning have-config..." - mkdir -p "$(dirname "$have_config_dir")" - if ! git clone --quiet "$repo_url" "$have_config_dir"; then - if [[ "$repo_url" != "$fallback_repo_url" ]]; then - echo " SSH clone failed, trying HTTPS..." - git clone --quiet "$fallback_repo_url" "$have_config_dir" - else - return 1 - fi - fi - fi - - if [[ ! -x "$have_config_dir/install.sh" ]]; then - echo "have-config installer not executable at $have_config_dir/install.sh" - return 1 - fi - - repair_have_config_claude_plugin "$have_config_dir" - repair_have_config_codex_marketplace "$have_config_dir" - - echo "Installing HappyVertical agent workflows..." - (cd "$have_config_dir" && ./install.sh "${install_args[@]}") -} - -run_agent_resolver() { - if ! command -v python3 &> /dev/null; then - echo "python3 not found; cannot resolve HappyVertical agent configuration" - return 1 - fi - - local have_config_dir="${HAVE_CONFIG_DIR_RESOLVED:-${HAVE_CONFIG_DIR:-$HOME/Work/happyvertical/repos/have-config}}" - local args=( - --dotfiles-dir "$DOTFILES_DIR" - --have-config-dir "$have_config_dir" - ) - - if [[ "$DRY_RUN" -eq 1 ]]; then - args+=(--dry-run) - fi - - echo "Resolving HappyVertical agent configuration..." - python3 "$DOTFILES_DIR/scripts/hv-agent-resolver.py" "${args[@]}" -} - install_gemini_cli() { ensure_agent_paths @@ -970,7 +641,7 @@ set_default_shell() { return 0 fi - if [[ "${HV_NONINTERACTIVE:-1}" == "1" || ! -t 0 ]]; then + if [[ "${DOTFILES_NONINTERACTIVE:-1}" == "1" || ! -t 0 ]]; then echo "Skipping default shell prompt (noninteractive install)." return 0 fi @@ -1008,16 +679,12 @@ main() { [[ "$DRY_RUN" -eq 1 ]] && echo "Mode: dry-run" echo - load_hv_env - if [[ "$DRY_RUN" -eq 1 ]]; then - echo "Dry-run: skipping package, CLI, shell, and stow mutations." - install_have_config + echo "Dry-run: would install packages, AI CLIs, shell tooling, and stowed dotfiles." echo - run_agent_resolver - append_tool_audit - echo - echo "========================================" + echo "Core tools: zsh git curl stow starship zoxide direnv fzf bat eza ripgrep fd jq" + echo "AI CLIs: codex claude gemini kimi ralph" + echo "Package mutation, downloads, shell changes, and stow operations skipped." echo "Dry-run complete!" echo "========================================" return 0 @@ -1026,9 +693,6 @@ main() { # Install packages install_packages install_sops_tools - process_sops_env - load_hv_env - install_service_clis echo # Install AI CLI tools @@ -1036,18 +700,11 @@ main() { install_claude_code install_claude_plugins install_copilot_cli - install_pr_review - install_have_config install_kimi_code install_gemini_cli install_ralph echo - # Compose and install cross-agent skills/docs from all configured layers. - run_agent_resolver - append_tool_audit - echo - # Install Oh My Zsh install_oh_my_zsh echo diff --git a/scripts/hv-agent-resolver.py b/scripts/hv-agent-resolver.py deleted file mode 100755 index 678421e..0000000 --- a/scripts/hv-agent-resolver.py +++ /dev/null @@ -1,770 +0,0 @@ -#!/usr/bin/env python3 -"""Resolve HappyVertical agent definitions into local generated files. - -The resolver composes four layers: - -1. dotfiles personal baseline -2. have-config organization standard -3. Context Forge install-time snapshot -4. machine-local overrides - -Commands and skills use winner-takes-all resolution by layer priority. Agent -documents are cumulative and assembled in layer order. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import shutil -import sys -from dataclasses import dataclass, field -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - - -LAYER_PRIORITIES = { - "dotfiles": 10, - "have-config": 20, - "contextforge": 30, - "local": 40, -} - -TARGETS = { - "agents": "AGENTS.md", - "codex": "AGENTS.md", - "claude": "CLAUDE.md", -} - - -@dataclass(frozen=True) -class SourceLayer: - name: str - root: Path - manifest: Path | None - priority: int - available: bool - notes: list[str] = field(default_factory=list) - - -@dataclass -class Candidate: - kind: str - agent: str - name: str - layer: str - priority: int - source: str - path: Path | None = None - content: str | None = None - metadata: dict[str, Any] = field(default_factory=dict) - - @property - def key(self) -> str: - return f"{self.agent}:{self.kind}:{self.name}" - - def digest(self) -> str: - if self.content is not None: - return sha256_text(self.content) - if self.path is None: - return sha256_text("") - return sha256_path(self.path) - - -@dataclass -class DocSnippet: - snippet_id: str - targets: list[str] - layer: str - priority: int - source: str - path: Path | None = None - content: str | None = None - - def read(self) -> str: - if self.content is not None: - return self.content.rstrip() + "\n" - if self.path is None: - return "" - return self.path.read_text(encoding="utf-8").rstrip() + "\n" - - def digest(self) -> str: - if self.content is not None: - return sha256_text(self.content) - if self.path is None: - return sha256_text("") - return sha256_path(self.path) - - -def sha256_text(value: str) -> str: - return hashlib.sha256(value.encode("utf-8")).hexdigest() - - -def sha256_path(path: Path) -> str: - if path.is_file(): - return hashlib.sha256(path.read_bytes()).hexdigest() - - digest = hashlib.sha256() - if path.is_dir(): - for child in sorted(p for p in path.rglob("*") if p.is_file()): - digest.update(str(child.relative_to(path)).encode("utf-8")) - digest.update(b"\0") - digest.update(child.read_bytes()) - digest.update(b"\0") - return digest.hexdigest() - - -def load_json(path: Path) -> dict[str, Any]: - with path.open(encoding="utf-8") as f: - return json.load(f) - - -def resolve_path(root: Path, value: str | None) -> Path | None: - if not value: - return None - raw = Path(os.path.expandvars(os.path.expanduser(value))) - if raw.is_absolute(): - return raw - return root / raw - - -def source_label(layer: str, root: Path, path: Path | None, content: str | None) -> str: - if path is None: - return f"{layer}:inline:{sha256_text(content or '')[:12]}" - try: - rel = path.relative_to(root) - return f"{layer}:{rel}" - except ValueError: - return f"{layer}:{path}" - - -def expand_agents(agent: str, kind: str) -> list[str]: - if agent != "all": - return [agent] - if kind == "command": - return ["claude", "codex"] - return ["codex"] - - -def doc_target_matches(target: str, snippet_targets: list[str]) -> bool: - if "all" in snippet_targets: - return True - if target == "agents": - return "agents" in snippet_targets or "codex" in snippet_targets - if target == "codex": - return "codex" in snippet_targets or "agents" in snippet_targets - return target in snippet_targets - - -def manifest_layer(name: str, root: Path, manifest_name: str, default_priority: int) -> SourceLayer: - manifest = root / manifest_name - if manifest.exists(): - data = load_json(manifest) - notes: list[str] = [] - declared_priority = data.get("priority") - if declared_priority is not None and int(declared_priority) != default_priority: - notes.append(f"declared priority {declared_priority} ignored; using fixed {default_priority}") - return SourceLayer(name, root, manifest, default_priority, True, notes) - if name == "local" and root.exists(): - return SourceLayer(name, root, None, default_priority, True, [f"no {manifest}; using convention-based overrides"]) - return SourceLayer(name, root, None, default_priority, False, [f"missing {manifest}"]) - - -def collect_manifest(layer: SourceLayer) -> tuple[list[Candidate], list[DocSnippet], list[dict[str, Any]], list[dict[str, Any]]]: - if not layer.available or layer.manifest is None: - return [], [], [], [] - - data = load_json(layer.manifest) - root = layer.root - priority = layer.priority - layer_name = layer.name - - candidates: list[Candidate] = [] - docs: list[DocSnippet] = [] - - for item in data.get("skills", []): - path = resolve_path(root, item.get("path")) - content = item.get("content") - name = item["name"] - agent = item.get("agent", "codex") - for expanded_agent in expand_agents(agent, "skill"): - candidates.append( - Candidate( - kind="skill", - agent=expanded_agent, - name=name, - layer=layer_name, - priority=priority, - path=path, - content=content, - source=source_label(layer_name, root, path, content), - metadata={k: v for k, v in item.items() if k not in {"path", "content"}}, - ) - ) - - for item in data.get("commands", []): - path = resolve_path(root, item.get("path")) - content = item.get("content") - name = item["name"] - agent = item.get("agent", "all") - for expanded_agent in expand_agents(agent, "command"): - candidates.append( - Candidate( - kind="command", - agent=expanded_agent, - name=name, - layer=layer_name, - priority=priority, - path=path, - content=content, - source=source_label(layer_name, root, path, content), - metadata={k: v for k, v in item.items() if k not in {"path", "content"}}, - ) - ) - - for item in data.get("agent_docs", []): - path = resolve_path(root, item.get("path")) - content = item.get("content") - docs.append( - DocSnippet( - snippet_id=item["id"], - targets=list(item.get("targets", ["agents"])), - layer=layer_name, - priority=priority, - path=path, - content=content, - source=source_label(layer_name, root, path, content), - ) - ) - - return candidates, docs, data.get("env_requirements", []), data.get("services", []) - - -def collect_local_conventions(root: Path, priority: int) -> tuple[list[Candidate], list[DocSnippet]]: - candidates: list[Candidate] = [] - docs: list[DocSnippet] = [] - - skill_roots = [ - ("codex", root / "skills"), - ("codex", root / "skills" / "codex"), - ("claude", root / "skills" / "claude"), - ] - for agent, skill_root in skill_roots: - if not skill_root.is_dir(): - continue - for skill_dir in sorted(p for p in skill_root.iterdir() if p.is_dir()): - if (skill_dir / "SKILL.md").exists(): - candidates.append( - Candidate( - kind="skill", - agent=agent, - name=skill_dir.name, - layer="local", - priority=priority, - path=skill_dir, - source=source_label("local", root, skill_dir, None), - ) - ) - - commands_root = root / "commands" - for agent in ["claude", "codex"]: - agent_root = commands_root / agent - if not agent_root.is_dir(): - continue - for command_file in sorted(agent_root.glob("*.md")): - candidates.append( - Candidate( - kind="command", - agent=agent, - name=command_file.stem, - layer="local", - priority=priority, - path=command_file, - source=source_label("local", root, command_file, None), - ) - ) - - doc_root = root / "agent-docs" - doc_map = [ - ("local.agents", ["agents", "codex"], doc_root / "AGENTS.md"), - ("local.claude", ["claude"], doc_root / "CLAUDE.md"), - ] - for snippet_id, targets, path in doc_map: - if path.exists(): - docs.append( - DocSnippet( - snippet_id=snippet_id, - targets=targets, - layer="local", - priority=priority, - path=path, - source=source_label("local", root, path, None), - ) - ) - - return candidates, docs - - -def ensure_parent(path: Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - - -def replace_tree(src: Path, dest: Path) -> None: - if dest.exists() or dest.is_symlink(): - if dest.is_dir() and not dest.is_symlink(): - shutil.rmtree(dest) - else: - dest.unlink() - if src.is_dir(): - shutil.copytree(src, dest, symlinks=True) - else: - ensure_parent(dest) - shutil.copy2(src, dest) - - -def write_candidate(candidate: Candidate, dest: Path) -> None: - if candidate.content is not None: - if dest.suffix: - ensure_parent(dest) - dest.write_text(candidate.content.rstrip() + "\n", encoding="utf-8") - else: - dest.mkdir(parents=True, exist_ok=True) - (dest / "SKILL.md").write_text(candidate.content.rstrip() + "\n", encoding="utf-8") - return - if candidate.path is None: - return - replace_tree(candidate.path, dest) - - -def is_managed_target(path: Path, generated_root: Path, repo_roots: list[Path]) -> bool: - if not path.exists() and not path.is_symlink(): - return True - if not path.is_symlink(): - return False - target = Path(os.readlink(path)) - if not target.is_absolute(): - target = (path.parent / target).resolve() - try: - target.resolve().relative_to(generated_root.resolve()) - return True - except ValueError: - pass - for root in repo_roots: - try: - target.resolve().relative_to(root.resolve()) - return True - except ValueError: - continue - parts = set(target.parts) - if ".agents" in parts and "skills" in parts: - return True - if target.name == "AGENTS.md" and ".codex" in parts: - return True - if target.name == "CLAUDE.md" and ".claude" in parts: - return True - if "commands" in parts and (".claude" in parts or ".codex" in parts): - return True - return False - - -def link_target(src: Path, target: Path, generated_root: Path, repo_roots: list[Path], dry_run: bool, report: list[str]) -> None: - if not is_managed_target(target, generated_root, repo_roots): - report.append(f"- blocked managed link `{target}`; existing file is not managed by hv") - return - if dry_run: - report.append(f"- would link `{target}` -> `{src}`") - return - ensure_parent(target) - if target.exists() or target.is_symlink(): - if target.is_dir() and not target.is_symlink(): - shutil.rmtree(target) - else: - target.unlink() - target.symlink_to(src) - - -def resolve_candidates(candidates: list[Candidate]) -> dict[str, list[Candidate]]: - by_key: dict[str, list[Candidate]] = {} - for candidate in candidates: - by_key.setdefault(candidate.key, []).append(candidate) - for items in by_key.values(): - items.sort(key=lambda c: (c.priority, c.layer, c.source)) - return by_key - - -def selected_candidate(items: list[Candidate]) -> Candidate: - return sorted(items, key=lambda c: (c.priority, c.layer, c.source))[-1] - - -def candidate_record(candidate: Candidate) -> dict[str, Any]: - return { - "kind": candidate.kind, - "agent": candidate.agent, - "name": candidate.name, - "layer": candidate.layer, - "priority": candidate.priority, - "source": candidate.source, - "sha256": candidate.digest(), - "metadata": candidate.metadata, - } - - -def parse_enabled_capabilities(value: str | None, defaults: list[str]) -> set[str]: - enabled = {item.strip() for item in defaults if item.strip()} - if value: - enabled.update(item.strip() for item in value.split(",") if item.strip()) - return enabled - - -def validate_env(requirements: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - defaults = [ - req["capability"] - for req in requirements - if req.get("default_enabled") is True and req.get("capability") - ] - enabled = parse_enabled_capabilities(os.environ.get("HV_ENABLED_CAPABILITIES"), defaults) - if "all" in enabled: - enabled.update(req.get("capability", "") for req in requirements) - - checked: list[dict[str, Any]] = [] - missing: list[dict[str, Any]] = [] - for req in requirements: - capability = req.get("capability") - vars_required = list(req.get("vars", [])) - if not capability or capability not in enabled: - continue - absent = [name for name in vars_required if not os.environ.get(name)] - record = { - "capability": capability, - "vars": vars_required, - "missing": absent, - "source": req.get("source"), - } - checked.append(record) - if absent: - missing.append(record) - return checked, missing - - -def assemble_doc(target: str, snippets: list[DocSnippet]) -> str: - title = "Global Agent Instructions" if target in {"agents", "codex"} else "Global Claude Instructions" - lines = [ - f"# {title}", - "", - "", - "", - ] - for snippet in sorted(snippets, key=lambda s: (s.priority, s.layer, s.snippet_id)): - if not doc_target_matches(target, snippet.targets): - continue - lines.extend( - [ - f"", - snippet.read().rstrip(), - "", - ] - ) - return "\n".join(lines).rstrip() + "\n" - - -def doc_conflicts(content: str) -> list[str]: - must: set[str] = set() - must_not: set[str] = set() - for line in content.splitlines(): - cleaned = line.strip().lower().strip("-* ") - if "must not " in cleaned: - must_not.add(cleaned.split("must not ", 1)[1]) - elif "must " in cleaned: - must.add(cleaned.split("must ", 1)[1]) - return sorted(must.intersection(must_not)) - - -def write_report( - path: Path, - layers: list[SourceLayer], - resolved: dict[str, list[Candidate]], - doc_outputs: dict[str, str], - env_checked: list[dict[str, Any]], - env_missing: list[dict[str, Any]], - services: list[dict[str, Any]], - link_report: list[str], - dry_run: bool, -) -> None: - lines = [ - "# HappyVertical Agent Install Report", - "", - f"- Generated: {datetime.now(timezone.utc).isoformat()}", - f"- Mode: {'dry-run' if dry_run else 'install'}", - "", - "## Source Layers", - "", - ] - for layer in layers: - status = "available" if layer.available else "missing" - lines.append(f"- `{layer.name}` priority {layer.priority}: {status} at `{layer.root}`") - for note in layer.notes: - lines.append(f" - {note}") - - lines.extend(["", "## Resolved Commands And Skills", ""]) - for key in sorted(resolved): - items = resolved[key] - winner = selected_candidate(items) - lines.append(f"- `{key}` -> `{winner.source}` ({winner.layer})") - for item in items: - if item is winner: - continue - lines.append(f" - overrides `{item.source}` ({item.layer})") - - lines.extend(["", "## Agent Docs", ""]) - for target, content in doc_outputs.items(): - conflicts = doc_conflicts(content) - lines.append(f"- `{target}` generated ({len(content.splitlines())} lines)") - for conflict in conflicts: - lines.append(f" - potential must/must-not conflict: `{conflict}`") - - lines.extend(["", "## Environment Requirements", ""]) - if not env_checked: - lines.append("- No enabled capabilities required env validation.") - for item in env_checked: - if item["missing"]: - lines.append(f"- `{item['capability']}` missing: {', '.join(item['missing'])}") - else: - lines.append(f"- `{item['capability']}` satisfied.") - - lines.extend(["", "## Services", ""]) - if not services: - lines.append("- No service registry entries found.") - for service in services: - cli = service.get("cli", {}) - status = cli.get("status", "documented") - lines.append(f"- `{service.get('id')}` {service.get('url', '')} CLI: {status}") - - if link_report: - lines.extend(["", "## Managed Links", ""]) - lines.extend(link_report) - - ensure_parent(path) - path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") - - -def write_lock( - path: Path, - layers: list[SourceLayer], - resolved: dict[str, list[Candidate]], - docs: list[DocSnippet], - env_checked: list[dict[str, Any]], - services: list[dict[str, Any]], -) -> None: - data = { - "schema": "https://happyvertical.com/hv-agent-lock/v1", - "generated_at": datetime.now(timezone.utc).isoformat(), - "layers": [ - { - "name": layer.name, - "root": str(layer.root), - "manifest": str(layer.manifest) if layer.manifest else None, - "priority": layer.priority, - "available": layer.available, - } - for layer in layers - ], - "definitions": [], - "docs": [ - { - "id": doc.snippet_id, - "targets": doc.targets, - "layer": doc.layer, - "priority": doc.priority, - "source": doc.source, - "sha256": doc.digest(), - } - for doc in sorted(docs, key=lambda d: (d.priority, d.layer, d.snippet_id)) - ], - "env": env_checked, - "services": services, - } - for key in sorted(resolved): - items = resolved[key] - winner = selected_candidate(items) - data["definitions"].append( - { - "key": key, - "winner": candidate_record(winner), - "candidates": [candidate_record(item) for item in items], - } - ) - ensure_parent(path) - path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") - - -def materialize( - resolved: dict[str, list[Candidate]], - doc_outputs: dict[str, str], - output_dir: Path, - home_dir: Path, - repo_roots: list[Path], - dry_run: bool, -) -> list[str]: - report: list[str] = [] - if not dry_run: - output_dir.mkdir(parents=True, exist_ok=True) - for child in ["skills", "commands"]: - target = output_dir / child - if target.exists(): - shutil.rmtree(target) - - for key in sorted(resolved): - winner = selected_candidate(resolved[key]) - if winner.kind == "skill": - dest = output_dir / "skills" / winner.name - if not dry_run: - write_candidate(winner, dest) - link_target(dest, home_dir / ".agents" / "skills" / winner.name, output_dir, repo_roots, dry_run, report) - elif winner.kind == "command": - suffix = ".md" if winner.path is None or winner.path.is_file() else "" - dest = output_dir / "commands" / winner.agent / f"{winner.name}{suffix}" - if not dry_run: - write_candidate(winner, dest) - if winner.agent == "claude": - link_target(dest, home_dir / ".claude" / "commands" / f"{winner.name}.md", output_dir, repo_roots, dry_run, report) - elif winner.agent == "codex": - link_target(dest, home_dir / ".codex" / "commands" / f"{winner.name}.md", output_dir, repo_roots, dry_run, report) - - for target, content in doc_outputs.items(): - filename = TARGETS[target] - dest = output_dir / "docs" / target / filename - if not dry_run: - ensure_parent(dest) - dest.write_text(content, encoding="utf-8") - if target in {"agents", "codex"}: - link_target(dest, home_dir / ".codex" / "AGENTS.md", output_dir, repo_roots, dry_run, report) - if target == "claude": - link_target(dest, home_dir / ".claude" / "CLAUDE.md", output_dir, repo_roots, dry_run, report) - return report - - -def ensure_local_override_templates(local_dir: Path, dry_run: bool) -> list[str]: - report: list[str] = [] - directories = [ - local_dir / "skills", - local_dir / "skills" / "codex", - local_dir / "skills" / "claude", - local_dir / "commands" / "claude", - local_dir / "commands" / "codex", - local_dir / "agent-docs", - ] - readme = local_dir / "README.md" - - if dry_run: - report.append(f"- would ensure local override directories under `{local_dir}`") - return report - - for directory in directories: - directory.mkdir(parents=True, exist_ok=True) - - if not readme.exists(): - readme.write_text( - "\n".join( - [ - "# HappyVertical Local Overrides", - "", - "Files in this directory are machine-local and are never overwritten by", - "the dotfiles installer.", - "", - "- `skills//SKILL.md` overrides Codex skills.", - "- `commands/claude/.md` overrides Claude commands.", - "- `commands/codex/.md` overrides Codex commands.", - "- `agent-docs/AGENTS.md` and `agent-docs/CLAUDE.md` are appended", - " to generated global instructions.", - "", - "Local overrides win over Context Forge snapshots, have-config, and", - "dotfiles. Keep them intentional and review the install report after", - "each update.", - "", - ] - ), - encoding="utf-8", - ) - return report - - -def main() -> int: - hv_config_dir = os.environ.get("HV_CONFIG_DIR", "~/.config/hv") - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--dotfiles-dir", default=os.getcwd()) - parser.add_argument("--have-config-dir", default=os.environ.get("HAVE_CONFIG_DIR", "~/Work/happyvertical/repos/have-config")) - parser.add_argument("--contextforge-dir", default=os.environ.get("HV_CONTEXTFORGE_SNAPSHOT_DIR", f"{hv_config_dir}/contextforge")) - parser.add_argument("--local-overrides-dir", default=os.environ.get("HV_LOCAL_OVERRIDES_DIR", f"{hv_config_dir}/overrides")) - parser.add_argument("--output-dir", default=os.environ.get("HV_GENERATED_DIR", f"{hv_config_dir}/generated")) - parser.add_argument("--home-dir", default="~") - parser.add_argument("--lock-path", default=os.environ.get("HV_AGENT_LOCK", f"{hv_config_dir}/agent-lock.json")) - parser.add_argument("--report-path", default=os.environ.get("HV_INSTALL_REPORT", f"{hv_config_dir}/install-report.md")) - parser.add_argument("--dry-run", action="store_true") - args = parser.parse_args() - - dotfiles = Path(args.dotfiles_dir).expanduser().resolve() - have_config = Path(args.have_config_dir).expanduser().resolve() - contextforge = Path(args.contextforge_dir).expanduser().resolve() - local = Path(args.local_overrides_dir).expanduser().resolve() - output_dir = Path(args.output_dir).expanduser().resolve() - home_dir = Path(args.home_dir).expanduser().resolve() - lock_path = Path(args.lock_path).expanduser().resolve() - report_path = Path(args.report_path).expanduser().resolve() - - link_report = ensure_local_override_templates(local, args.dry_run) - - layers = [ - manifest_layer("dotfiles", dotfiles, "hv/manifest.json", LAYER_PRIORITIES["dotfiles"]), - manifest_layer("have-config", have_config, "hv/manifest.json", LAYER_PRIORITIES["have-config"]), - manifest_layer("contextforge", contextforge, "manifest.json", LAYER_PRIORITIES["contextforge"]), - manifest_layer("local", local, "manifest.json", LAYER_PRIORITIES["local"]), - ] - - candidates: list[Candidate] = [] - docs: list[DocSnippet] = [] - env_requirements: list[dict[str, Any]] = [] - services: list[dict[str, Any]] = [] - - for layer in layers: - layer_candidates, layer_docs, layer_env, layer_services = collect_manifest(layer) - candidates.extend(layer_candidates) - docs.extend(layer_docs) - env_requirements.extend({**item, "source": layer.name} for item in layer_env) - services.extend({**item, "source": layer.name} for item in layer_services) - - local_candidates, local_docs = collect_local_conventions(local, LAYER_PRIORITIES["local"]) - candidates.extend(local_candidates) - docs.extend(local_docs) - - resolved = resolve_candidates(candidates) - env_checked, env_missing = validate_env(env_requirements) - doc_outputs = { - target: assemble_doc(target, docs) - for target in ["agents", "claude"] - if any(doc_target_matches(target, snippet.targets) for snippet in docs) - } - - repo_roots = [dotfiles, have_config] - link_report.extend(materialize(resolved, doc_outputs, output_dir, home_dir, repo_roots, args.dry_run)) - - if not args.dry_run: - write_lock(lock_path, layers, resolved, docs, env_checked, services) - write_report(report_path, layers, resolved, doc_outputs, env_checked, env_missing, services, link_report, args.dry_run) - - print(f"HappyVertical agent report: {report_path}") - if not args.dry_run: - print(f"HappyVertical agent lock: {lock_path}") - - if env_missing: - print("Missing required environment variables for enabled capabilities:", file=sys.stderr) - for item in env_missing: - print(f" {item['capability']}: {', '.join(item['missing'])}", file=sys.stderr) - return 2 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/test-hv-agent-resolver.sh b/scripts/test-hv-agent-resolver.sh deleted file mode 100755 index 01c2587..0000000 --- a/scripts/test-hv-agent-resolver.sh +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TMP_DIR"' EXIT - -DOTFILES_DIR="$TMP_DIR/dotfiles" -HAVE_CONFIG_DIR="$TMP_DIR/have-config" -CONTEXTFORGE_DIR="$TMP_DIR/contextforge" -LOCAL_DIR="$TMP_DIR/overrides" -HOME_DIR="$TMP_DIR/home" -OUTPUT_DIR="$TMP_DIR/generated" -LOCK_PATH="$TMP_DIR/agent-lock.json" -REPORT_PATH="$TMP_DIR/install-report.md" - -mkdir -p "$DOTFILES_DIR/hv" "$HAVE_CONFIG_DIR/hv" "$CONTEXTFORGE_DIR" \ - "$LOCAL_DIR/commands/codex" "$LOCAL_DIR/skills/codex/ship" "$LOCAL_DIR/agent-docs" "$HOME_DIR" - -cat > "$DOTFILES_DIR/hv/manifest.json" <<'JSON' -{ - "schema": "https://happyvertical.com/hv-agent-manifest/v1", - "layer": "dotfiles", - "priority": 10, - "commands": [ - { - "agent": "all", - "name": "review", - "content": "dotfiles review" - } - ], - "skills": [ - { - "agent": "codex", - "name": "ship", - "content": "dotfiles ship" - } - ], - "agent_docs": [ - { - "id": "dotfiles.test", - "targets": ["agents"], - "content": "Agents must use fixture-order." - } - ] -} -JSON - -cat > "$HAVE_CONFIG_DIR/hv/manifest.json" <<'JSON' -{ - "schema": "https://happyvertical.com/hv-agent-manifest/v1", - "layer": "have-config", - "priority": 20, - "commands": [ - { - "agent": "all", - "name": "review", - "content": "have-config review" - } - ], - "skills": [ - { - "agent": "codex", - "name": "ship", - "content": "have-config ship" - } - ], - "env_requirements": [ - { - "capability": "identity", - "vars": ["HV_AGENT_EMAIL"], - "default_enabled": false - } - ] -} -JSON - -cat > "$CONTEXTFORGE_DIR/manifest.json" <<'JSON' -{ - "schema": "https://happyvertical.com/hv-agent-manifest/v1", - "layer": "contextforge", - "priority": 30, - "commands": [ - { - "agent": "codex", - "name": "review", - "content": "contextforge review" - } - ], - "skills": [ - { - "agent": "codex", - "name": "ship", - "content": "contextforge ship" - } - ], - "agent_docs": [ - { - "id": "contextforge.test", - "targets": ["codex"], - "content": "Agents must not use fixture-order." - } - ] -} -JSON - -cat > "$LOCAL_DIR/commands/codex/review.md" <<'EOF' -local review -EOF - -cat > "$LOCAL_DIR/skills/codex/ship/SKILL.md" <<'EOF' -local ship -EOF - -cat > "$LOCAL_DIR/agent-docs/AGENTS.md" <<'EOF' -Local machine note. -EOF - -python3 "$ROOT_DIR/scripts/hv-agent-resolver.py" \ - --dotfiles-dir "$DOTFILES_DIR" \ - --have-config-dir "$HAVE_CONFIG_DIR" \ - --contextforge-dir "$CONTEXTFORGE_DIR" \ - --local-overrides-dir "$LOCAL_DIR" \ - --output-dir "$OUTPUT_DIR" \ - --home-dir "$HOME_DIR" \ - --lock-path "$LOCK_PATH" \ - --report-path "$REPORT_PATH" >/dev/null - -grep -q "local review" "$HOME_DIR/.codex/commands/review.md" -grep -q "have-config review" "$HOME_DIR/.claude/commands/review.md" -grep -q "local ship" "$HOME_DIR/.agents/skills/ship/SKILL.md" -grep -q "Local machine note." "$HOME_DIR/.codex/AGENTS.md" -grep -q "potential must/must-not conflict" "$REPORT_PATH" -grep -q '"key": "codex:command:review"' "$LOCK_PATH" - -if HV_ENABLED_CAPABILITIES=identity python3 "$ROOT_DIR/scripts/hv-agent-resolver.py" \ - --dotfiles-dir "$DOTFILES_DIR" \ - --have-config-dir "$HAVE_CONFIG_DIR" \ - --contextforge-dir "$CONTEXTFORGE_DIR" \ - --local-overrides-dir "$LOCAL_DIR" \ - --output-dir "$TMP_DIR/generated-env-failure" \ - --home-dir "$TMP_DIR/home-env-failure" \ - --lock-path "$TMP_DIR/env-failure-lock.json" \ - --report-path "$TMP_DIR/env-failure-report.md" >/dev/null 2>&1; then - echo "Expected missing HV_AGENT_EMAIL to fail when identity capability is enabled" >&2 - exit 1 -fi - -echo "hv-agent-resolver tests passed"