Skill: mbojer/pve-builder (version 1.0.11)

--- name: pve-builder description: Proxmox VE VM builder with cloud-init automation, config-driven hardware defaults, validation, and static IP support version: 1.0.10 author: mbojer permissions: - shell - file_read - file_write - http_request tools: - ssh_keygen - bash - file_operations - web_search --- # CRITICAL: Agent Access Limitations ## YOU DO NOT HAVE ACCESS TO PROXMox Agent runs on your local machine - NOT on Proxmox Forbidden: - Try to run qm commands - Try to run pvesh commands - Check storage availability - Verify VM creation - Access Proxmox API Must Do: - Output commands as text for user to copy/paste - Tell user which node to SSH to - Store keys locally in a configurable directory (default: ~/.ssh/pve-builder/) - Never claim to create VMs - Use web search for specs validation --- # PVE Builder Skill ## Overview Generates Proxmox VM creation commands with cloud-init configuration, SSH key management, and optional data disks. All hardware defaults are config-driven via `pve-env.md`. **IMPORTANT:** Commands are output as text for you to copy/paste into Proxmox shell. The agent does NOT execute any Proxmox commands. --- ## Environment Setup - **Config file:** `pve-env.md` in the skill directory - **Ignored from git:** `.gitignore` excludes `pve-env.md` ### Critical Configuration Keys (pve-env.md) | Section | Keys | Purpose | |---------|------|---------| | **Proxy** | `Proxy Required`, `HTTP Proxy`, `HTTPS Proxy`, `Proxy CA Certificate` | Network proxy for apt inside VMs | | **SSH** | `Default User`, `Key Path`, `Key Type` | Default SSH user, key storage location, key type | | **Network** | `Default Bridge`, `Default VLAN`, `DNS Server`, `Use DHCP Default`, `Network Interface` | Default network settings and interface type | | **Storage** | `Default Storage`, `Template Path`, `Default OS Disk Size`, `Auto-Format Data Disks`, `Data Disk Interface`, `Default Cloud Image` | Storage defaults and cloud image path | | **Node** | `Default Node`, `BIOS Type`, `Machine Type`, `CPU Type`, `OS Type`, `SCSI Controller`, `Onboot` | Hardware defaults for VM creation | | **Workload Presets** | Preset table (RAM/CPU/Disk) | Recommended specs per workload type | | **Package Defaults** | `Package Update`, `Base Packages` | Always-installed package list | ## Agent Workflow The workflow uses **section-based numbered prompts** with continuous numbering across sections: ``` === VM Specs === 1. CPU cores 2. CPU sockets 3. RAM in GB 4. OS disk size === Network === 5. Bridge 6. VLAN 7. DHCP? [if static:] 8. IP 9. Gateway 10. DNS === User & Disks === 11. SSH user 12. Add data disks? 13. Format? 14. Count 15.x: Disk sizes 15. Proxy? 16. Extra packages 17. SSH key directory ``` Steps: 1. Load `pve-env.md` (error if missing) 2. Ask cloud image path (default from config: `Template Path` + `Default Cloud Image`) 3. Ask Proxmox node (default from config) 4. **Validate storage/bridge/image** (see Validation section below) 5. Ask VM name 6. Software lookup (name or URL) → web search for RAM/CPU recommendations (or manual) 7. Prompt specs (numbered prompts: cores, sockets, RAM, OS disk) 8. Prompt network (bridge, VLAN, DHCP vs static) 9. Static IP details (only if no DHCP) 10. Prompt SSH username 11. Prompt data disks (count, sizes, formatting option) 12. Proxy configuration (yes/no/change) 13. Extra apt packages 14. SSH key directory (default from config) 15. **Password setup** — **always generate** a random password via `openssl rand -base64 12 | tr -d '/+=' | head -c16`. Use this in `chpasswd`. If the user explicitly provides a password, use theirs instead. Always show the password in the final output summary. 16. Generate SSH key (unique ed25519 per VM) 17. Show summary & confirm 18. **VMID auto-detection** — Ask the user to run `pvesh get /cluster/nextid` on the Proxmox node and paste back the result. Use that VMID in all generated commands. Never hardcode a VMID — always get the next ID from the cluster. Add a `# Replace VMID=... if already taken` comment in the output. 19. Build cloud-init user-data YAML (packages, proxy, data disk formatting) 20. **Pre-flight validation** (see Command Pre-flight Validation below) — generate commands internally, validate, fix errors, then present 21. Generate and display the final verified commands in two sections: **Setup commands** (create VM through `qm start`) and **Post-boot cleanup** (delete the snippets YAML — only run after the VM is verified up). 22. Optional: save commands to file 22. Show SSH key path and chmod reminder ## Validation Before generating commands, the agent validates that the target storage, bridge, and cloud image exist on the Proxmox node. ### Cache System - **Cache file:** `~/.pve-builder/validation.json` - **Valid for:** 24 hours - **Cache invalidated if:** node, storage, or bridge values change - **On cache hit:** validation is skipped if all checks passed ### Validation Process If no valid cache exists, the agent shows these commands for the user to run on the Proxmox node: ```bash echo "=== Storage ==="; pvesm status echo "=== Bridge ==="; ip -br link show echo "=== Image ==="; ls -la <image-path> echo "=== END ===" ``` Results are parsed: - **Storage:** Checks if configured storage name exists in `pvesm status` - **Bridge:** Verifies bridge interface is present and UP - **Image:** Confirms cloud image file exists at path **On failure:** Agent aborts and reports which check(s) failed. **On success:** Results are cached with node/storage/bridge/timestamp. ## Notes - Custom cloud-init user-data is written to `/var/lib/vz/snippets/<VMNAME>-user-data.yaml` and attached via `--cicustom "user=local:snippets/<VMNAME>-user-data.yaml"`. The `local:snippets/` storage path maps to `/var/lib/vz/snippets/` on the Proxmox node. **Never** use [REDACTED_SECRET] — that directory is not a recognized Proxmox snippets storage target. - **`--cicustom` reads the snippets file at every boot.** The snippets YAML must exist on the node when the VM starts — cloud-init won't apply without it. **Do NOT delete the snippets file before the first boot.** - **Always include `mkdir -p /var/lib/vz/snippets`** at the top of the generated commands. - **After setting `--ide2` and `--citype`, add `--cicustom`** to wire the user-data: `qm set $VMID --cicustom "user=local:snippets/${VMNAME}-user-data.yaml"`. - **After `--cicustom`, regenerate the cloud-init drive** before starting: `qm cloudinit update $VMID`. - **ssh_pwauth: set `true` when a password is configured** (so the password actually works over SSH). Set `false` only for SSH-key-only VMs. - The command to get the next VMID is provided as a hint; the agent does not run Proxmox commands - SSH keys are stored locally in the configured directory (default: `~/.ssh/pve-builder/`) - Generated commands include cleanup steps at the end: lists cloud-init YAML files for review, then removes the current VM's file. **IMPORTANT:** The cleanup step (rm snippets YAML) must NOT use `set -e` or be in the main command block. It should be in a separate "Post-boot" section that runs only after the VM is verified running and cloud-init applied. If the user deletes the YAML before the first boot, cloud-init fails silently. - **Always include `echo "VMID: <id>"` in the verify/final section** so the user has the VMID referenced ## Command Pre-flight Validation **Never present commands to the user without running this validation first.** Generate → validate → fix → present. ### Phase 1: Internal Draft Build the complete command set internally (do not show yet). ### Phase 2: Parameter Verification Script Generate a small bash validation script, set the variables to match the VM specs, and run it locally with `exec`. If it fails, fix the draft and re-run until it passes. Only then show the commands. ```bash #!/bin/bash # Pre-flight: verify parameters match intended spec # SET THESE to match the VM being built: VMID="1024"; VMNAME="MB-TBD"; RAM_MB="12288"; CORES="2"; SOCKETS="1" OS_DISK_SIZE="25G"; BRIDGE="vmbr0"; VLAN_TAG="160"; STORAGE="Data" IMAGE="/mnt/pve/ISO/template/iso/ubuntu-24.04-server-cloudimg-amd64.img" CITYPE="nocloud"; VM_GB="12" PASS=true # RAM must be multiple of 256 (( RAM_MB % 256 != 0 )) && { echo "FAIL: RAM $RAM_MB not mult. of 256"; PASS=false; } # Cores/sockets positive (( CORES < 1 )) && { echo "FAIL: CORES < 1"; PASS=false; } (( SOCKETS < 1 )) && { echo "FAIL: SOCKETS < 1"; PASS=false; } # VLAN in valid range (( VLAN_TAG < 1 || VLAN_TAG > 4094 )) && { echo "FAIL: VLAN out of range"; PASS=false; } # Disk size format [[ ! "$OS_DISK_SIZE" =~ ^[0-9]+[G]$ ]] && { echo "FAIL: OS_DISK_SIZE format"; PASS=false; } # Valid CITYPE case "$CITYPE" in nocloud|configdrive2|opennebula) ;; *) echo "FAIL: CITYPE=$CITYPE invalid"; PASS=false ;; esac # Image path not empty [ -z "$IMAGE" ] && { echo "FAIL: IMAGE path empty"; PASS=false; } # RAM_MB must match VM_GB * 1024 EXPECTED_MB=$((VM_GB * 1024)) [ "$RAM_MB" -ne "$EXPECTED_MB" ] && { echo "FAIL: RAM_MB=$RAM_MB != VM_GB=$VM_GB (expected $EXPECTED_MB)"; PASS=false; } $PASS && echo "PREFLIGHT OK" || { echo "PREFLIGHT FAILED"; exit 1; } ``` The agent: 1. Writes this script to a temp file with actual VM values 2. Runs it with `exec bash /tmp/preflight.sh` 3. If it fails, fixes values, re-runs 4. Only presents commands when `PREFLIGHT OK` ### Checklist (run after pre-flight script passes) - [ ] Shebang/header with VM name, VMID, node - [ ] `mkdir -p /var/lib/vz/snippets` - [ ] User-data written to `/var/lib/vz/snippets/$VMNAME-user-data.yaml` - [ ] `ssh_pwauth: true` if password configured, `false` if SSH-only - [ ] `--citype nocloud` (NOT `cloud-config`) - [ ] `qm create` with no `--node` - [ ] `qm importdisk` references correct image - [ ] `--scsi0` attaches imported disk - [ ] `--ide2 <storage>:cloudinit` attached - [ ] `--cicustom "user=local:snippets/${VMNAME}-user-data.yaml"` added - [ ] `qm cloudinit update $VMID` after cicustom set - [ ] **`--ipconfig0 ip=dhcp`** (DHCP) **or** `--ipconfig0 ip=<CIDR>,gw=<GW>` (static) — this actually configures the network inside the guest - [ ] `--net0 virtio,bridge=<bridge>,tag=<vlan>` - [ ] `--boot order=scsi0` - [ ] `qm resize $VMID scsi0 <size>` after import - [ ] `echo "VMID: $VMID"` in output - [ ] `qm config $VMID` for review - [ ] Cleanup note at end (remove snippets YAML) - [ ] No typos: `qm` not `vm`, `$VMID` not `$VM` - [ ] Variable names consistent throughout ## Networking ### DHCP (default) Simple network config: `qm set --ipconfig0 ip=dhcp` **Do NOT use `--nameserver` with DHCP** — let the DHCP lease provide DNS. Only set `--nameserver` when using static IP. ### Static IP When DHCP is declined, the agent prompts for: - IP address with CIDR (e.g., `10.0.12.50/24`) - Gateway (e.g., `10.0.12.1`) - DNS servers (comma-separated, default from config) Generated commands: - `qm set --ipconfig0 ip=10.0.12.50/24,gw=10.0.12.1` - `qm set --nameserver 8.8.8.8` ## Package Installation All VMs get base packages from `pve-env.md` (deduplicated with any extra packages). If proxy is configured, apt proxy is automatically enabled in cloud-init. ## Security - **Passwords: ALL VMs get a random password by default.** Generate with: `openssl rand -base64 12 | tr -d '/+=' | head -c16`. Use in both `chpasswd` in cloud-init YAML and embed in command output. If the user provides an explicit password, use theirs instead — but always show it back in output so they have it. Set `ssh_pwauth: true` unless the user explicitly says SSH-only. - SSH keys: Unique per VM, ed25519, no passphrase - pve-env.md: chmod 600, excluded from git - Private keys: chmod 600, never in commands - Public keys: Safe to embed in commands - SSH key directory: configurable via `Key Path` in pve-env.md (default `~/.ssh/pve-builder`); permissions 700 on base dir ## Version History - **1.0.10:** Add `--cicustom` warning: snippets file must survive first boot. Split output into Setup and Post-boot sections. VMID auto-detection via `cluster/nextid`. Fix workflow numbering. - **1.0.9:** Always generate random password per-VM (`openssl rand -base64 12`), user override option, always display password in output - **1.0.7:** Fix custom user-data routing: `/var/lib/vz/snippets/` + `--cicustom`, `qm cloudinit update`, `ssh_pwauth` conditional - **1.0.5:** `mkdir -p` for required dirs in generated commands, command self-review checklist, `--citype nocloud` enforced, automatic disk resize after importdisk - **1.0.3:** Full config decoupling (all hardware defaults from pve-env.md), storage/bridge validation with 24h cache, static IP support, continuous numbered prompts with section headers, SSH key path not echoed in summary, duplicate package removal, direct VMID input, cloud-init cleanup commands - **1.0.2:** Added URL analysis, web search validation, simplified proxy flow - **1.0.1:** Added explicit access limitation warnings - **1.0.0:** Initial release --- _This file is yours to evolve. As you learn who you are, update it._

path: _meta.json
content: ownerId: kn74x87hshbavsc4313x3d4smh8318ta
slug: pve-builder
version: 1.0.3
publishedAt: 1774631781381

path: agent.js
content: // pve-builder agent.js v1.0.4
// Proxmox VM builder with cloud-init, SSH keys, package management, and config-driven validation

const path = require('path');
const fs = require('fs');

// --- Validation ---
const CACHE_DIR = path.join(process.env.HOME, '.pve-builder');
const VALIDATION_CACHE = path.join(CACHE_DIR, 'validation.json');
const CACHE_VALID_MS = 24 * 60 * 60 * 1000; // 24 hours

function loadValidationCache() {
  try {
    const raw = fs.readFileSync(VALIDATION_CACHE, 'utf-8');
    const data = JSON.parse(raw);
    if (data.timestamp && (Date.now() - data.timestamp) < CACHE_VALID_MS) return data;
  } catch (e) {}
  return null;
}

function saveValidationCache(obj) {
  fs.mkdirSync(CACHE_DIR, { recursive: true });
  fs.writeFileSync(VALIDATION_CACHE, JSON.stringify(obj, null, 2), 'utf-8');
}

function checkStorageOutput(output) {
  const lines = (output || '').trim().split('\n');
  const found = [];
  for (const line of lines) {
    const parts = line.trim().split(/\s+/);
    if (parts.length >= 2 && parts[1] === 'dir') found.push({ name: parts[0], available: true });
    else if (parts.length >= 2 && parts[0] !== 'Name') found.push({ name: parts[0], available: true });
  }
  return found;
}

function checkBridgeOutput(output, bridge) {
  const lower = (output || '').toLowerCase();
  return lower.includes(bridge.toLowerCase());
}

function checkImageOutput(output, imagePath) {
  const basename = imagePath.split('/').pop();
  return (output || '').includes(basename);
}

async function promptValidation(node, storage, bridge, imagePath) {
  console.log('\n=== Proxmox Validation Required ===');
  console.log(`\nRun the following commands on **${node}** and paste the output below.\n`);
  console.log('```bash');
  console.log('echo "=== Storage ==="; pvesm status');
  console.log('echo "=== Bridge ==="; ip -br link show');
  console.log('echo "=== Image ==="; ls -la ' + imagePath);
  console.log('echo "=== END ==="');
  console.log('```\n');

  const result = await ask('Paste the output (all lines): ');
  return result || '';
}

async function validate(node, storage, bridge, imagePath) {
  // Check cache first
  const cache = loadValidationCache();
  if (cache && cache.node === node && cache.storage === storage && cache.bridge === bridge) {
    const storageOk = cache.storageValid === 'PASS';
    const bridgeOk = cache.bridgeValid === 'PASS';
    const imageOk = cache.imageValid !== 'FAIL';
    if (storageOk && bridgeOk && imageOk) {
      console.log(`\n✅ Validation cached for ${node} (storage: ${storage}, bridge: ${bridge})`);
      return true;
    }
    console.log('\n⚠️  Cached validation had failures; re-validating...');
  }

  // Prompt user to run validation commands
  const output = await promptValidation(node, storage, bridge, imagePath);

  // Split by echo markers
  const parts = {};
  const markers = ['=== Storage ===', '=== Bridge ===', '=== Image ===', '=== END ==='];
  for (let i = 0; i < markers.length - 1; i++) {
    const start = output.indexOf(markers[i]);
    const end = output.indexOf(markers[i + 1]);
    if (start !== -1 && end !== -1) {
      parts[markers[i].replace('=== ', '').replace(' ===', '').toLowerCase()] = output.substring(start + markers[i].length, end).trim();
    }
  }

  // Parse
  const storages = checkStorageOutput(parts.storage);
  const storageFound = storages.some(s => s.name === storage);
  const bridgeFound = checkBridgeOutput(parts.bridge, bridge) && parts.bridge.includes(bridge);
  const imageFound = checkImageOutput(parts.image, imagePath);

  console.log('\n--- Validation Results ---');
  const storageStatus = storageFound ? 'PASS' : 'FAIL';
  const bridgeStatus = bridgeFound ? 'PASS' : 'FAIL';
  const imageStatus = imageFound ? 'PASS' : 'FAIL';
  console.log(`Storage "${storage}": ${storageStatus}${!storageFound ? ` (available: ${storages.map(s => s.name).join(', ')})` : ''}`);
  console.log(`Bridge "${bridge}": ${bridgeStatus}`);
  console.log(`Image "${imagePath.split('/').pop()}": ${imageStatus}`);
  console.log('---\n');

  // Save cache
  saveValidationCache({
    timestamp: Date.now(),
    node,
    storage,
    bridge,
    storageValid: storageStatus,
    bridgeValid: bridgeStatus,
    imageValid: imageStatus
  });

  if (!storageFound || !bridgeFound) {
    console.log('❌ Validation failed. Check your pve-env.md settings or Proxmox node and try again.');
    return false;
  }
  if (!imageFound) {
    console.log('⚠️  Cloud image not found at configured path. You can continue but may need a different image.');
  }

  return storageFound && bridgeFound;
}

// --- Tool wrappers ---
async function ask(q) {
  if (typeof openclaw !== 'undefined' && openclaw.ask) return await openclaw.ask({ question: q });
  console.log(`[ASK] ${q}`);
  const rl = require('readline').createInterface({ input: process.stdin, output: process.stdout });
  const ans = await new Promise(r => rl.question('', a => { rl.close(); r(a); }));
  return ans;
}

async function writeFile(p, c) {
  if (typeof openclaw !== 'undefined' && openclaw.write) await openclaw.write({ file: p, content: c });
  else require('fs').writeFileSync(p, c, 'utf-8');
}

async function readFile(p) {
  if (typeof openclaw !== 'undefined' && openclaw.read) return (await openclaw.read({ file: p })).content;
  return require('fs').readFileSync(p, 'utf-8');
}

async function execCmd(cmd) {
  if (typeof openclaw !== 'undefined' && openclaw.exec) return await openclaw.exec({ command: cmd });
  return new Promise((res, rej) => {
    require('child_process').exec(cmd, (e, out, err) => e ? rej(e) : res({ stdout: out, stderr: err }));
  });
}

async function webSearch(q) {
  if (typeof openclaw !== 'undefined' && openclaw.web_search) return await openclaw.web_search({ query: q });
  console.log(`[WEB_SEARCH] ${q}`);
  return { results: [] };
}

async function webFetch(url, opts = {}) {
  if (typeof openclaw !== 'undefined' && openclaw.web_fetch) return await openclaw.web_fetch({ url, ...opts });
  console.log(`[WEB_FETCH] ${url}`);
  return { content: '' };
}

// --- Helpers ---
function parseEnv(content) {
  const env = {};
  const re = /^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|/gm;
  let m;
  while ((m = re.exec(content)) !== null) {
    const k = m[1].trim();
    let v = m[2].trim().replace(/^`|`$/g, '');
    env[k] = v;
  }
  return env;
}

function storageName(env) { return env['Default Storage'] || 'Data'; }

async function ensureDir(d) {
  try { await execCmd(`mkdir -p ${d}`); } catch (e) {}
}

async function genSshKey(vmName, keyDir, keyType = 'ed25519') {
  const base = path.join(keyDir, vmName);
  const priv = base, pub = base + '.pub';
  try { await execCmd(`ssh-keygen -t ${keyType} -f ${base} -N ''`); }
  catch (e) {
    const uniqueId = `${vmName}-${Date.now()}`;
    await writeFile(priv, `[REDACTED_SECRET]`);
    await writeFile(pub, `${keyType} AAAAC3NzaC1lZDI1NTE5AAAAI... placeholder - ${uniqueId}`);
  }
  await execCmd(`chmod 600 ${priv} ${pub}`);
  return { privateKey: priv, publicKey: pub };
}

function loadPubKey(p) {
  try { return require('fs').readFileSync(p, 'utf-8').trim(); }
  catch (e) { return 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...'; }
}

// --- Main ---
async function main() {
  console.log('=== pve-builder: Proxmox VM creation command generator ===');

  // 1. Load environment
  const envPath = path.join(__dirname, 'pve-env.md');
  let envContent;
  try { envContent = await readFile(envPath); }
  catch (e) { console.error('pve-env.md missing'); process.exit(1); }
  const env = parseEnv(envContent);

  // 2. Cloud image path (default from config)
  const templatePath = env['Template Path'] || '/mnt/pve/ISO/template/iso/';
  const defaultCloudImage = env['Default Cloud Image'] || 'ubuntu-24.04-server-cloudimg-amd64.img';
  const defaultImagePath = `${templatePath}${defaultCloudImage}`;
  const imagePath = await ask(`Cloud image path on Proxmox node (default: ${defaultImagePath}): `) || defaultImagePath;

  // 3. Proxmox node (moved up — validation needs it)
  const defNode = env['Default Node'] || 'mb-pve-03';
  const node = (await ask(`Proxmox node (default: ${defNode}): `)).trim() || defNode;

  // 4. Validate storage/bridge/image
  const storage = storageName(env);
  const bridge = env['Default Bridge'] || 'vmb0';
  const valid = await validate(node, storage, bridge, imagePath);
  if (!valid) {
    console.log('Validation failed. Aborting.');
    process.exit(1);
  }

  // 5. VM name
  const vmName = await ask('VM name: ');

  // 6. Software lookup (name or URL) → recommendations
  const swInput = await ask('Software name or info URL (blank for manual): ');
  let software = '', infoUrl = '', recommended = null;

  if (swInput.trim() === '') {
    console.log('Manual specs requested.');
  } else if (swInput.startsWith('http')) {
    infoUrl = swInput.trim();
    const derived = infoUrl.split('/').pop().replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
    console.log(`Fetching URL to extract specs (derived: "${derived}")...`);
    try {
      const fetched = await webFetch(infoUrl, { maxChars: 20000, extractMode: 'text' });
      const txt = fetched.content || '';
      const ramM = txt.match(/(\d+)\s*(?:GB|G|MB|M)\s*(?:RAM|memory|ram)/i);
      const cpuM = txt.match(/(\d+)\s*(?:cores?|cpu|v?cpu|processor)(?:\s+|$)/i);
      if (ramM) {
        const v = parseInt(ramM[1], 10);
        recommended = recommended || {};
        recommended.ram = ramM[0].toLowerCase().includes('m') ? v : v * 1024;
      }
      if (cpuM) {
        recommended = recommended || {};
        recommended.cpu = parseInt(cpuM[1], 10);
      }
      console.log('From URL:', JSON.stringify(recommended) || 'none');
      if (!recommended) {
        console.log(`No specs from URL; searching web for "${derived}"...`);
        const sr = await webSearch(`${derived} system requirements RAM CPU`);
        const s = sr.results?.[0]?.snippet || '';
        const rm = s.match(/(\d+)\s*(?:GB|G|MB|M)/i);
        const cm = s.match(/(\d+)\s*(?:cores?|cpu|processor)/i);
        if (rm) {
          const v = parseInt(rm[1], 10);
          recommended = recommended || {};
          recommended.ram = rm[0].toLowerCase().includes('m') ? v : v * 1024;
        }
        if (cm) {
          recommended = recommended || {};
          recommended.cpu = parseInt(cm[1], 10);
        }
        console.log('Web search:', recommended ? JSON.stringify(recommended) : 'none');
      }
    } catch (e) {
      console.log('URL fetch failed; continuing without recommendations.');
    }
  } else {
    software = swInput.trim();
    console.log(`Searching specs for "${software}"...`);
    try {
      const sr = await webSearch(`${software} system requirements RAM CPU disk`);
      const s = sr.results?.[0]?.snippet || '';
      const rm = s.match(/(\d+)\s*(?:GB|G|MB|M)/i);
      const cm = s.match(/(\d+)\s*(?:cores?|cpu|processor)/i);
      if (rm) {
        const v = parseInt(rm[1], 10);
        recommended = recommended || {};
        recommended.ram = rm[0].toLowerCase().includes('m') ? v : v * 1024;
      }
      if (cm) {
        recommended = recommended || {};
        recommended.cpu = parseInt(cm[1], 10);
      }
      console.log('Web:', recommended ? JSON.stringify(recommended) : 'none');
    } catch (e) {
      console.log('Web search failed; proceeding manually.');
    }
  }

  // 7. Specs: numbered order: CPU cores, sockets, RAM (GB), OS disk (GB)
    console.log('\n=== VM Specs ===');
  const cpu = await ask(`1. CPU cores${recommended?.cpu ? ` (recommended: ${recommended.cpu})` : ''}: `);
  const sockets = await ask(`2. CPU sockets (default 1): `) || '1';
  const ramGb = await ask(`3. RAM in GB (e.g., 4): `);
  const osDiskGb = await ask(`4. OS disk size in GB (e.g., 12): `);

  const ramMb = ramGb ? (parseInt(ramGb, 10) * 1024).toString() : '';
  const osDisk = osDiskGb ? `${osDiskGb}G` : '';

  // 8. Network - numbered prompts
  // 8. Network - numbered prompts
  console.log('\n=== Network ===');
  const defBridge = env['Default Bridge'] || 'vmb0';
  const selBridge = await ask(`5. Bridge (default: ${defBridge}): `) || defBridge;
  const defVlan = env['Default VLAN'] || '12';
  const vlan = await ask(`6. VLAN tag (default: ${defVlan}): `) || defVlan;
  const dhcpAns = await ask(`7. Use DHCP? (yes/no, default yes): `);
  const useDhcp = dhcpAns === '' || dhcpAns.toLowerCase() === 'yes';

  // Static IP details
  const staticIp = { address: '', gw: '', dns: '' };
  if (!useDhcp) {
    const defaultDns = env['DNS Server'] || '8.8.8.8';
    staticIp.address = await ask(`8. Static IP (e.g., 10.0.12.50/24): `);
    staticIp.gw = await ask(`9. Gateway IP (e.g., 10.0.12.1): `);
    staticIp.dns = await ask(`10. DNS server(s), comma-separated (default: ${defaultDns}): `) || defaultDns;
  }

  // 9. SSH username
  console.log('\n=== User & Disks ===');
  const sshUser = await ask(`11. SSH username for cloud-init: `);

  const hasData = await ask(`12. Add data disks? (yes/no): `);
  const dataDisks = [];
  let formatData = false;
  if (hasData.toLowerCase() === 'yes') {
    const autoFormatDefault = env['Auto-Format Data Disks'] === 'true' ? 'yes' : 'no';
    formatData = (await ask(`13. Format data disks on first boot? (yes/no, default ${autoFormatDefault}): `) || autoFormatDefault).toLowerCase() === 'yes';
    const count = parseInt(await ask(`14. Number of data disks: `), 10) || 1;
    for (let i = 0; i < count; i++) {
      const size = await ask(`Disk ${i+1} size (e.g., 20G): `);
      dataDisks.push({ size });
    }
  }

  // 11. Proxy
  const proxyReq = env['Proxy Required'] === 'true';
  let proxyChoice = await ask(`15. Proxy configuration required? (yes/no${proxyReq ? '/change' : ''}): `);
  if (proxyChoice === 'change' && proxyReq) {
    console.log('Update proxy settings in pve-env.md manually.');
    proxyChoice = await ask(`Proceed without proxy? (yes/no): `);
  }
  const useProxy = proxyChoice === 'yes';

  // 12. Extra apt packages
  const basePkgs = (env['Base Packages'] || '').split(',').map(s => s.trim()).filter(Boolean);
    const extraInput = await ask(`16. Extra apt packages (comma-separated, default none): `);
  const extraPkgs = extraInput ? extraInput.split(',').map(s => s.trim()).filter(Boolean) : [];
  const allPkgs = [...new Set([...basePkgs, ...extraPkgs])];

  // 13. SSH key directory
  let sshKeysDir = env['Key Path'] || path.join(process.env.HOME, '.ssh', 'pve-builder');
    const customDir = await ask(`17. SSH keys directory (default: ${sshKeysDir}): `);
  if (customDir && customDir.trim() !== '') sshKeysDir = customDir.trim();
  await ensureDir(sshKeysDir);

  // 14. Generate SSH key
  console.log(`Generating SSH key for ${vmName} in ${sshKeysDir}...`);
  const keys = await genSshKey(vmName, sshKeysDir, env['Key Type'] || 'ed25519');
  console.log(`Key pair created: ${vmName} (unique ed25519 key)`);

  // 15. Summary
  console.log('\n=== Summary ===');
  if (software) console.log(`Software: ${software}`);
  if (infoUrl) console.log(`Info URL: ${infoUrl}`);
  console.log(`VM Name: ${vmName}`);
  console.log(`CPU: ${cpu} cores, ${sockets} sockets`);
  console.log(`RAM: ${ramMb} MB (${ramGb} GB)`);
  console.log(`OS Disk: ${osDisk}`);
  if (!useDhcp) {
    console.log(`Network: bridge=${selBridge}, vlan=${vlan}, static ip=${staticIp.address}, gw=${staticIp.gw}, dns=${staticIp.dns}`);
  } else {
    console.log(`Network: bridge=${selBridge}, vlan=${vlan}, dhcp=yes`);
  }
  console.log(`SSH User: ${sshUser}`);
  console.log(`SSH Key: ${vmName} / ${sshKeysDir}`);
  if (dataDisks.length) console.log(`Data disks: ${dataDisks.map(d => d.size).join(', ')} (format: ${formatData})`);
  console.log(`Proxy: ${useProxy ? 'yes' : 'no'}`);
  console.log(`Node: ${node}`);

  const proceed = await ask('\nProceed to generate commands? (yes/no): ');
  if (proceed.toLowerCase() !== 'yes') {
    console.log('Cancelled.');
    return;
  }

  // 16. VMID acquisition (auto helper)
  console.log('\nVMID selection:');
  let vmid;
  const vmChoice = (await ask('  auto (fetch next) / manual / or type a VMID directly: ')).trim() || 'auto';
  if (vmChoice.match(/^\d+$/)) {
    // User typed a VMID directly
    vmid = vmChoice;
    console.log('  VMID: ' + vmid);
  } else if (vmChoice.toLowerCase() === 'auto') {
    console.log('\n# On the Proxmox node, run to get next VMID:');
    console.log('nextid=$(pvesh get /cluster/nextid --output-format json | jq -r \'.data\'); echo $nextid');
    const nextidInput = (await ask('\nEnter the VMID number you retrieved: ')).trim();
    if (!nextidInput) {
      console.log('No VMID provided; using placeholder <VMID>.');
      vmid = '<VMID>';
    } else {
      vmid = nextidInput;
    }
  } else {
    // manual or anything else — ask for number
    vmid = (await ask('Enter VMID: ')).trim() || '<VMID>';
  }

  // 17. Build cloud-init user-data YAML
  let userData = `#cloud-config\npackage_update: true\npackage_upgrade: false\npackages:\n`;
  allPkgs.forEach(p => { userData += `  - ${p}\n`; });
  userData += `\nruncmd:\n  - systemctl enable qemu-guest-agent\n  - systemctl start qemu-guest-agent\n  - echo "Cloud-init complete at $(date)" >> /var/log/cloud-init-complete.log\n`;
  if (useProxy && env['Proxy Required'] === 'true') {
    const httpProxy = env['HTTP Proxy'] || '';
    const httpsProxy = env['HTTPS Proxy'] || '';
    if (httpProxy || httpsProxy) {
      userData += `\napt:\n  proxy: ${httpProxy}\n  https_proxy: ${httpsProxy}\n`;
    }
    const caCert = env['Proxy CA Certificate'] || '';
    if (caCert && caCert.includes('BEGIN CERTIFICATE')) {
      userData += `ca_certs:\n  remove_defaults: false\n  trusted:\n    - |\n`;
      caCert.split('\n').forEach(line => {
        if (line.trim()) userData += `      ${line}\n`;
      });
      userData += `\nruncmd:\n  - update-ca-certificates\n  - apt update\n`;
    }
  }

  // Add data disk formatting if requested
  if (formatData && dataDisks.length) {
    const iface = env['Data Disk Interface'] || 'scsi';
    dataDisks.forEach((d, i) => {
      const device = iface === 'nvme' ? `/dev/nvme0n${i+1}` : `/dev/${iface === 'virtio' ? 'v' : 's'}d${String.fromCharCode(98 + i)}`;
      userData += `  - mkfs.ext4 ${device}\n`;
    });
    const firstDisk = env['Data Disk Interface'] === 'nvme' ? '/dev/nvme0n1' : '/dev/sdb';
    userData += `  - mkdir -p /data\n  - mount ${firstDisk} /data\n  - echo "${firstDisk} /data ext4 defaults 0 2" >> /etc/fstab\n`;
  }

  const userDataPath = `/var/lib/vz/template/cloud-init/${vmName}-user-data.yaml`;

  // 18. Generate commands
  const storageForCmd = storageName(env);
  const pubKeyContent = loadPubKey(keys.publicKey);

  const cpuType = env['CPU Type'] || 'x86-64-v2-AES';
  const machineType = env['Machine Type'] || 'q35';
  const biosType = env['BIOS Type'] || 'seabios';
  const osType = env['OS Type'] || 'l26';
  const scsiCtrl = env['SCSI Controller'] || 'virtio-scsi-pci';
  const onboot = env['Onboot'] || '0';
  const netIface = env['Network Interface'] || 'virtio';
  const dataDiskIface = env['Data Disk Interface'] || 'scsi';

  const lines = [];
  lines.push(`# SSH to the Proxmox node first`);
  lines.push(`ssh root@${node}`);
  lines.push('');
  lines.push(`# Create cloud-init user-data`);
  lines.push(`mkdir -p /var/lib/vz/template/cloud-init`);
  lines.push(`cat <<'EOF' > ${userDataPath}`);
  lines.push(userData.trim());
  lines.push(`EOF`);
  lines.push('');
  lines.push(`# Create VM`);
  lines.push(`qm create ${vmid} \\`);
  lines.push(`  --name ${vmName} \\`);
  lines.push(`  --memory ${ramMb} \\`);
  lines.push(`  --cores ${cpu} \\`);
  lines.push(`  --sockets ${sockets} \\`);
  lines.push(`  --cpu ${cpuType} \\`);
  lines.push(`  --machine ${machineType} \\`);
  lines.push(`  --bios ${biosType} \\`);
  lines.push(`  --ostype ${osType} \\`);
  lines.push(`  --scsihw ${scsiCtrl} \\`);
  lines.push(`  --onboot ${onboot}`);
  lines.push('');
  lines.push(`# Import cloud image as OS disk`);
  lines.push(`qm importdisk ${vmid} ${imagePath} ${storageForCmd} --format raw`);
  lines.push(`qm set ${vmid} --scsi0 ${storageForCmd}:vm-${vmid}-disk-0,discard=on,ssd=1`);
  lines.push(`qm resize ${vmid} --scsi0 ${osDisk}`);
  lines.push('');
  lines.push(`# Network`);
  lines.push(`qm set ${vmid} --net0 ${netIface},bridge=${selBridge}${vlan ? `,tag=${vlan}` : ''}`);
  lines.push('');
  lines.push(`# Cloud-init`);
  lines.push(`qm set ${vmid} --ide0 ${storageForCmd}:cloudinit`);
  if (useDhcp) {
    lines.push(`qm set ${vmid} --ipconfig0 ip=dhcp`);
  } else {
    const ipStr = `ip=${staticIp.address}`;
    const gwStr = staticIp.gw ? `,gw=${staticIp.gw}` : '';
    lines.push(`qm set ${vmid} --ipconfig0 ${ipStr}${gwStr}`);
    const dnsList = staticIp.dns.split(',').filter(Boolean);
    lines.push(`qm set ${vmid} --nameserver ${dnsList.join(' ')}`);
  }
  lines.push(`qm set ${vmid} --ciuser ${sshUser}`);
  lines.push(`qm set ${vmid} --sshkeys "${pubKeyContent}"`);
  lines.push(`qm set ${vmid} --cicustom user=${userDataPath}`);
  lines.push(`qm cloudinit update ${vmid}`);
  lines.push('');
  lines.push(`# Boot order and guest agent`);
  lines.push(`qm set ${vmid} --boot order=scsi0`);
  lines.push(`qm set ${vmid} --agent enabled=1`);
  lines.push(`qm set ${vmid} --tablet 0`);
  if (dataDisks.length) {
    lines.push('');
    lines.push(`# Data disks`);
    for (let i = 0; i < dataDisks.length; i++) {
      const ifaceFlag = `--${dataDiskIface}${i + 1}`;
      lines.push(`qm set ${vmid} ${ifaceFlag} ${storageForCmd}:${dataDisks[i].size},discard=on,ssd=1`);
    }
  }
  lines.push('');
  lines.push(`# Start VM`);
  lines.push(`qm start ${vmid}`);
  lines.push('');
  lines.push(`# SSH into VM`);
  lines.push(`ssh -i ${keys.privateKey} ${sshUser}@<VM_IP>`);
  lines.push('');
  lines.push(`# Note: Replace <VM_IP> after cloud-init completes.`);
  lines.push('');
  lines.push(`# Cleanup: list cloud-init YAML files`);
  lines.push(`echo "=== Current cloud-init YAML files ===" && ls -la /var/lib/vz/template/cloud-init/*.yaml 2>/dev/null || echo "(none)"`);
  lines.push(`# Review the list above. Delete confirmed files one by one:`);
  lines.push(`rm -v /var/lib/vz/template/cloud-init/${vmName}-user-data.yaml`);
  lines.push(`# Or remove all stale files (older than 30 days):`);
  lines.push(`# ls /var/lib/vz/template/cloud-init/*.yaml`);
  lines.push(`# rm -v /var/lib/vz/template/cloud-init/<FILE.yaml>  # repeat for each file`);

  const output = lines.join('\n');

  console.log('\n=== Generated Commands ===\n');
  console.log(output);
  console.log('\n=== End ===');

  const save = await ask('\nSave commands to file? (path or skip): ');
  if (save && save.trim()) {
    try { await writeFile(save.trim(), output); console.log(`Saved to ${save.trim()}`); }
    catch (e) { console.error('Save failed:', e.message); }
  }

  console.log('\n🔑 SSH key generated: ' + vmName + ' in ' + sshKeysDir);
  console.log('Run: chmod 600 ' + keys.privateKey);
  console.log('Use: ssh -i ' + keys.privateKey + ' ' + sshUser + '@<VM_IP>');
}

main().catch(e => { console.error('Error:', e); process.exit(1); });


path: qm-reference.md
content: # ============================================================
# Proxmox QM Command Reference
# ============================================================
# For use with pve-builder skill
# ============================================================

# CRITICAL LIMITATIONS

## Node Parameter

DO NOT USE --node parameter with qm commands

# Wrong:
qm create 100 --node MB-PVE-03 --name test

# Correct:
# SSH to the node first, then run qm without --node
ssh root@MB-PVE-03
qm create 100 --name test

# Reason: qm is a node-local command, not a cluster command

## Access Requirements

- Run qm commands on the Proxmox node itself
- No remote execution supported
- pve-builder agent outputs commands for you to copy/paste
- Agent cannot access Proxmox directly

# Common QM Commands

## Create VM

qm create <VMID> \
  --name <VM-NAME> \
  --memory <MEMORY_MB> \
  --cores <CORES> \
  --sockets <SOCKETS> \
  --cpu <CPU_TYPE> \
  --machine <MACHINE_TYPE> \
  --bios <BIOS_TYPE> \
  --ostype l26 \
  --scsihw virtio-scsi-pci \
  --onboot 0

# Note: --cpu, --machine, --bios, --ostype values are read from pve-env.md

## Import Disk (Cloud Image)

qm importdisk <VMID> <PATH_TO_IMAGE> <STORAGE> --format raw
qm set <VMID> --scsi0 <STORAGE>:vm-<VMID>-disk-0,discard=on,ssd=1
qm resize <VMID> --scsi0 <SIZE>

## Network Configuration

### DHCP
qm set <VMID> --net0 <INTERFACE_TYPE>,bridge=<bridge>,tag=<VLAN>
qm set <VMID> --ipconfig0 ip=dhcp

### Static IP
qm set <VMID> --net0 <INTERFACE_TYPE>,bridge=<bridge>,tag=<VLAN>
qm set <VMID> --ipconfig0 ip=<IP/CIDR>,gw=<GATEWAY>
qm set <VMID> --nameserver <DNS1> <DNS2> ...

## Cloud-Init Setup

# Attach cloud-init drive
qm set <VMID> --ide0 <STORAGE>:cloudinit

# Configure network
qm set <VMID> --ipconfig0 ip=dhcp
# or static: qm set <VMID> --ipconfig0 ip=10.0.12.50/24,gw=10.0.12.1

# Set user and SSH key (either sshkeys OR cipassword, not both)
qm set <VMID> --ciuser <USER>
qm set <VMID> --sshkeys "ssh-ed25519 AAAAC...PUBLIC-KEY-CONTENT..."

# Optional: DNS (for static IP)
qm set <VMID> --nameserver 8.8.8.8

# Optional: Custom user-data (proxy, packages, disk formatting)
qm set <VMID> --cicustom user=/path/to/user-data.yaml

# Update cloud-init after changes
qm cloudinit update <VMID>

# Note: cloud-init runs on first boot automatically
# No need for --ciupgrade if proxy is required

## Boot Order and Settings

qm set <VMID> --boot order=scsi0
qm set <VMID> --agent enabled=1
qm set <VMID> --tablet 0
qm set <VMID> --onboot 0

## Add Data Disks

# SCSI disk (recommended for Linux, default interface)
qm set <VMID> --scsi1 <STORAGE>:20G,discard=on,ssd=1

# VirtIO disk (alternative)
qm set <VMID> --virtio1 <STORAGE>:10G,discard=on

# NVMe disk (high performance)
qm set <VMID> --nvme1 <STORAGE>:50G,discard=on

# Note: Data disk interface type is configurable via `Data Disk Interface` in pve-env.md.
# Default: scsi. Each disk is numbered sequentially (--scsi1, --scsi2, --scsi3, etc.)

## Data Disk Formatting (via cloud-init)

When data disks are added with auto-format enabled, the cloud-init user-data YAML
includes mkfs and mount commands. Example for 2 disks (sdb, sdc):

# In user-data.yaml runcmd section:
runcmd:
  - mkfs.ext4 /dev/sdb
  - mkfs.ext4 /dev/sdc
  - mkdir -p /data
  - mount /dev/sdb /data
  - echo "/dev/sdb /data ext4 defaults 0 2" >> /etc/fstab

# Device mapping depends on interface type:
# - scsi/ide → /dev/sdX
# - virtio   → /dev/vdX
# - nvme     → /dev/nvme0nX

## VM Management

# Start VM
qm start <VMID>

# Stop VM
qm stop <VMID>

# Restart VM
qm restart <VMID>

# Shutdown VM (graceful)
qm shutdown <VMID>

# Delete VM
qm destroy <VMID>

# View VM config
qm config <VMID>

# List all VMs
qm list

# Cloud-Init Configuration Reference

## User-Data YAML Template

#cloud-config

# Apt Proxy (if needed)
apt:
  proxy: http://proxy.yourdomain.local:3128
  https_proxy: http://proxy.yourdomain.local:3128

# Packages (install on first boot)
package_update: true
package_upgrade: false
packages:
  - qemu-guest-agent
  - nano
  - curl
  - wget
  - btop
  - bind9-dnsutils
  - iputils-ping

# Proxy CA Certificate (if needed)
ca_certs:
  remove_defaults: false
  trusted:
    - |
      [REDACTED_SECRET]

# Run commands on first boot
runcmd:
  - systemctl enable qemu-guest-agent
  - systemctl start qemu-guest-agent
  - echo "Cloud-init complete at $(date)" >> /var/log/cloud-init-complete.log

# Format and mount data disks (if enabled)
runcmd:
  - mkfs.ext4 /dev/sdb    # First data disk (scsi)
  - mkdir -p /data
  - mount /dev/sdb /data
  - echo "/dev/sdb /data ext4 defaults 0 2" >> /etc/fstab

  - mkfs.ext4 /dev/sdc    # Second data disk (scsi)

# Data disk device mapping:
#   scsi → /dev/sdX (sdb, sdc, sdd...)
#   virtio → /dev/vdX (vdb, vdc, vdd...)
#   nvme → /dev/nvme0nX (nvme0n1, nvme0n2, nvme0n3...)

## Network-Config YAML Template

# Version 2 (Default - DHCP)
network:
  version: 2
  ethernets:
    eth0:
      dhcp4: true
      nameservers:
        addresses:
          - 8.8.8.8
          - 1.1.1.1

# Static IP
network:
  version: 2
  ethernets:
    eth0:
      dhcp4: false
      addresses:
        - 10.0.12.50/24
      gateway4: 10.0.12.1
      nameservers:
        addresses:
          - 8.8.8.8

# Note: qm set --nameserver handles DNS. Custom network-config can override.

# Disk Interface Comparison

| Interface | Max Disks | Performance | Use Case |
|-----------|-----------|-------------|----------|
| scsi0 - scsi30 | 31 | Best | Linux VMs (OS + data) |
| virtio0 - virtio15 | 16 | Best | Linux VMs (alternative) |
| sata0 - sata5 | 6 | Good | CD-ROM, slower disks |
| ide0 - ide3 | 4 | Slow | Cloud-init only |
| nvme0 - nvme15 | 16 | Best | High-performance storage |

# Proxy Considerations

## Apt Proxy Configuration

# In cloud-init user-data:
apt:
  proxy: http://proxy.yourdomain.local:3128
  https_proxy: http://proxy.yourdomain.local:3128

## CA Certificate Installation

# In cloud-init user-data:
ca_certs:
  remove_defaults: false
  trusted:
    - |
      [REDACTED_SECRET]
runcmd:
  - update-ca-certificates
  - apt update

# Validation Commands

## On Proxmox Node

# Check VM configuration
qm config <VMID>

# Check cloud-init configuration
qm cloudinit dump <VMID>

# Check task log
pvesh get /nodes/[NODE]/tasks

# List all VM
qm list

# Check storage
pvesm status

## On VM After Boot

# Check cloud-init logs
cat /var/log/cloud-init-output.log
cat /var/log/cloud-init.log

# Check packages installed
apt list --installed | grep -E "qemu|nano|curl|wget|btop"

# Check proxy configuration
env | grep proxy

# Check data disk mounted
df -h
ls -la /data

# Troubleshooting

## Common Issues

| Issue | Solution |
|-------|----------|
| qm: command not found | Run commands on Proxmox node, not locally |
| --node: invalid option | Remove --node, SSH to target node first |
| Cloud-init not running | Verify cloud-init device attached (ide0) and custom user-data (cicustom) |
| SSH access denied | Check private key permissions (chmod 600) |
| Data disk not mounted | Check cloud-init runcmd executed in logs |
| Proxy failing | Verify CA Certificate is valid and apt proxy is set |
| VM not starting | Check boot order (boot order=scsi0) |
| Static IP not applied | Verify --ipconfig0 and --nameserver are both set |
| Network unreachable | Verify bridge name and VLAN tag match Proxmox config |
| Validation failed | Run pvesm status / ip link to check storage and bridge exist |

## USEFUL TIPS

## Recommended Workflow

1. Configure pve-env.md with your defaults (bridge, storage, node, SSH key path)
2. Run pve-builder to generate VM creation commands
3. Review validation output (storage/bridge/image checks)
4. SSH to Proxmox node: ssh root@[NODE]
5. Paste commands into Proxmox shell
6. Wait for VM to start and cloud-init to complete
7. SSH to VM: ssh -i <SSH_KEY_DIR>/[VM-NAME] [USER]@[VM_IP]
8. Verify services and disk mounts

## Best Practices

- Always use SSH keys instead of passwords
- Generate unique SSH key per VM
- Store keys in a configurable directory (default: ~/.ssh/pve-builder/)
- Set private key permissions to chmod 600
- Never commit private keys or pve-env.md to git
- Use cloud-init for package installation
- Skip --ciupgrade if proxy is required
- Add data disks for application storage
- Keep --onboot = 0 (do not auto-start until verified)
- Remove cloud-init YAML files after deployment (cleanup in generated commands)

## Security Checklist

- [ ] SSH keys generated per VM (unique, ed25519)
- [ ] Private key permissions set to 600
- [ ] Private keys never in commands or git
- [ ] pve-env.md permissions set to 600
- [ ] .gitignore excludes pve-env.md
- [ ] Passwords not used in cloud-init

# ============================================================
# Last Updated: 2026-03-31
# ============================================================


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)