Advanced CLI Patterns

1. Overview

This guide covers advanced bash patterns for working with netapi, including heredocs, multi-line commands, configuration file generation, and handling complex automation scenarios.

When to use these patterns:

  • Creating ACLs or configuration files

  • Multi-step device configuration

  • Batch operations

  • Complex automation workflows

2. Heredocs - The Power Pattern

Heredocs (Here Documents) allow you to create multi-line content inline in your shell scripts. They’re incredibly powerful for generating configuration files, ACLs, and multi-command sequences.

2.1. Basic Heredoc Syntax

cat > /path/to/file << 'EOF'
line 1
line 2
line 3
EOF

Use single quotes around EOF ('EOF') to prevent variable expansion!

  • << 'EOF' - Literal text, no variable substitution (RECOMMENDED)

  • << EOF - Variables like $var will be expanded

2.2. Creating ISE dACL Files

The most common use case: creating downloadable ACL files for Cisco ISE.

# Create a zero-trust dACL file using heredoc
cat > /tmp/LINUX_RESEARCH_ZERO_TRUST.txt << 'EOF'
permit icmp any any
permit udp any host 10.50.1.1 eq 53
permit udp any host 10.50.1.50 eq 53
permit udp any eq 53 any gt 1023
permit udp any host 10.50.1.1 eq 123
permit udp any eq 123 any gt 1023
permit tcp any host 10.50.1.50 eq 88
permit udp any host 10.50.1.50 eq 88
permit tcp any host 10.50.1.50 eq 389
permit tcp any host 10.50.1.50 eq 636
permit tcp any host 10.50.1.50 eq 445
permit tcp any host 10.50.1.21 eq 8443
permit tcp any host 10.50.1.21 eq 8905
permit tcp any eq 22 10.50.0.0 0.0.255.255 gt 1023
permit tcp any any eq 80
permit tcp any eq 80 any gt 1023
permit tcp any any eq 443
permit tcp any eq 443 any gt 1023
deny ip any 10.0.0.0 0.255.255.255
deny ip any 172.16.0.0 0.15.255.255
deny ip any 192.168.0.0 0.0.255.255
EOF

# Verify the file
cat /tmp/LINUX_RESEARCH_ZERO_TRUST.txt

# Upload to ISE
uv run netapi ise create-dacl LINUX_RESEARCH_ZERO_TRUST \
  --file /tmp/LINUX_RESEARCH_ZERO_TRUST.txt \
  --descr "Zero-trust ACL for domain-joined Linux workstations"

Why this is better than inline --acl parameter:

✅ Easier to read and maintain (ACL is formatted like Cisco config) ✅ Can validate syntax before uploading ✅ Version control friendly (commit the ACL file) ✅ Reusable across multiple profiles ✅ No semicolon/escaping issues

2.3. Multi-Step Switch Configuration

For switch configuration that requires multiple commands, use heredoc to create a reusable script.

# Create a CoA configuration script
cat >| /tmp/configure-coa.sh << 'EOF'
#!/bin/bash
# Configure RADIUS dynamic-author for ISE CoA
# Note: Use --timeout 5 and || true to handle netmiko prompt timeouts

uv run netapi ios run --timeout 5 "configure terminal" || true
uv run netapi ios run --timeout 5 "aaa server radius dynamic-author" || true
uv run netapi ios run --timeout 5 "client 10.50.1.20 server-key Cisco123\!ISE" || true
uv run netapi ios run --timeout 5 "client 10.50.1.21 server-key Cisco123\!ISE" || true
uv run netapi ios run --timeout 5 "exit" || true
uv run netapi ios run --timeout 5 "end" || true
uv run netapi ios run --timeout 5 "write memory" || true
EOF

# Make executable
chmod +x /tmp/configure-coa.sh

# Run the script
/tmp/configure-coa.sh

# Verify configuration applied
uv run netapi ios exec "show run | section dynamic-author"

Why || true and --timeout 5?

Netmiko may fail to detect the switch prompt after configuration commands, causing ReadTimeout errors. However, the commands DO execute successfully (you’ll see "Building configuration…​ [OK]"). Using || true allows the script to continue despite these harmless timeouts.

2.4. Creating Batch Files

Generate batch files for repetitive operations.

# Create a batch file to add multiple AD groups to ISE
cat > /tmp/add-ad-groups.sh << 'EOF'
#!/bin/bash
# Add all clinical department AD groups to ISE

GROUPS=(
  "GRP-Clinical-Physicians"
  "GRP-Clinical-Nurses"
  "GRP-Clinical-Residents"
  "GRP-IT-Network-Engineers"
  "GRP-IT-Security-Team"
)

for group in "${GROUPS[@]}"; do
  echo "Adding group: $group"
  uv run netapi ise add-ad-groups "inside.domusdigitalis.dev" "$group" \
    --dict "INSIDE-AD" || true
  sleep 2  # Rate limit API calls
done

echo "Done! Groups added to ISE."
EOF

chmod +x /tmp/add-ad-groups.sh
/tmp/add-ad-groups.sh

2.5. Templated Configuration with Variables

Use heredoc WITH variable expansion (no quotes around EOF) for templated configs.

# Set variables
SWITCH_IP="10.50.1.10"
ISE_PSN1="10.50.1.20"
ISE_PSN2="10.50.1.21"
RADIUS_KEY="Cisco123!ISE"

# Create config script with variable substitution
cat > /tmp/configure-switch-${SWITCH_IP}.sh << EOF
#!/bin/bash
# Auto-generated switch configuration for ${SWITCH_IP}

echo "Configuring switch at ${SWITCH_IP}"

uv run netapi ios run --timeout 5 "configure terminal" || true
uv run netapi ios run --timeout 5 "aaa server radius dynamic-author" || true
uv run netapi ios run --timeout 5 "client ${ISE_PSN1} server-key ${RADIUS_KEY}" || true
uv run netapi ios run --timeout 5 "client ${ISE_PSN2} server-key ${RADIUS_KEY}" || true
uv run netapi ios run --timeout 5 "end" || true
uv run netapi ios run --timeout 5 "write memory" || true

echo "Configuration complete for ${SWITCH_IP}"
EOF

chmod +x /tmp/configure-switch-${SWITCH_IP}.sh
/tmp/configure-switch-${SWITCH_IP}.sh

Notice the difference:

  • << 'EOF' - Single quotes = Literal text (variables NOT expanded)

  • << EOF - No quotes = Variables ARE expanded

Choose based on your needs!

3. Advanced Pipeline Patterns

3.1. Process Line-by-Line with While Read

Execute a command for each line in a file or heredoc.

# Method 1: From heredoc
cat << 'EOF' | while read cmd; do
  [ -z "$cmd" ] || uv run netapi ios run "$cmd"
done
configure terminal
interface GigabitEthernet1/0/5
description Connected to modestus-p50
no shutdown
end
write memory
EOF

# Method 2: From file
while read switch; do
  echo "Checking switch: $switch"
  uv run netapi ios exec "show version" --host "$switch"
done < /tmp/switch-list.txt

3.2. Parallel Execution for Speed

Run multiple independent commands simultaneously.

# Create a function to run commands in parallel
run_checks() {
  local switch=$1
  echo "=== Checking $switch ==="
  uv run netapi ios exec "show version" --host "$switch" &
}

# Export function for subshells
export -f run_checks

# Run checks in parallel for all switches
cat << 'EOF' | xargs -I {} -P 5 bash -c 'run_checks "$@"' _ {}
10.50.1.10
10.50.1.11
10.50.1.12
10.50.1.13
10.50.1.14
EOF

# Wait for all background jobs to complete
wait
echo "All checks complete!"

3.3. Error Handling and Logging

Add robust error handling to your scripts.

cat > /tmp/robust-config.sh << 'EOF'
#!/bin/bash
set -euo pipefail  # Exit on error, undefined vars, pipe failures

# Logging function
log() {
  echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" | tee -a /tmp/netapi-config.log
}

# Error handler
error_exit() {
  log "ERROR: $1"
  exit 1
}

# Configuration commands
log "Starting switch configuration"

uv run netapi ios run "configure terminal" || error_exit "Failed to enter config mode"
uv run netapi ios run "aaa server radius dynamic-author" || error_exit "Failed to configure dynamic-author"
uv run netapi ios run "client 10.50.1.21 server-key Cisco123\!ISE" || error_exit "Failed to add client"
uv run netapi ios run "end" || true
uv run netapi ios run "write memory" || error_exit "Failed to save config"

log "Configuration completed successfully"
EOF

chmod +x /tmp/robust-config.sh
/tmp/robust-config.sh

4. Practical Examples

4.1. Complete Workflow: Create and Deploy dACL

#!/bin/bash
# Complete workflow: Create, upload, and apply a dACL

DACL_NAME="LINUX_MEDICAL_WORKSTATION"
PROFILE_NAME="Medical_Linux_EAP_TLS"

# Step 1: Create dACL file
log "Creating dACL file..."
cat > "/tmp/${DACL_NAME}.txt" << 'EOF'
permit icmp any any
permit udp any host 10.50.1.50 eq 53
permit tcp any host 10.50.1.50 eq 88
permit tcp any host 10.50.1.50 eq 389
permit tcp any host 10.100.10.10 eq 443
permit tcp any host 10.100.10.11 eq 1433
deny ip any 10.0.0.0 0.255.255.255
EOF

# Step 2: Upload to ISE
log "Uploading dACL to ISE..."
uv run netapi ise create-dacl "$DACL_NAME" \
  --file "/tmp/${DACL_NAME}.txt" \
  --descr "Medical workstation zero-trust ACL"

# Step 3: Apply to authorization profile
log "Applying dACL to profile..."
uv run netapi ise update-authz-profile "$PROFILE_NAME" \
  --dacl "$DACL_NAME"

# Step 4: Verify
log "Verifying dACL..."
uv run netapi ise get-dacl "$DACL_NAME"

log "Deployment complete!"

4.2. Batch Endpoint Provisioning

# Provision multiple endpoints at once
cat > /tmp/provision-endpoints.sh << 'EOF'
#!/bin/bash

# Endpoint data
cat << 'DATA' | while IFS=',' read mac desc group; do
  echo "Provisioning: $mac ($desc)"
  uv run netapi ise create-endpoint "$mac" \
    --description "$desc" \
    --group "$group" || true
  sleep 1  # Rate limiting
done
AA:BB:CC:DD:EE:01,Medical Cart 1,Medical-Devices
AA:BB:CC:DD:EE:02,Medical Cart 2,Medical-Devices
AA:BB:CC:DD:EE:03,Nurse Station PC,Clinical-Workstations
AA:BB:CC:DD:EE:04,Physician Laptop,Clinical-Laptops
DATA

echo "Endpoint provisioning complete!"
EOF

chmod +x /tmp/provision-endpoints.sh
/tmp/provision-endpoints.sh

5. Best Practices

Practice Reason

Use 'EOF' (single quotes)

Prevents unintended variable expansion

Add set -euo pipefail

Exit on errors, catch issues early

Include logging

Debug issues, track execution history

Add || true for netmiko

Handle prompt timeout errors gracefully

Use --timeout 5

Don’t wait forever for prompts

Validate before applying

cat /tmp/file.txt before create-dacl

Save scripts to /tmp

Easy cleanup, no repo clutter

Make scripts executable

chmod +x script.sh before running

6. Common Pitfalls

Shell metacharacter issues:

  • ! in passwords - Use \! or single quotes

  • $ in strings - Quote properly or escape

  • Spaces in commands - Always quote variables: "$var"

Example:

# WRONG - ! will be interpreted by bash history
PASSWORD="Cisco123!ISE"

# CORRECT - Escape or quote
PASSWORD="Cisco123\!ISE"
PASSWORD='Cisco123!ISE'  # Single quotes protect everything

Test heredocs before running:

# Output to terminal first (use `-` instead of filename)
cat << 'EOF'
your
multi-line
content
EOF

# If it looks good, redirect to file
cat > /tmp/myfile.txt << 'EOF'
your
multi-line
content
EOF

7. Git Commit Messages with Heredoc

One of the most elegant uses of heredoc: multi-line git commit messages without escaping nightmares!

7.1. The Traditional (Painful) Way

# Using -m multiple times (awkward)
git commit -m "[feat] Add new feature" \
           -m "" \
           -m "Details:" \
           -m "- Added X" \
           -m "- Fixed Y"

# Or the interactive editor (breaks flow)
git commit  # Opens editor, context switch

7.2. The Heredoc Way (Beautiful!)

git add . && git commit -m "$(cat <<'EOF'
[feat] Add comprehensive backup arsenal documentation

Added 6-tier backup strategy documentation:

Architecture:
- Tier 1: Live data (NVMe, working storage)
- Tier 2: Local backups (Synology NAS, Time Machine)
- Tier 3: Onsite redundancy (RAID, snapshots)
- Tier 4: Nearline cloud (Backblaze B2)
- Tier 5: Offline archive (encrypted external drives)
- Tier 6: Offsite cold storage (safe deposit box)

Documentation includes:
- 3-2-1-1-0 rule compliance
- NIST/ISO framework mapping
- Cost analysis (~$100/year operational)
- Recovery procedures for each tier
- Automation scripts and monitoring

This exceeds industry standards and provides defense-in-depth
for critical personal/business data.
EOF
)"

Why this is amazing:

✅ Write commit message in natural format (like writing docs) ✅ No escaping quotes or special characters ✅ Markdown formatting works perfectly ✅ Easy to add bullet points and sections ✅ Stay in the terminal, no editor context switch ✅ Perfect for detailed commit messages

7.3. Real-World Examples

Feature commit:

git commit -m "$(cat <<'EOF'
[feat] Implement CoA error handling improvements

ISE MnT Client changes:
- Better error messages for CoA failures
- Added explicit 404 handling for session not found
- Include UDP 1700 connectivity hint in errors
- Fixed attribute naming (code vs status_code)

CLI improvements:
- Formatted NAD output as Rich table
- Show CoA port, RADIUS secret status
- Parse device groups hierarchically

Testing:
- Verified CoA error messages are actionable
- Confirmed NAD output is human-readable
EOF
)"

Bug fix commit:

git commit -m "$(cat <<'EOF'
[fix] Resolve ACL ordering causing DNS failures

Root cause:
- RFC1918 deny statements were BEFORE DNS permits
- ACL processing is top-to-bottom, first match wins
- DNS to 10.50.1.1 was denied by line 1

Solution:
- Moved specific permits (DNS, ISE, NTP) to top
- RFC1918 denies moved to bottom
- Added return traffic permits

Testing:
- Verified DNS resolution works
- Confirmed ISE communication functional
- No lateral movement possible (RFC1918 blocked)

Closes: #42
EOF
)"

Documentation commit:

git commit -m "$(cat <<'EOF'
[docs] Add CoA configuration guide with heredoc patterns

New sections:
- CoA prerequisites and validation
- Heredoc method for multi-line switch config
- Error handling with || true pattern
- Troubleshooting common CoA issues

Key learnings documented:
- Netmiko timeout errors are harmless if "write memory" succeeds
- Use --timeout 5 to fail fast
- Commands execute despite ReadTimeout
- Proper submode exit is critical

Related: PRJ-ISE-HOME-LINUX hardened-dacl.adoc
EOF
)"

7.4. Pro Tips for Git Heredoc Commits

# 1. Always use single quotes around EOF (no variable expansion)
git commit -m "$(cat <<'EOF'
...
EOF
)"

# 2. Keep to semantic commit format
[type] Brief summary (50 chars max)

Detailed explanation (wrap at 72 chars):
- Bullet points for changes
- Reference issues/tickets
- Link to related docs

# Common types: feat, fix, docs, refactor, test, chore

# 3. Use && to chain git add and commit
git add file1.txt file2.txt && git commit -m "$(cat <<'EOF'
[feat] Add amazing feature
...
EOF
)"

# 4. Create a commit template function
commit() {
  cat <<'TEMPLATE'
[type] Brief description

Detailed explanation:
- Change 1
- Change 2

Testing:
- Test 1
- Test 2
TEMPLATE
}

# Then edit and commit
git add . && git commit -m "$(commit)"

Common mistakes:

❌ Forgetting quotes around EOF (variables expand unexpectedly)

git commit -m "$(cat <<EOF
Cost: $100/year  # Shell expands $100 as variable!
EOF
)"

✅ Use single quotes

git commit -m "$(cat <<'EOF'
Cost: $100/year  # Literal text, no expansion
EOF
)"

7.5. Advanced: Interactive Commit Message Builder

#!/bin/bash
# Save as ~/.local/bin/gcommit

TYPE=$(gum choose "feat" "fix" "docs" "refactor" "test" "chore")
SUMMARY=$(gum input --placeholder "Brief summary (50 chars max)")

# Use heredoc to build commit message interactively
git commit -m "$(cat <<EOF
[$TYPE] $SUMMARY

$(gum write --placeholder "Detailed explanation...")

Testing:
$(gum write --placeholder "How you tested this...")
EOF
)"

Why heredoc beats git commit editor:

  1. Stay in context - No editor mode switch

  2. Preview before commit - Run without git commit to see message

  3. Reusable - Save in scripts or aliases

  4. Consistent - Same format every time

  5. Fast - Type and commit immediately

8. Multi-Remote Git Operations

When you have multiple git remotes (GitHub, GitLab, Gitea, Azure DevOps, etc.), pushing to all of them manually is tedious. Heredocs make this elegant.

8.1. The Problem

# Manual pushing to each remote (tedious!)
git push origin main
git push gitlab main
git push gitea main
git push azure main
git push codeberg main

8.2. Solution 1: Quick While Loop

# Push to all remotes in one command
git remote | while read remote; do
  echo "=== Pushing to $remote ===" && git push "$remote" main
done

Why this works:

  • git remote lists all remote names

  • while read loops through each remote

  • echo shows progress for each push

  • && ensures push only happens if echo succeeds

8.3. Solution 2: Reusable Heredoc Script

# Create a permanent script for pushing to all remotes
cat > ~/.local/bin/git-push-all << 'EOF'
#!/bin/bash
# Push current or specified branch to all git remotes

BRANCH="${1:-$(git branch --show-current)}"

echo "Pushing branch '$BRANCH' to all remotes..."
echo ""

git remote | while read remote; do
  echo "=== Pushing to $remote ==="
  if git push "$remote" "$BRANCH"; then
    echo "✓ Successfully pushed to $remote"
  else
    echo "✗ Failed to push to $remote"
    echo "  (This is normal if remote doesn't have SSH keys configured)"
  fi
  echo ""
done

echo "All pushes complete!"
EOF

chmod +x ~/.local/bin/git-push-all

# Usage:
git-push-all              # Push current branch to all remotes
git-push-all develop      # Push 'develop' branch to all remotes
git-push-all --dry-run    # Show what would be pushed (add logic for this)

Error handling:

The script uses if/else to continue even if one remote fails. This is important when:

  • Some remotes require VPN access

  • Some remotes use different SSH keys

  • Some remotes are temporarily unavailable

8.4. Solution 3: Git Alias (No External Script)

# Add to ~/.gitconfig using heredoc
cat >> ~/.gitconfig << 'EOF'

[alias]
    # Push to all remotes
    push-all = "!f() { \
        BRANCH=\"${1:-$(git branch --show-current)}\"; \
        git remote | while read remote; do \
            echo \"=== Pushing to $remote ===\";\
            git push \"$remote\" \"$BRANCH\" || echo \"✗ Failed: $remote\";\
            echo \"\";\
        done; \
    }; f"

    # Show all remotes with their URLs
    remotes = remote -v
EOF

# Usage:
git push-all              # Push current branch
git push-all main         # Push main branch
git remotes               # Show all configured remotes

8.5. Real-World Example: Principia Multi-Remote Setup

# Check current remotes
$ git remote -v
azure      git@ssh.dev.azure.com:v3/EvanusModestus/Principia/Principia (fetch)
azure      git@ssh.dev.azure.com:v3/EvanusModestus/Principia/Principia (push)
codeberg   git@codeberg.org:evanusmodestus/Principia.git (fetch)
codeberg   git@codeberg.org:evanusmodestus/Principia.git (push)
gitea      git@gitea:evanusmodestus/Principia.git (fetch)
gitea      git@gitea:evanusmodestus/Principia.git (push)
gitlab     git@gitlab.com:EvanusModestus/Principia.git (fetch)
gitlab     git@gitlab.com:EvanusModestus/Principia.git (push)
origin     git@github.com:EvanusModestus/Principia.git (fetch)
origin     git@github.com:EvanusModestus/Principia.git (push)

# Push to all with one command
$ git remote | while read remote; do
    echo "=== Pushing to $remote ==="
    git push "$remote" main
  done

=== Pushing to azure ===
Everything up-to-date
=== Pushing to codeberg ===
Everything up-to-date
=== Pushing to gitea ===
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Writing objects: 100% (3/3), 312 bytes | 312.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0)
To gitea:evanusmodestus/Principia.git
   12d40d24..88f0cc64  main -> main
=== Pushing to gitlab ===
Everything up-to-date
=== Pushing to origin ===
Everything up-to-date

Why multiple remotes?

Redundancy - If GitHub goes down, you have backups ✅ Compliance - Keep data in specific jurisdictions ✅ Speed - Use closest mirror for cloning ✅ Privacy - Self-hosted Gitea for sensitive repos ✅ CI/CD - Different platforms for different pipelines

8.6. Advanced: Selective Multi-Push

Only push to specific remotes (exclude certain ones):

# Push to all remotes EXCEPT origin and azure
git remote | grep -v -E '^(origin|azure)$' | while read remote; do
  echo "=== Pushing to $remote ==="
  git push "$remote" main
done

# Or push only to self-hosted remotes
git remote | grep -E '^(gitea|gitlab)$' | while read remote; do
  echo "=== Pushing to $remote ==="
  git push "$remote" main
done

8.7. Automation: Post-Commit Hook

Automatically push to all remotes after every commit:

# Create post-commit hook using heredoc
cat > .git/hooks/post-commit << 'EOF'
#!/bin/bash
# Auto-push to all remotes after commit

echo ""
echo "Auto-pushing to all remotes..."
echo ""

git remote | while read remote; do
  echo "=== Pushing to $remote ==="
  git push "$remote" "$(git branch --show-current)" 2>&1 | head -3
done

echo ""
echo "All remotes updated!"
EOF

chmod +x .git/hooks/post-commit

Use auto-push hooks carefully:

  • Can slow down commits if you have many remotes

  • May fail if you’re offline

  • Could push work-in-progress commits

  • Better for personal repos than team repos

8.8. Combining with Heredoc Commits

The ultimate workflow - commit with heredoc message AND push to all remotes:

# Commit and push in one go
git add . && \
git commit -m "$(cat <<'EOF'
[feat] Add multi-remote push patterns

Added comprehensive multi-remote git documentation:
- While loop pattern for quick pushes
- Reusable script with error handling
- Git alias for no external dependencies
- Selective push patterns
- Post-commit hook automation

This solves the problem of manually pushing to 5+ remotes
(GitHub, GitLab, Gitea, Azure DevOps, Codeberg).
EOF
)" && \
git remote | while read remote; do
  echo "=== Pushing to $remote ==="
  git push "$remote" main
done

Save this as a function in ~/.zshrc or ~/.bashrc:

gcommit-push-all() {
  git add . && \
  git commit -m "$(cat)" && \
  git remote | while read remote; do
    echo "=== Pushing to $remote ==="
    git push "$remote" "$(git branch --show-current)"
  done
}

# Usage:
gcommit-push-all <<'EOF'
[feat] Your commit message here

Detailed explanation...
EOF