diff --git a/.gitignore b/.gitignore index 4a59924..f61dc98 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,12 @@ logs/user_prompt_submit.json # Bun build artifacts *.bun-build claude-code/hooks/*.bun-build -.code/ \ No newline at end of file +.code/ +# Orchestration runtime state (DO NOT COMMIT) +.claude/orchestration/state/sessions.json +.claude/orchestration/state/completed.json +.claude/orchestration/state/dag-*.json +.claude/orchestration/checkpoints/*.json + +# Keep config template +!.claude/orchestration/state/config.json diff --git a/claude-code-4.5/CLAUDE.md b/claude-code-4.5/CLAUDE.md index e0fd469..c7ceda7 100644 --- a/claude-code-4.5/CLAUDE.md +++ b/claude-code-4.5/CLAUDE.md @@ -177,11 +177,12 @@ async def process_payment(amount: int, customer_id: str): # Background Process Management -CRITICAL: When starting any long-running server process (web servers, development servers, APIs, etc.), you MUST: +CRITICAL: When starting any long-running server process (web servers, development servers, APIs, etc.), you MUST use tmux for persistence and management: -1. **Always Run in Background** +1. **Always Run in tmux Sessions** - NEVER run servers in foreground as this will block the agent process indefinitely - - Use background execution (`&` or `nohup`) or container-use background mode + - ALWAYS use tmux for background execution (provides persistence across disconnects) + - Fallback to container-use background mode if tmux unavailable - Examples of foreground-blocking commands: - `npm run dev` or `npm start` - `python app.py` or `flask run` @@ -193,96 +194,164 @@ CRITICAL: When starting any long-running server process (web servers, developmen - ALWAYS use random/dynamic ports to avoid conflicts between parallel sessions - Generate random port: `PORT=$(shuf -i 3000-9999 -n 1)` - Pass port via environment variable or command line argument - - Document the assigned port in logs for reference + - Document the assigned port in session metadata -3. **Mandatory Log Redirection** - - Redirect all output to log files: `command > app.log 2>&1 &` - - Use descriptive log names: `server.log`, `api.log`, `dev-server.log` - - Include port in log name when possible: `server-${PORT}.log` - - Capture both stdout and stderr for complete debugging information +3. **tmux Session Naming Convention** + - Dev environments: `dev-{project}-{timestamp}` + - Spawned agents: `agent-{timestamp}` + - Monitoring: `monitor-{purpose}` + - Examples: `dev-myapp-1705161234`, `agent-1705161234` -4. **Container-use Background Mode** - - When using container-use, ALWAYS set `background: true` for server commands - - Use `ports` parameter to expose the randomly assigned port - - Example: `mcp__container-use__environment_run_cmd` with `background: true, ports: [PORT]` +4. **Session Metadata** + - Save session info to `.tmux-dev-session.json` (per project) + - Include: session name, ports, services, created timestamp + - Use metadata for session discovery and conflict detection -5. **Log Monitoring** - - After starting background process, immediately check logs with `tail -f logfile.log` - - Use `cat logfile.log` to view full log contents - - Monitor startup messages to ensure server started successfully - - Look for port assignment confirmation in logs +5. **Log Capture** + - Use `| tee logfile.log` to capture output to both tmux and file + - Use descriptive log names: `server.log`, `api.log`, `dev-server.log` + - Include port in log name when possible: `server-${PORT}.log` + - Logs visible in tmux pane AND saved to disk 6. **Safe Process Management** - - NEVER kill by process name (`pkill node`, `pkill vite`, `pkill uv`) - this affects other parallel sessions + - NEVER kill by process name (`pkill node`, `pkill vite`, `pkill uv`) - affects other sessions - ALWAYS kill by port to target specific server: `lsof -ti:${PORT} | xargs kill -9` - - Alternative port-based killing: `fuser -k ${PORT}/tcp` - - Check what's running on port before killing: `lsof -i :${PORT}` - - Clean up port-specific processes before starting new servers on same port + - Alternative: Kill entire tmux session: `tmux kill-session -t {session-name}` + - Check what's running on port: `lsof -i :${PORT}` **Examples:** ```bash -# ❌ WRONG - Will block forever and use default port +# ❌ WRONG - Will block forever npm run dev # ❌ WRONG - Killing by process name affects other sessions pkill node -# ✅ CORRECT - Complete workflow with random port +# ❌ DEPRECATED - Using & background jobs (no persistence) PORT=$(shuf -i 3000-9999 -n 1) -echo "Starting server on port $PORT" PORT=$PORT npm run dev > dev-server-${PORT}.log 2>&1 & -tail -f dev-server-${PORT}.log + +# ✅ CORRECT - Complete tmux workflow with random port +PORT=$(shuf -i 3000-9999 -n 1) +SESSION="dev-$(basename $(pwd))-$(date +%s)" + +# Create tmux session +tmux new-session -d -s "$SESSION" -n dev-server + +# Start server in tmux with log capture +tmux send-keys -t "$SESSION:dev-server" "PORT=$PORT npm run dev | tee dev-server-${PORT}.log" C-m + +# Save metadata +cat > .tmux-dev-session.json </dev/null && echo "Session running" -# ✅ CORRECT - Container-use with random port +# ✅ CORRECT - Attach to monitor logs +tmux attach -t "$SESSION" + +# ✅ CORRECT - Flask/Python in tmux +PORT=$(shuf -i 5000-5999 -n 1) +SESSION="dev-flask-$(date +%s)" +tmux new-session -d -s "$SESSION" -n server +tmux send-keys -t "$SESSION:server" "FLASK_RUN_PORT=$PORT flask run | tee flask-${PORT}.log" C-m + +# ✅ CORRECT - Next.js in tmux +PORT=$(shuf -i 3000-3999 -n 1) +SESSION="dev-nextjs-$(date +%s)" +tmux new-session -d -s "$SESSION" -n server +tmux send-keys -t "$SESSION:server" "PORT=$PORT npm run dev | tee nextjs-${PORT}.log" C-m +``` + +**Fallback: Container-use Background Mode** (when tmux unavailable): +```bash +# Only use if tmux is not available mcp__container-use__environment_run_cmd with: command: "PORT=${PORT} npm run dev" background: true ports: [PORT] - -# ✅ CORRECT - Flask/Python example -PORT=$(shuf -i 3000-9999 -n 1) -FLASK_RUN_PORT=$PORT python app.py > flask-${PORT}.log 2>&1 & - -# ✅ CORRECT - Next.js example -PORT=$(shuf -i 3000-9999 -n 1) -PORT=$PORT npm run dev > nextjs-${PORT}.log 2>&1 & ``` -**Playwright Testing Background Execution:** +**Playwright Testing in tmux:** -- **ALWAYS run Playwright tests in background** to prevent agent blocking -- **NEVER open test report servers** - they will block agent execution indefinitely -- Use `--reporter=json` and `--reporter=line` for programmatic result parsing -- Redirect all output to log files for later analysis +- **Run Playwright tests in tmux** for persistence and log monitoring +- **NEVER open test report servers** - they block agent execution +- Use `--reporter=json` and `--reporter=line` for programmatic parsing - Examples: ```bash -# ✅ CORRECT - Background Playwright execution -npx playwright test --reporter=json > playwright-results.log 2>&1 & +# ✅ CORRECT - Playwright in tmux session +SESSION="test-playwright-$(date +%s)" +tmux new-session -d -s "$SESSION" -n tests +tmux send-keys -t "$SESSION:tests" "npx playwright test --reporter=json | tee playwright-results.log" C-m -# ✅ CORRECT - Custom config with background execution -npx playwright test --config=custom.config.js --reporter=line > test-output.log 2>&1 & +# Monitor progress +tmux attach -t "$SESSION" + +# ❌ DEPRECATED - Background job (no persistence) +npx playwright test --reporter=json > playwright-results.log 2>&1 & # ❌ WRONG - Will block agent indefinitely npx playwright test --reporter=html npx playwright show-report # ✅ CORRECT - Parse results programmatically -cat playwright-results.json | jq '.stats' -tail -20 test-output.log +cat playwright-results.log | jq '.stats' ``` +**Using Generic /start-* Commands:** + +For common development scenarios, use the generic commands: + +```bash +# Start local web development (auto-detects framework) +/start-local development # Uses .env.development +/start-local staging # Uses .env.staging +/start-local production # Uses .env.production + +# Start iOS development (auto-detects project type) +/start-ios Debug # Uses .env.development +/start-ios Staging # Uses .env.staging +/start-ios Release # Uses .env.production + +# Start Android development (auto-detects project type) +/start-android debug # Uses .env.development +/start-android staging # Uses .env.staging +/start-android release # Uses .env.production +``` -RATIONALE: Background execution with random ports prevents agent process deadlock while enabling parallel sessions to coexist without interference. Port-based process management ensures safe cleanup without affecting other concurrent development sessions. This maintains full visibility into server status through logs while ensuring continuous agent operation. +These commands automatically: +- Create organized tmux sessions +- Assign random ports +- Start all required services +- Save session metadata +- Setup log monitoring + +**Session Persistence Benefits:** +- Survives SSH disconnects +- Survives terminal restarts +- Easy reattachment: `tmux attach -t {session-name}` +- Live log monitoring in split panes +- Organized multi-window layouts + +RATIONALE: tmux provides persistence across disconnects, better visibility through split panes, and session organization. Random ports prevent conflicts between parallel sessions. Port-based or session-based process management ensures safe cleanup. Generic /start-* commands provide consistent, framework-agnostic development environments. # Session Management System diff --git a/claude-code-4.5/commands/attach-agent-worktree.md b/claude-code-4.5/commands/attach-agent-worktree.md new file mode 100644 index 0000000..a141096 --- /dev/null +++ b/claude-code-4.5/commands/attach-agent-worktree.md @@ -0,0 +1,46 @@ +# /attach-agent-worktree - Attach to Agent Session + +Changes to agent worktree directory and attaches to its tmux session. + +## Usage + +```bash +/attach-agent-worktree {timestamp} +``` + +## Implementation + +```bash +#!/bin/bash + +AGENT_ID="$1" + +if [ -z "$AGENT_ID" ]; then + echo "❌ Agent ID required" + echo "Usage: /attach-agent-worktree {timestamp}" + exit 1 +fi + +# Find worktree directory +WORKTREE_DIR=$(find worktrees -type d -name "agent-${AGENT_ID}*" 2>/dev/null | head -1) + +if [ -z "$WORKTREE_DIR" ] || [ ! -d "$WORKTREE_DIR" ]; then + echo "❌ Worktree not found for agent: $AGENT_ID" + exit 1 +fi + +SESSION="agent-${AGENT_ID}" + +# Check if tmux session exists +if ! tmux has-session -t "$SESSION" 2>/dev/null; then + echo "❌ Tmux session not found: $SESSION" + exit 1 +fi + +echo "📂 Worktree: $WORKTREE_DIR" +echo "🔗 Attaching to session: $SESSION" +echo "" + +# Attach to session +tmux attach -t "$SESSION" +``` diff --git a/claude-code-4.5/commands/cleanup-agent-worktree.md b/claude-code-4.5/commands/cleanup-agent-worktree.md new file mode 100644 index 0000000..12f7ebb --- /dev/null +++ b/claude-code-4.5/commands/cleanup-agent-worktree.md @@ -0,0 +1,36 @@ +# /cleanup-agent-worktree - Remove Agent Worktree + +Removes a specific agent worktree and its branch. + +## Usage + +```bash +/cleanup-agent-worktree {timestamp} +/cleanup-agent-worktree {timestamp} --force +``` + +## Implementation + +```bash +#!/bin/bash + +AGENT_ID="$1" +FORCE="$2" + +if [ -z "$AGENT_ID" ]; then + echo "❌ Agent ID required" + echo "Usage: /cleanup-agent-worktree {timestamp} [--force]" + exit 1 +fi + +# Source utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../utils/git-worktree-utils.sh" + +# Cleanup worktree +if [ "$FORCE" = "--force" ]; then + cleanup_agent_worktree "$AGENT_ID" true +else + cleanup_agent_worktree "$AGENT_ID" false +fi +``` diff --git a/claude-code-4.5/commands/handover.md b/claude-code-4.5/commands/handover.md index e57f076..36a9e37 100644 --- a/claude-code-4.5/commands/handover.md +++ b/claude-code-4.5/commands/handover.md @@ -1,59 +1,187 @@ -# Handover Command +# /handover - Generate Session Handover Document -Use this command to generate a session handover document when transferring work to another team member or continuing work in a new session. +Generate a handover document for transferring work to another developer or spawning an async agent. ## Usage -``` -/handover [optional-notes] +```bash +/handover # Standard handover +/handover "notes about current work" # With notes +/handover --agent-spawn "task desc" # For spawning agent ``` -## Description +## Modes -This command generates a comprehensive handover document that includes: +### Standard Handover (default) -- Current session health status +For transferring work to another human or resuming later: +- Current session health - Task progress and todos -- Technical context and working files -- Instructions for resuming work -- Any blockers or important notes +- Technical context +- Resumption instructions -## Example +### Agent Spawn Mode (`--agent-spawn`) +For passing context to spawned agents: +- Focused on task context +- Technical stack details +- Success criteria +- Files to modify + +## Implementation + +### Detect Mode + +```bash +MODE="standard" +AGENT_TASK="" +NOTES="${1:-}" + +if [[ "$1" == "--agent-spawn" ]]; then + MODE="agent" + AGENT_TASK="${2:-}" + shift 2 +fi ``` -/handover Working on authentication refactor, need to complete OAuth integration + +### Generate Timestamp + +```bash +TIMESTAMP=$(date +"%Y-%m-%d-%H-%M-%S") +DISPLAY_TIME=$(date +"%Y-%m-%d %H:%M:%S") +FILENAME="handover-${TIMESTAMP}.md" +PRIMARY_LOCATION="${TOOL_DIR}/session/${FILENAME}" +BACKUP_LOCATION="./${FILENAME}" + +mkdir -p "${TOOL_DIR}/session" ``` -## Output Location +### Standard Handover Content + +```markdown +# Handover Document + +**Generated**: ${DISPLAY_TIME} +**Session**: $(tmux display-message -p '#S' 2>/dev/null || echo 'unknown') + +## Current Work + +[Describe what you're working on] + +## Task Progress + +[List todos and completion status] + +## Technical Context + +**Current Branch**: $(git branch --show-current) +**Last Commit**: $(git log -1 --oneline) +**Modified Files**: +$(git status --short) + +## Resumption Instructions + +1. Review changes: git diff +2. Continue work on [specific task] +3. Test with: [test command] + +## Notes + +${NOTES} +``` + +### Agent Spawn Handover Content + +```markdown +# Agent Handover - ${AGENT_TASK} + +**Generated**: ${DISPLAY_TIME} +**Parent Session**: $(tmux display-message -p '#S' 2>/dev/null || echo 'unknown') +**Agent Task**: ${AGENT_TASK} + +## Context Summary + +**Current Work**: [What's in progress] +**Current Branch**: $(git branch --show-current) +**Last Commit**: $(git log -1 --oneline) + +## Task Details + +**Agent Mission**: ${AGENT_TASK} + +**Requirements**: +- [List specific requirements] +- [What needs to be done] + +**Success Criteria**: +- [How to know when done] + +## Technical Context + +**Stack**: [Technology stack] +**Key Files**: +$(git status --short) + +**Modified Recently**: +$(git log --name-only -5 --oneline) -The handover document MUST be saved to: -- **Primary Location**: `.{{TOOL_DIR}}/session/handover-{{TIMESTAMP}}.md` -- **Backup Location**: `./handover-{{TIMESTAMP}}.md` (project root) +## Instructions for Agent -## File Naming Convention +1. Review current implementation +2. Make specified changes +3. Add/update tests +4. Verify all tests pass +5. Commit with clear message -Use this format: `handover-YYYY-MM-DD-HH-MM-SS.md` +## References -Example: `handover-2024-01-15-14-30-45.md` +**Documentation**: [Links to relevant docs] +**Related Work**: [Related PRs/issues] +``` + +### Save Document -**CRITICAL**: Always obtain the timestamp programmatically: ```bash -# Generate timestamp - NEVER type dates manually -TIMESTAMP=$(date +"%Y-%m-%d-%H-%M-%S") -FILENAME="handover-${TIMESTAMP}.md" +# Generate appropriate content based on MODE +if [ "$MODE" = "agent" ]; then + # Generate agent handover content + CONTENT="[Agent handover content from above]" +else + # Generate standard handover content + CONTENT="[Standard handover content from above]" +fi + +# Save to primary location +echo "$CONTENT" > "$PRIMARY_LOCATION" + +# Save backup +echo "$CONTENT" > "$BACKUP_LOCATION" + +echo "✅ Handover document generated" +echo "" +echo "Primary: $PRIMARY_LOCATION" +echo "Backup: $BACKUP_LOCATION" +echo "" ``` -## Implementation +## Output Location + +**Primary**: `${TOOL_DIR}/session/handover-{timestamp}.md` +**Backup**: `./handover-{timestamp}.md` + +## Integration with spawn-agent + +The `/spawn-agent` command automatically calls `/handover --agent-spawn` when `--with-handover` flag is used: + +```bash +/spawn-agent codex "refactor auth" --with-handover +# Internally calls: /handover --agent-spawn "refactor auth" +# Copies handover to agent worktree as .agent-handover.md +``` + +## Notes -1. **ALWAYS** get the current timestamp using `date` command: - ```bash - date +"%Y-%m-%d %H:%M:%S" # For document header - date +"%Y-%m-%d-%H-%M-%S" # For filename - ``` -2. Generate handover using `{{HOME_TOOL_DIR}}/templates/handover-template.md` -3. Replace all `{{VARIABLE}}` placeholders with actual values -4. Save to BOTH locations (primary and backup) -5. Display the full file path to the user for reference -6. Verify the date in the filename matches the date in the document header - -The handover document will be saved as a markdown file and can be used to seamlessly continue work in a new session. \ No newline at end of file +- Always uses programmatic timestamps (never manual) +- Saves to both primary and backup locations +- Agent mode focuses on task context, not session health +- Standard mode includes full session state diff --git a/claude-code-4.5/commands/list-agent-worktrees.md b/claude-code-4.5/commands/list-agent-worktrees.md new file mode 100644 index 0000000..3534902 --- /dev/null +++ b/claude-code-4.5/commands/list-agent-worktrees.md @@ -0,0 +1,16 @@ +# /list-agent-worktrees - List All Agent Worktrees + +Shows all active agent worktrees with their paths and branches. + +## Usage + +```bash +/list-agent-worktrees +``` + +## Implementation + +```bash +#!/bin/bash +git worktree list | grep "worktrees/agent-" || echo "No agent worktrees found" +``` diff --git a/claude-code-4.5/commands/m-implement.md b/claude-code-4.5/commands/m-implement.md new file mode 100644 index 0000000..b713e44 --- /dev/null +++ b/claude-code-4.5/commands/m-implement.md @@ -0,0 +1,375 @@ +--- +description: Multi-agent implementation - Execute DAG in waves with automated monitoring +tags: [orchestration, implementation, multi-agent] +--- + +# Multi-Agent Implementation (`/m-implement`) + +You are now in **multi-agent implementation mode**. Your task is to execute a pre-planned DAG by spawning agents in waves and monitoring their progress. + +## Your Role + +Act as an **orchestrator** that manages parallel agent execution, monitors progress, and handles failures. + +## Prerequisites + +1. **DAG file must exist**: `~/.claude/orchestration/state/dag-.json` +2. **Session must be created**: Via `/m-plan` or manually +3. **Git worktrees setup**: Project must support git worktrees + +## Process + +### Step 1: Load DAG and Session + +```bash +# Load DAG file +DAG_FILE="~/.claude/orchestration/state/dag-${SESSION_ID}.json" + +# Verify DAG exists +if [ ! -f "$DAG_FILE" ]; then + echo "Error: DAG file not found: $DAG_FILE" + exit 1 +fi + +# Load session +SESSION=$(~/.claude/utils/orchestrator-state.sh get "$SESSION_ID") + +if [ -z "$SESSION" ]; then + echo "Error: Session not found: $SESSION_ID" + exit 1 +fi +``` + +### Step 2: Calculate Waves + +```bash +# Get waves from DAG (already calculated in /m-plan) +WAVES=$(jq -r '.waves[] | "\(.wave_number):\(.nodes | join(" "))"' "$DAG_FILE") + +# Example output: +# 1:ws-1 ws-3 +# 2:ws-2 ws-4 +# 3:ws-5 +``` + +### Step 3: Execute Wave-by-Wave + +**For each wave:** + +```bash +WAVE_NUMBER=1 + +# Get nodes in this wave +WAVE_NODES=$(echo "$WAVES" | grep "^${WAVE_NUMBER}:" | cut -d: -f2) + +echo "🌊 Starting Wave $WAVE_NUMBER: $WAVE_NODES" + +# Update wave status +~/.claude/utils/orchestrator-state.sh update-wave-status "$SESSION_ID" "$WAVE_NUMBER" "active" + +# Spawn all agents in wave (parallel) +for node in $WAVE_NODES; do + spawn_agent "$SESSION_ID" "$node" & +done + +# Wait for all agents in wave to complete +wait + +# Check if wave completed successfully +if wave_all_complete "$SESSION_ID" "$WAVE_NUMBER"; then + ~/.claude/utils/orchestrator-state.sh update-wave-status "$SESSION_ID" "$WAVE_NUMBER" "complete" + echo "✅ Wave $WAVE_NUMBER complete" +else + echo "❌ Wave $WAVE_NUMBER failed" + exit 1 +fi +``` + +### Step 4: Spawn Agent Function + +**Function to spawn a single agent:** + +```bash +spawn_agent() { + local session_id="$1" + local node_id="$2" + + # Get node details from DAG + local node=$(jq -r --arg n "$node_id" '.nodes[$n]' "$DAG_FILE") + local task=$(echo "$node" | jq -r '.task') + local agent_type=$(echo "$node" | jq -r '.agent_type') + local workstream_id=$(echo "$node" | jq -r '.workstream_id') + + # Create git worktree + local worktree_dir="worktrees/${workstream_id}-${node_id}" + local branch="feat/${workstream_id}" + + git worktree add "$worktree_dir" -b "$branch" 2>/dev/null || git worktree add "$worktree_dir" "$branch" + + # Create tmux session + local agent_id="agent-${workstream_id}-$(date +%s)" + tmux new-session -d -s "$agent_id" -c "$worktree_dir" + + # Start Claude in tmux + tmux send-keys -t "$agent_id" "claude --dangerously-skip-permissions" C-m + + # Wait for Claude to initialize + wait_for_claude_ready "$agent_id" + + # Send task + local full_task="$task + +AGENT ROLE: Act as a ${agent_type}. + +CRITICAL REQUIREMENTS: +- Work in worktree: $worktree_dir +- Branch: $branch +- When complete: Run tests, commit with clear message, report status + +DELIVERABLES: +$(echo "$node" | jq -r '.deliverables[]' | sed 's/^/- /') + +When complete: Commit all changes and report status." + + tmux send-keys -t "$agent_id" -l "$full_task" + tmux send-keys -t "$agent_id" C-m + + # Add agent to session state + local agent_config=$(cat <15min, killing..." + ~/.claude/utils/orchestrator-agent.sh kill "$tmux_session" + ~/.claude/utils/orchestrator-state.sh update-agent-status "$session_id" "$agent_id" "killed" + fi + done + + # Check if wave is complete + if wave_all_complete "$session_id" "$wave_number"; then + return 0 + fi + + # Check if wave failed + local failed_count=$(~/.claude/utils/orchestrator-state.sh list-agents "$session_id" | \ + xargs -I {} ~/.claude/utils/orchestrator-state.sh get-agent "$session_id" {} | \ + jq -r 'select(.status == "failed")' | wc -l) + + if [ "$failed_count" -gt 0 ]; then + echo "❌ Wave $wave_number failed ($failed_count agents failed)" + return 1 + fi + + # Sleep before next check + sleep 30 + done +} +``` + +### Step 7: Handle Completion + +**When all waves complete:** + +```bash +# Archive session +~/.claude/utils/orchestrator-state.sh archive "$SESSION_ID" + +# Print summary +echo "🎉 All waves complete!" +echo "" +echo "Summary:" +echo " Total Cost: \$$(jq -r '.total_cost_usd' sessions.json)" +echo " Total Agents: $(jq -r '.agents | length' sessions.json)" +echo " Duration: " +echo "" +echo "Next steps:" +echo " 1. Review agent outputs in worktrees" +echo " 2. Merge worktrees to main branch" +echo " 3. Run integration tests" +``` + +## Output Format + +**During execution, display:** + +``` +🚀 Multi-Agent Implementation: + +📊 Plan Summary: + - Total Workstreams: 7 + - Total Waves: 4 + - Max Concurrent: 4 + +🌊 Wave 1 (2 agents) + ✅ agent-ws1-xxx (complete) - Cost: $1.86 + ✅ agent-ws3-xxx (complete) - Cost: $0.79 + Duration: 8m 23s + +🌊 Wave 2 (2 agents) + 🔄 agent-ws2-xxx (active) - Cost: $0.45 + 🔄 agent-ws4-xxx (active) - Cost: $0.38 + Elapsed: 3m 12s + +🌊 Wave 3 (1 agent) + ⏸️ agent-ws5-xxx (pending) + +🌊 Wave 4 (2 agents) + ⏸️ agent-ws6-xxx (pending) + ⏸️ agent-ws7-xxx (pending) + +💰 Total Cost: $3.48 / $50.00 (7%) +⏱️ Total Time: 11m 35s + +Press Ctrl+C to pause monitoring (agents continue in background) +``` + +## Important Notes + +- **Non-blocking**: Agents run in background tmux sessions +- **Resumable**: Can exit and resume with `/m-monitor ` +- **Auto-recovery**: Idle agents are killed automatically +- **Budget limits**: Stops if budget exceeded +- **Parallel execution**: Multiple agents per wave (up to max_concurrent) + +## Error Handling + +**If agent fails:** +1. Mark agent as "failed" +2. Continue other agents in wave +3. Do not proceed to next wave +4. Present failure summary to user +5. Allow manual retry or skip + +**If timeout:** +1. Check if agent is actually running (may be false positive) +2. If truly stuck, kill and mark as failed +3. Offer retry option + +## Resume Support + +**To resume a paused/stopped session:** + +```bash +/m-implement --resume +``` + +**Resume logic:** +1. Load existing session state +2. Determine current wave +3. Check which agents are still running +4. Continue from where it left off + +## CLI Options (Future) + +```bash +/m-implement [options] + +Options: + --resume Resume from last checkpoint + --from-wave N Start from specific wave number + --dry-run Show what would be executed + --max-concurrent N Override max concurrent agents + --no-monitoring Spawn agents and exit (no monitoring loop) +``` + +## Integration with `/spawn-agent` + +This command reuses logic from `~/.claude/commands/spawn-agent.md`: +- Git worktree creation +- Claude initialization detection +- Task sending via tmux + +## Exit Conditions + +**Success:** +- All waves complete +- All agents have status "complete" +- No failures + +**Failure:** +- Any agent has status "failed" +- Budget limit exceeded +- User manually aborts + +**Pause:** +- User presses Ctrl+C +- Session state saved +- Agents continue in background +- Resume with `/m-monitor ` + +--- + +**End of `/m-implement` command** diff --git a/claude-code-4.5/commands/m-monitor.md b/claude-code-4.5/commands/m-monitor.md new file mode 100644 index 0000000..b1ecee6 --- /dev/null +++ b/claude-code-4.5/commands/m-monitor.md @@ -0,0 +1,118 @@ +--- +description: Multi-agent monitoring - Real-time dashboard for orchestration sessions +tags: [orchestration, monitoring, multi-agent] +--- + +# Multi-Agent Monitoring (`/m-monitor`) + +You are now in **multi-agent monitoring mode**. Display a real-time dashboard of the orchestration session status. + +## Your Role + +Act as a **monitoring dashboard** that displays live status of all agents, waves, costs, and progress. + +## Usage + +```bash +/m-monitor +``` + +## Display Format + +``` +🚀 Multi-Agent Session: orch-1763400000 + +📊 Plan Summary: + - Task: Implement BigCommerce migration + - Created: 2025-11-17 10:00:00 + - Total Workstreams: 7 + - Total Waves: 4 + - Max Concurrent: 4 + +🌊 Wave 1: Complete ✅ (Duration: 8m 23s) + ✅ agent-ws1-1763338466 (WS-1: Service Layer) + Status: complete | Cost: $1.86 | Branch: feat/ws-1 + Worktree: worktrees/ws-1-service-layer + Last Update: 2025-11-17 10:08:23 + + ✅ agent-ws3-1763338483 (WS-3: Database Schema) + Status: complete | Cost: $0.79 | Branch: feat/ws-3 + Worktree: worktrees/ws-3-database-schema + Last Update: 2025-11-17 10:08:15 + +🌊 Wave 2: Active 🔄 (Elapsed: 3m 12s) + 🔄 agent-ws2-1763341887 (WS-2: Edge Functions) + Status: active | Cost: $0.45 | Branch: feat/ws-2 + Worktree: worktrees/ws-2-edge-functions + Last Update: 2025-11-17 10:11:35 + Attach: tmux attach -t agent-ws2-1763341887 + + 🔄 agent-ws4-1763341892 (WS-4: Frontend UI) + Status: active | Cost: $0.38 | Branch: feat/ws-4 + Worktree: worktrees/ws-4-frontend-ui + Last Update: 2025-11-17 10:11:42 + Attach: tmux attach -t agent-ws4-1763341892 + +🌊 Wave 3: Pending ⏸️ + ⏸️ agent-ws5-pending (WS-5: Checkout Flow) + +🌊 Wave 4: Pending ⏸️ + ⏸️ agent-ws6-pending (WS-6: E2E Tests) + ⏸️ agent-ws7-pending (WS-7: Documentation) + +💰 Budget Status: + - Current Cost: $3.48 + - Budget Limit: $50.00 + - Usage: 7% 🟢 + +⏱️ Timeline: + - Total Elapsed: 11m 35s + - Estimated Remaining: ~5h 30m + +📋 Commands: + - Refresh: /m-monitor + - Attach to agent: tmux attach -t + - View agent output: tmux capture-pane -t -p + - Kill idle agent: ~/.claude/utils/orchestrator-agent.sh kill + - Pause session: Ctrl+C (agents continue in background) + - Resume session: /m-implement --resume + +Status Legend: + ✅ complete 🔄 active ⏸️ pending ⚠️ idle ❌ failed 💀 killed +``` + +## Implementation (Phase 2) + +**This is a stub command for Phase 1.** Full implementation in Phase 2 will include: + +1. **Live monitoring loop** - Refresh every 30s +2. **Interactive controls** - Pause, resume, kill agents +3. **Cost tracking** - Real-time budget updates +4. **Idle detection** - Highlight idle agents +5. **Failure alerts** - Notify on failures +6. **Performance metrics** - Agent completion times + +## Current Workaround + +**Until Phase 2 is complete, use these manual commands:** + +```bash +# View session status +~/.claude/utils/orchestrator-state.sh print + +# List all agents +~/.claude/utils/orchestrator-state.sh list-agents + +# Check specific agent +~/.claude/utils/orchestrator-state.sh get-agent + +# Attach to agent tmux session +tmux attach -t + +# View agent output without attaching +tmux capture-pane -t -p | tail -50 +``` + +--- + +**End of `/m-monitor` command (stub)** diff --git a/claude-code-4.5/commands/m-plan.md b/claude-code-4.5/commands/m-plan.md new file mode 100644 index 0000000..39607c7 --- /dev/null +++ b/claude-code-4.5/commands/m-plan.md @@ -0,0 +1,261 @@ +--- +description: Multi-agent planning - Decompose complex tasks into parallel workstreams with dependency DAG +tags: [orchestration, planning, multi-agent] +--- + +# Multi-Agent Planning (`/m-plan`) + +You are now in **multi-agent planning mode**. Your task is to decompose a complex task into parallel workstreams with a dependency graph (DAG). + +## Your Role + +Act as a **solution-architect** specialized in task decomposition and dependency analysis. + +## Process + +### 1. Understand the Task + +**Ask clarifying questions if needed:** +- What is the overall goal? +- Are there any constraints (time, budget, resources)? +- Are there existing dependencies or requirements? +- What is the desired merge strategy? + +### 2. Decompose into Workstreams + +**Break down the task into independent workstreams:** +- Each workstream should be a cohesive unit of work +- Workstreams should be as independent as possible +- Identify clear deliverables for each workstream +- Assign appropriate agent types (backend-developer, frontend-developer, etc.) + +**Workstream Guidelines:** +- **Size**: Each workstream should take 1-3 hours of agent time +- **Independence**: Minimize dependencies between workstreams +- **Clarity**: Clear, specific deliverables +- **Agent Type**: Match to specialized agent capabilities + +### 3. Identify Dependencies + +**For each workstream, determine:** +- What other workstreams must complete first? +- What outputs does it depend on? +- What outputs does it produce for others? + +**Dependency Types:** +- **Blocking**: Must complete before dependent can start +- **Data**: Provides data/files needed by dependent +- **Interface**: Provides API/interface contract + +### 4. Create DAG Structure + +**Generate a JSON DAG file:** +```json +{ + "session_id": "orch-", + "created_at": "", + "task_description": "", + "nodes": { + "ws-1-": { + "task": "", + "agent_type": "backend-developer", + "workstream_id": "ws-1", + "dependencies": [], + "status": "pending", + "deliverables": [ + "src/services/FooService.ts", + "tests for FooService" + ] + }, + "ws-2-": { + "task": "", + "agent_type": "frontend-developer", + "workstream_id": "ws-2", + "dependencies": ["ws-1"], + "status": "pending", + "deliverables": [ + "src/components/FooComponent.tsx" + ] + } + }, + "edges": [ + {"from": "ws-1", "to": "ws-2", "type": "blocking"} + ] +} +``` + +### 5. Calculate Waves + +Use the topological sort utility to calculate execution waves: + +```bash +~/.claude/utils/orchestrator-dag.sh topo-sort +``` + +**Add wave information to DAG:** +```json +{ + "waves": [ + { + "wave_number": 1, + "nodes": ["ws-1", "ws-3"], + "status": "pending", + "estimated_parallel_time_hours": 2 + }, + { + "wave_number": 2, + "nodes": ["ws-2", "ws-4"], + "status": "pending", + "estimated_parallel_time_hours": 1.5 + } + ] +} +``` + +### 6. Estimate Costs and Timeline + +**For each workstream:** +- Estimate agent time (hours) +- Estimate cost based on historical data (~$1-2 per hour) +- Calculate total cost and timeline + +**Wave-based timeline:** +- Wave 1: 2 hours (parallel) +- Wave 2: 1.5 hours (parallel) +- Total: 3.5 hours (not 7 hours due to parallelism) + +### 7. Save DAG File + +**Save to:** +``` +~/.claude/orchestration/state/dag-.json +``` + +**Create orchestration session:** +```bash +SESSION_ID=$(~/.claude/utils/orchestrator-state.sh create \ + "orch-$(date +%s)" \ + "orch-$(date +%s)-monitor" \ + '{}') + +echo "Created session: $SESSION_ID" +``` + +## Output Format + +**Present to user:** + +```markdown +# Multi-Agent Plan: + +## Summary +- **Total Workstreams**: X +- **Total Waves**: Y +- **Estimated Timeline**: Z hours (parallel) +- **Estimated Cost**: $A - $B +- **Max Concurrent Agents**: 4 + +## Workstreams + +### Wave 1 (No dependencies) +- **WS-1: ** (backend-developer) - + - Deliverables: ... + - Estimated: 2h, $2 + +- **WS-3: ** (migration) - + - Deliverables: ... + - Estimated: 1.5h, $1.50 + +### Wave 2 (Depends on Wave 1) +- **WS-2: ** (backend-developer) - + - Dependencies: WS-3 (needs database schema) + - Deliverables: ... + - Estimated: 1.5h, $1.50 + +### Wave 3 (Depends on Wave 2) +- **WS-4: ** (frontend-developer) - + - Dependencies: WS-1 (needs service interface) + - Deliverables: ... + - Estimated: 2h, $2 + +## Dependency Graph +``` + WS-1 + │ + ├─→ WS-2 + │ + WS-3 + │ + └─→ WS-4 +``` + +## Timeline +- Wave 1: 2h (WS-1, WS-3 in parallel) +- Wave 2: 1.5h (WS-2 waits for WS-3) +- Wave 3: 2h (WS-4 waits for WS-1) +- **Total: 5.5 hours** + +## Total Cost Estimate +- **Low**: $5.00 (efficient execution) +- **High**: $8.00 (with retries) + +## DAG File +Saved to: `~/.claude/orchestration/state/dag-.json` + +## Next Steps +To execute this plan: +```bash +/m-implement +``` + +To monitor progress: +```bash +/m-monitor +``` +``` + +## Important Notes + +- **Keep workstreams focused**: Don't create too many tiny workstreams +- **Minimize dependencies**: More parallelism = faster completion +- **Assign correct agent types**: Use specialized agents for best results +- **Include all deliverables**: Be specific about what each workstream produces +- **Estimate conservatively**: Better to over-estimate than under-estimate + +## Agent Types Available + +- `backend-developer` - Server-side code, APIs, services +- `frontend-developer` - UI components, React, TypeScript +- `migration` - Database schemas, Flyway migrations +- `test-writer-fixer` - E2E tests, test suites +- `documentation-specialist` - Docs, runbooks, guides +- `security-agent` - Security reviews, vulnerability fixes +- `performance-optimizer` - Performance analysis, optimization +- `devops-automator` - CI/CD, infrastructure, deployments + +## Example Usage + +**User Request:** +``` +/m-plan Implement authentication system with OAuth, JWT tokens, and user profile management +``` + +**Your Response:** +1. Ask clarifying questions (OAuth provider? Existing DB schema?) +2. Decompose into workstreams (auth service, OAuth integration, user profiles, frontend UI) +3. Identify dependencies (auth service → OAuth integration → frontend) +4. Create DAG JSON +5. Calculate waves +6. Estimate costs +7. Save DAG file +8. Present plan to user +9. Wait for approval before proceeding + +**After user approves:** +- Do NOT execute automatically +- Instruct user to run `/m-implement ` +- Provide monitoring commands + +--- + +**End of `/m-plan` command** diff --git a/claude-code-4.5/commands/merge-agent-work.md b/claude-code-4.5/commands/merge-agent-work.md new file mode 100644 index 0000000..f54bc4f --- /dev/null +++ b/claude-code-4.5/commands/merge-agent-work.md @@ -0,0 +1,30 @@ +# /merge-agent-work - Merge Agent Branch + +Merges an agent's branch into the current branch. + +## Usage + +```bash +/merge-agent-work {timestamp} +``` + +## Implementation + +```bash +#!/bin/bash + +AGENT_ID="$1" + +if [ -z "$AGENT_ID" ]; then + echo "❌ Agent ID required" + echo "Usage: /merge-agent-work {timestamp}" + exit 1 +fi + +# Source utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../utils/git-worktree-utils.sh" + +# Merge agent work +merge_agent_work "$AGENT_ID" +``` diff --git a/claude-code-4.5/commands/spawn-agent.md b/claude-code-4.5/commands/spawn-agent.md new file mode 100644 index 0000000..8ced240 --- /dev/null +++ b/claude-code-4.5/commands/spawn-agent.md @@ -0,0 +1,289 @@ +# /spawn-agent - Spawn Claude Agent in tmux Session + +Spawn a Claude Code agent in a separate tmux session with optional handover context. + +## Usage + +```bash +/spawn-agent "implement user authentication" +/spawn-agent "refactor the API layer" --with-handover +/spawn-agent "implement feature X" --with-worktree +/spawn-agent "review the PR" --with-worktree --with-handover +``` + +## Implementation + +```bash +#!/bin/bash + +# Function: Wait for Claude Code to be ready for input +wait_for_claude_ready() { + local SESSION=$1 + local TIMEOUT=30 + local START=$(date +%s) + + echo "⏳ Waiting for Claude to initialize..." + + while true; do + # Capture pane output (suppress errors if session not ready) + PANE_OUTPUT=$(tmux capture-pane -t "$SESSION" -p 2>/dev/null) + + # Check for Claude prompt/splash (any of these indicates readiness) + if echo "$PANE_OUTPUT" | grep -qE "Claude Code|Welcome back|──────|Style:|bypass permissions"; then + # Verify not in error state + if ! echo "$PANE_OUTPUT" | grep -qiE "error|crash|failed|command not found"; then + echo "✅ Claude initialized successfully" + return 0 + fi + fi + + # Timeout check + local ELAPSED=$(($(date +%s) - START)) + if [ $ELAPSED -gt $TIMEOUT ]; then + echo "❌ Timeout: Claude did not initialize within ${TIMEOUT}s" + echo "📋 Capturing debug output..." + tmux capture-pane -t "$SESSION" -p > "/tmp/spawn-agent-${SESSION}-failure.log" 2>&1 + echo "Debug output saved to /tmp/spawn-agent-${SESSION}-failure.log" + return 1 + fi + + sleep 0.2 + done +} + +# Parse arguments +TASK="$1" +WITH_HANDOVER=false +WITH_WORKTREE=false +shift + +# Parse flags +while [[ $# -gt 0 ]]; do + case $1 in + --with-handover) + WITH_HANDOVER=true + shift + ;; + --with-worktree) + WITH_WORKTREE=true + shift + ;; + *) + shift + ;; + esac +done + +if [ -z "$TASK" ]; then + echo "❌ Task description required" + echo "Usage: /spawn-agent \"task description\" [--with-handover] [--with-worktree]" + exit 1 +fi + +# Generate session info +TASK_ID=$(date +%s) +SESSION="agent-${TASK_ID}" + +# Setup working directory (worktree or current) +if [ "$WITH_WORKTREE" = true ]; then + # Detect transcrypt (informational only - works transparently with worktrees) + if git config --get-regexp '^transcrypt\.' >/dev/null 2>&1; then + echo "📦 Transcrypt detected - worktree will inherit encryption config automatically" + echo "" + fi + + # Get current branch as base + CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || echo "HEAD") + + # Generate task slug from task description + TASK_SLUG=$(echo "$TASK" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | tr -s ' ' '-' | cut -c1-40 | sed 's/-$//') + + # Source worktree utilities + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + source "$SCRIPT_DIR/../utils/git-worktree-utils.sh" + + # Create worktree with task slug + echo "🌳 Creating isolated git worktree..." + WORK_DIR=$(create_agent_worktree "$TASK_ID" "$CURRENT_BRANCH" "$TASK_SLUG") + AGENT_BRANCH="agent/agent-${TASK_ID}" + + echo "✅ Worktree created:" + echo " Directory: $WORK_DIR" + echo " Branch: $AGENT_BRANCH" + echo " Base: $CURRENT_BRANCH" + echo "" +else + WORK_DIR=$(pwd) + AGENT_BRANCH="" +fi + +echo "🚀 Spawning Claude agent in tmux session..." +echo "" + +# Generate handover if requested +HANDOVER_CONTENT="" +if [ "$WITH_HANDOVER" = true ]; then + echo "📝 Generating handover context..." + + # Get current branch and recent commits + CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown") + RECENT_COMMITS=$(git log --oneline -5 2>/dev/null || echo "No git history") + GIT_STATUS=$(git status -sb 2>/dev/null || echo "Not a git repo") + + # Create handover content + HANDOVER_CONTENT=$(cat << EOF + +# Handover Context + +## Current State +- Branch: $CURRENT_BRANCH +- Directory: $WORK_DIR +- Time: $(date) + +## Recent Commits +$RECENT_COMMITS + +## Git Status +$GIT_STATUS + +## Your Task +$TASK + +--- +Please review the above context and proceed with the task. +EOF +) + + echo "✅ Handover generated" + echo "" +fi + +# Create tmux session +tmux new-session -d -s "$SESSION" -c "$WORK_DIR" + +# Verify session creation +if ! tmux has-session -t "$SESSION" 2>/dev/null; then + echo "❌ Failed to create tmux session" + exit 1 +fi + +echo "✅ Created tmux session: $SESSION" +echo "" + +# Start Claude Code in the session +tmux send-keys -t "$SESSION" "claude --dangerously-skip-permissions" C-m + +# Wait for Claude to be ready (not just sleep!) +if ! wait_for_claude_ready "$SESSION"; then + echo "❌ Failed to start Claude agent - cleaning up..." + tmux kill-session -t "$SESSION" 2>/dev/null + exit 1 +fi + +# Additional small delay for UI stabilization +sleep 0.5 + +# Send handover context if generated (line-by-line to handle newlines) +if [ "$WITH_HANDOVER" = true ]; then + echo "📤 Sending handover context to agent..." + + # Send line-by-line to handle multi-line content properly + echo "$HANDOVER_CONTENT" | while IFS= read -r LINE || [ -n "$LINE" ]; do + # Use -l flag to send literal text (handles special characters) + tmux send-keys -t "$SESSION" -l "$LINE" + tmux send-keys -t "$SESSION" C-m + sleep 0.05 # Small delay between lines + done + + # Final Enter to submit + tmux send-keys -t "$SESSION" C-m + sleep 0.5 +fi + +# Send the task (use literal mode for safety with special characters) +echo "📤 Sending task to agent..." +tmux send-keys -t "$SESSION" -l "$TASK" +tmux send-keys -t "$SESSION" C-m + +# Small delay for Claude to start processing +sleep 1 + +# Verify task was received by checking if Claude is processing +CURRENT_OUTPUT=$(tmux capture-pane -t "$SESSION" -p 2>/dev/null) +if echo "$CURRENT_OUTPUT" | grep -qE "Thought for|Forming|Creating|Implement|⏳|✽|∴"; then + echo "✅ Task received and processing" +elif echo "$CURRENT_OUTPUT" | grep -qE "error|failed|crash"; then + echo "⚠️ Warning: Detected error in agent output" + echo "📋 Last 10 lines of output:" + tmux capture-pane -t "$SESSION" -p | tail -10 +else + echo "ℹ️ Task sent (unable to confirm receipt - agent may still be starting)" +fi + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "✨ Agent spawned successfully!" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "Session: $SESSION" +echo "Task: $TASK" +echo "Directory: $WORK_DIR" +echo "" +echo "To monitor:" +echo " tmux attach -t $SESSION" +echo "" +echo "To send more commands:" +echo " tmux send-keys -t $SESSION \"your command\" C-m" +echo "" +echo "To kill session:" +echo " tmux kill-session -t $SESSION" +echo "" + +# Save metadata +mkdir -p ~/.claude/agents +cat > ~/.claude/agents/${SESSION}.json < /dev/null && echo "❌ adb not found. Is Android SDK installed?" && exit 1 + +! emulator -list-avds 2>/dev/null | grep -q "^${DEVICE}$" && echo "❌ Emulator '$DEVICE' not found" && emulator -list-avds && exit 1 + +RUNNING_EMULATOR=$(adb devices | grep "emulator" | cut -f1) + +if [ -z "$RUNNING_EMULATOR" ]; then + emulator -avd "$DEVICE" -no-snapshot-load -no-boot-anim & + adb wait-for-device + sleep 5 + while [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do + sleep 2 + done +fi + +EMULATOR_SERIAL=$(adb devices | grep "emulator" | cut -f1 | head -1) +``` + +### Step 5: Setup Port Forwarding + +```bash +# For dev server access from emulator +if [ "$PROJECT_TYPE" = "react-native" ] || grep -q "\"dev\":" package.json 2>/dev/null; then + DEV_PORT=$(shuf -i 3000-9999 -n 1) + adb -s "$EMULATOR_SERIAL" reverse tcp:$DEV_PORT tcp:$DEV_PORT +fi +``` + +### Step 6: Configure Poltergeist (Optional) + +```bash +POLTERGEIST_AVAILABLE=false + +if command -v poltergeist &> /dev/null; then + POLTERGEIST_AVAILABLE=true + + [ ! -f ".poltergeist.yml" ] && cat > .poltergeist.yml </dev/null || echo "main") +TIMESTAMP=$(date +%s) +SESSION="android-${PROJECT_NAME}-${TIMESTAMP}" + +tmux new-session -d -s "$SESSION" -n build +``` + +### Step 8: Build & Install + +```bash +case $PROJECT_TYPE in + react-native) + tmux send-keys -t "$SESSION:build" "npx react-native run-android --variant=$VARIANT --deviceId=$EMULATOR_SERIAL" C-m + ;; + capacitor) + tmux send-keys -t "$SESSION:build" "npx cap sync android && npx cap run android --target=$EMULATOR_SERIAL" C-m + ;; + flutter) + tmux send-keys -t "$SESSION:build" "flutter run -d $EMULATOR_SERIAL --flavor $VARIANT" C-m + ;; + native) + tmux send-keys -t "$SESSION:build" "cd android && ./gradlew install${BUILD_TYPE} && cd .." C-m + ;; +esac +``` + +### Step 9: Setup Additional Windows + +```bash +# Dev server (if needed) +if [ "$PROJECT_TYPE" = "react-native" ] || grep -q "\"dev\":" package.json 2>/dev/null; then + tmux new-window -t "$SESSION" -n dev-server + tmux send-keys -t "$SESSION:dev-server" "PORT=$DEV_PORT npm start | tee dev-server.log" C-m +fi + +# Poltergeist (if available) +if [ "$POLTERGEIST_AVAILABLE" = true ]; then + tmux new-window -t "$SESSION" -n poltergeist + tmux send-keys -t "$SESSION:poltergeist" "poltergeist watch --platform android | tee poltergeist.log" C-m +fi + +# Logs +tmux new-window -t "$SESSION" -n logs +tmux send-keys -t "$SESSION:logs" "adb -s $EMULATOR_SERIAL logcat -v color" C-m + +# Git +tmux new-window -t "$SESSION" -n git +tmux send-keys -t "$SESSION:git" "git status" C-m +``` + +### Step 10: Save Metadata + +```bash +cat > .tmux-android-session.json < /dev/null; then + POLTERGEIST_AVAILABLE=true + + [ ! -f ".poltergeist.yml" ] && cat > .poltergeist.yml </dev/null || echo "main") +TIMESTAMP=$(date +%s) +SESSION="ios-${PROJECT_NAME}-${TIMESTAMP}" + +tmux new-session -d -s "$SESSION" -n build +``` + +### Step 7: Build & Install + +```bash +case $PROJECT_TYPE in + react-native) + tmux send-keys -t "$SESSION:build" "npx react-native run-ios --simulator='$DEVICE' --configuration $CONFIGURATION" C-m + ;; + capacitor) + tmux send-keys -t "$SESSION:build" "npx cap sync ios && npx cap run ios --target='$SIMULATOR_UDID' --configuration=$CONFIGURATION" C-m + ;; + native-pods|native) + WORKSPACE=$(find ios -name "*.xcworkspace" -maxdepth 1 | head -1) + if [ -n "$WORKSPACE" ]; then + tmux send-keys -t "$SESSION:build" "xcodebuild -workspace $WORKSPACE -scheme $SCHEME -configuration $CONFIGURATION -destination 'id=$SIMULATOR_UDID' build" C-m + fi + ;; +esac +``` + +### Step 8: Setup Additional Windows + +```bash +# Dev server (if needed) +if [ "$PROJECT_TYPE" = "react-native" ] || grep -q "\"dev\":" package.json 2>/dev/null; then + DEV_PORT=$(shuf -i 3000-9999 -n 1) + tmux new-window -t "$SESSION" -n dev-server + tmux send-keys -t "$SESSION:dev-server" "PORT=$DEV_PORT npm start | tee dev-server.log" C-m +fi + +# Poltergeist (if available) +if [ "$POLTERGEIST_AVAILABLE" = true ]; then + tmux new-window -t "$SESSION" -n poltergeist + tmux send-keys -t "$SESSION:poltergeist" "poltergeist watch --platform ios | tee poltergeist.log" C-m +fi + +# Logs +tmux new-window -t "$SESSION" -n logs +tmux send-keys -t "$SESSION:logs" "xcrun simctl spawn $SIMULATOR_UDID log stream --level debug" C-m + +# Git +tmux new-window -t "$SESSION" -n git +tmux send-keys -t "$SESSION:git" "git status" C-m +``` + +### Step 9: Save Metadata + +```bash +cat > .tmux-ios-session.json </dev/null + exit 1 +fi +``` + +### Step 2: Detect Project Type + +```bash +detect_project_type() { + if [ -f "package.json" ]; then + grep -q "\"next\":" package.json && echo "nextjs" && return + grep -q "\"vite\":" package.json && echo "vite" && return + grep -q "\"react-scripts\":" package.json && echo "cra" && return + grep -q "\"@vue/cli\":" package.json && echo "vue" && return + echo "node" + elif [ -f "requirements.txt" ] || [ -f "pyproject.toml" ]; then + grep -q "django" requirements.txt pyproject.toml 2>/dev/null && echo "django" && return + grep -q "flask" requirements.txt pyproject.toml 2>/dev/null && echo "flask" && return + echo "python" + elif [ -f "Cargo.toml" ]; then + echo "rust" + elif [ -f "go.mod" ]; then + echo "go" + else + echo "unknown" + fi +} + +PROJECT_TYPE=$(detect_project_type) +``` + +### Step 3: Detect Required Services + +```bash +NEEDS_SUPABASE=false +NEEDS_POSTGRES=false +NEEDS_REDIS=false + +[ -f "supabase/config.toml" ] && NEEDS_SUPABASE=true +grep -q "postgres" "$ENV_FILE" 2>/dev/null && NEEDS_POSTGRES=true +grep -q "redis" "$ENV_FILE" 2>/dev/null && NEEDS_REDIS=true +``` + +### Step 4: Generate Random Port + +```bash +DEV_PORT=$(shuf -i 3000-9999 -n 1) + +while lsof -i :$DEV_PORT >/dev/null 2>&1; do + DEV_PORT=$(shuf -i 3000-9999 -n 1) +done +``` + +### Step 5: Create tmux Session + +```bash +PROJECT_NAME=$(basename "$(pwd)") +BRANCH=$(git branch --show-current 2>/dev/null || echo "main") +TIMESTAMP=$(date +%s) +SESSION="dev-${PROJECT_NAME}-${TIMESTAMP}" + +tmux new-session -d -s "$SESSION" -n servers +``` + +### Step 6: Start Services + +```bash +PANE_COUNT=0 + +# Main dev server +case $PROJECT_TYPE in + nextjs|vite|cra|vue) + tmux send-keys -t "$SESSION:servers.${PANE_COUNT}" "PORT=$DEV_PORT npm run dev | tee dev-server-${DEV_PORT}.log" C-m + ;; + django) + tmux send-keys -t "$SESSION:servers.${PANE_COUNT}" "python manage.py runserver $DEV_PORT | tee dev-server-${DEV_PORT}.log" C-m + ;; + flask) + tmux send-keys -t "$SESSION:servers.${PANE_COUNT}" "FLASK_RUN_PORT=$DEV_PORT flask run | tee dev-server-${DEV_PORT}.log" C-m + ;; + *) + tmux send-keys -t "$SESSION:servers.${PANE_COUNT}" "PORT=$DEV_PORT npm run dev | tee dev-server-${DEV_PORT}.log" C-m + ;; +esac + +# Additional services (if needed) +if [ "$NEEDS_SUPABASE" = true ]; then + PANE_COUNT=$((PANE_COUNT + 1)) + tmux split-window -v -t "$SESSION:servers" + tmux send-keys -t "$SESSION:servers.${PANE_COUNT}" "supabase start" C-m +fi + +if [ "$NEEDS_POSTGRES" = true ] && [ "$NEEDS_SUPABASE" = false ]; then + PANE_COUNT=$((PANE_COUNT + 1)) + tmux split-window -v -t "$SESSION:servers" + tmux send-keys -t "$SESSION:servers.${PANE_COUNT}" "docker-compose up postgres" C-m +fi + +if [ "$NEEDS_REDIS" = true ]; then + PANE_COUNT=$((PANE_COUNT + 1)) + tmux split-window -v -t "$SESSION:servers" + tmux send-keys -t "$SESSION:servers.${PANE_COUNT}" "redis-server" C-m +fi + +tmux select-layout -t "$SESSION:servers" tiled +``` + +### Step 7: Create Additional Windows + +```bash +# Logs window +tmux new-window -t "$SESSION" -n logs +tmux send-keys -t "$SESSION:logs" "tail -f dev-server-${DEV_PORT}.log 2>/dev/null || sleep infinity" C-m + +# Work window +tmux new-window -t "$SESSION" -n work + +# Git window +tmux new-window -t "$SESSION" -n git +tmux send-keys -t "$SESSION:git" "git status" C-m +``` + +### Step 8: Save Metadata + +```bash +cat > .tmux-dev-session.json < +``` + +**Long-running detached sessions**: +``` +💡 Found dev sessions running >2 hours +Recommendation: Check if still needed: tmux attach -t +``` + +**Many sessions (>5)**: +``` +🧹 Found 5+ active sessions +Recommendation: Review and clean up unused sessions +``` + +## Use Cases + +### Before Starting New Environment + +```bash +/tmux-status +# Check for port conflicts and existing sessions before /start-local +``` + +### Monitor Agent Progress + +```bash +/tmux-status +# See status of spawned agents (running, completed, etc.) +``` + +### Session Discovery + +```bash +/tmux-status --detailed +# Find specific session by project name or port +``` + +## Notes + +- Read-only, never modifies sessions +- Uses tmux-monitor skill for discovery +- Integrates with tmuxwatch if available +- Detects metadata from `.tmux-dev-session.json` and `~/.claude/agents/*.json` diff --git a/claude-code-4.5/hooks/session_start.py b/claude-code-4.5/hooks/session_start.py index 670c439..9be155c 100755 --- a/claude-code-4.5/hooks/session_start.py +++ b/claude-code-4.5/hooks/session_start.py @@ -141,6 +141,73 @@ def load_development_context(source): return "\n".join(context_parts) +def load_tmux_sessions(): + """Load and display active tmux development sessions.""" + try: + # Find all tmux session metadata files + session_files = list(Path.cwd().glob('.tmux-*-session.json')) + + if not session_files: + return "📋 No active development sessions found" + + sessions = [] + for file in session_files: + try: + with open(file, 'r') as f: + data = json.load(f) + + # Verify session still exists + session_name = data.get('session') + if session_name: + check_result = subprocess.run( + ['tmux', 'has-session', '-t', session_name], + capture_output=True, + timeout=2 + ) + + if check_result.returncode == 0: + sessions.append({ + 'type': file.stem.replace('.tmux-', '').replace('-session', ''), + 'data': data + }) + except Exception: + continue + + if not sessions: + return "📋 No active development sessions found" + + # Format as table + lines = [ + "═══════════════════════════════════════════════════════════════", + " Active Development Sessions", + "═══════════════════════════════════════════════════════════════", + ] + + for sess in sessions: + data = sess['data'] + lines.append(f"\n [{sess['type'].upper()}] {data.get('session')}") + lines.append(f" Project: {data.get('project_name', 'N/A')}") + + branch = data.get('branch') + if branch and branch != 'main': + lines.append(f" Branch: {branch}") + + if 'dev_port' in data and data['dev_port']: + lines.append(f" Port: http://localhost:{data['dev_port']}") + + if 'environment' in data: + lines.append(f" Environment: {data['environment']}") + + lines.append(f" Attach: tmux attach -t {data.get('session')}") + + lines.append("\n═══════════════════════════════════════════════════════════════") + + return "\n".join(lines) + + except Exception as e: + return f"Failed to load tmux sessions: {e}" + + def main(): try: # Parse command line arguments @@ -196,17 +263,31 @@ def main(): except Exception as e: git_status_info.append(f"Failed to run git status: {e}") - # If we have git status info, output it as additional context - if git_status_info: - output = { - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": "\n\n".join(git_status_info) - } + # Store git status for potential combination with tmux sessions + # (Don't exit yet, combine with tmux sessions below) + + # Always load tmux sessions + tmux_sessions = load_tmux_sessions() + + # Combine git status (if requested) with tmux sessions + context_parts = [] + if args.git_status and git_status_info: + context_parts.extend(git_status_info) + + if tmux_sessions: + context_parts.append(tmux_sessions) + + # If we have any context to display, output it + if context_parts: + output = { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": "\n\n".join(context_parts) } - print(json.dumps(output)) - sys.exit(0) - + } + print(json.dumps(output)) + sys.exit(0) + # Load development context if requested if args.load_context: context = load_development_context(source) diff --git a/claude-code-4.5/orchestration/state/config.json b/claude-code-4.5/orchestration/state/config.json new file mode 100644 index 0000000..32484e6 --- /dev/null +++ b/claude-code-4.5/orchestration/state/config.json @@ -0,0 +1,24 @@ +{ + "orchestrator": { + "max_concurrent_agents": 4, + "idle_timeout_minutes": 15, + "checkpoint_interval_minutes": 5, + "max_retry_attempts": 3, + "polling_interval_seconds": 30 + }, + "merge": { + "default_strategy": "sequential", + "require_tests": true, + "auto_merge": false + }, + "monitoring": { + "check_interval_seconds": 30, + "log_level": "info", + "enable_cost_tracking": true + }, + "resource_limits": { + "max_budget_usd": 50, + "warn_at_percent": 80, + "hard_stop_at_percent": 100 + } +} diff --git a/claude-code-4.5/skills/frontend-design/SKILL.md b/claude-code-4.5/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..a928b72 --- /dev/null +++ b/claude-code-4.5/skills/frontend-design/SKILL.md @@ -0,0 +1,145 @@ +--- +name: frontend-design +description: Frontend design skill for UI/UX implementation - generates distinctive, production-grade interfaces +version: 1.0.0 +authors: + - Prithvi Rajasekaran + - Alexander Bricken +--- + +# Frontend Design Skill + +This skill helps create **distinctive, production-grade frontend interfaces** that avoid generic AI aesthetics. + +## Core Principles + +When building any frontend interface, follow these principles to create visually striking, memorable designs: + +### 1. Establish Bold Aesthetic Direction + +**Before writing any code**, define a clear aesthetic vision: + +- **Understand the purpose**: What is this interface trying to achieve? +- **Choose an extreme tone**: Select a distinctive aesthetic direction + - Brutalist: Raw, bold, functional + - Maximalist: Rich, layered, decorative + - Retro-futuristic: Nostalgic tech aesthetics + - Minimalist with impact: Powerful simplicity + - Neo-brutalist: Modern take on brutalism +- **Identify the unforgettable element**: What will make this design memorable? + +### 2. Implementation Standards + +Every interface you create should be: + +- ✅ **Production-grade and functional**: Code that works flawlessly +- ✅ **Visually striking and memorable**: Designs that stand out +- ✅ **Cohesive with clear aesthetic point-of-view**: Unified vision throughout + +## Critical Design Guidelines + +### Typography + +**Choose fonts that are beautiful, unique, and interesting.** + +- ❌ **AVOID**: Generic system fonts (Arial, Helvetica, default sans-serif) +- ✅ **USE**: Distinctive choices that elevate aesthetics + - Display fonts with character + - Unexpected font pairings + - Variable fonts for dynamic expression + - Fonts that reinforce your aesthetic direction + +### Color & Theme + +**Commit to cohesive aesthetics with CSS variables.** + +- ❌ **AVOID**: Generic color palettes, predictable combinations +- ✅ **USE**: Dominant colors with sharp accents + - Define comprehensive CSS custom properties + - Create mood through color temperature + - Use unexpected color combinations + - Build depth with tints, shades, and tones + +### Motion & Animation + +**Use high-impact animations that enhance the experience.** + +- For **HTML/CSS**: CSS-only animations (transforms, transitions, keyframes) +- For **React**: Motion library (Framer Motion, React Spring) +- ❌ **AVOID**: Generic fade-ins, boring transitions +- ✅ **USE**: High-impact moments + - Purposeful movement that guides attention + - Smooth, performant animations + - Delightful micro-interactions + - Entrance/exit animations with personality + +### Composition & Layout + +**Embrace unexpected layouts.** + +- ❌ **AVOID**: Predictable grids, centered everything, safe layouts +- ✅ **USE**: Bold composition choices + - Asymmetry + - Overlap + - Diagonal flow + - Unexpected whitespace + - Breaking the grid intentionally + +### Details & Atmosphere + +**Create atmosphere through thoughtful details.** + +- ✅ Textures and grain +- ✅ Sophisticated gradients +- ✅ Patterns and backgrounds +- ✅ Custom effects (blur, glow, shadows) +- ✅ Attention to spacing and rhythm + +## What to AVOID + +**Generic AI Design Patterns:** + +- ❌ Overused fonts (Inter, Roboto, Open Sans as defaults) +- ❌ Clichéd color schemes (purple gradients, generic blues) +- ❌ Predictable layouts (everything centered, safe grids) +- ❌ Cookie-cutter design that lacks context-specific character +- ❌ Lack of personality or point-of-view +- ❌ Generic animations (basic fade-ins everywhere) + +## Execution Philosophy + +**Show restraint or elaboration as the vision demands—execution quality matters most.** + +- Every design decision should serve the aesthetic direction +- Don't add complexity for its own sake +- Don't oversimplify when richness is needed +- Commit fully to your chosen direction +- Polish details relentlessly + +## Implementation Process + +When creating a frontend interface: + +1. **Define the aesthetic direction** (brutalist, maximalist, minimalist, etc.) +2. **Choose distinctive typography** that reinforces the aesthetic +3. **Establish color system** with CSS variables +4. **Design layout** with unexpected but purposeful composition +5. **Add motion** that enhances key moments +6. **Polish details** (textures, shadows, spacing) +7. **Review against principles** - is this distinctive and production-grade? + +## Examples of Strong Aesthetic Directions + +- **Brutalist Dashboard**: Monospace fonts, high contrast, grid-based, utilitarian +- **Retro-Futuristic Landing**: Neon colors, chrome effects, 80s sci-fi inspired +- **Minimalist with Impact**: Generous whitespace, bold typography, single accent color +- **Neo-Brutalist App**: Raw aesthetics, asymmetric layouts, bold shadows +- **Maximalist Content**: Rich layers, decorative elements, abundant color + +## Resources + +For deeper guidance on prompting for high-quality frontend design, see the [Frontend Aesthetics Cookbook](https://github.com/anthropics/claude-cookbooks/blob/main/coding/prompting_for_frontend_aesthetics.ipynb). + +--- + +**Remember**: The goal is to create interfaces that are both functionally excellent and visually unforgettable. Avoid generic AI aesthetics by committing to a clear, bold direction and executing it with meticulous attention to detail. diff --git a/claude-code-4.5/skills/retro-pdf/retro-pdf.md b/claude-code-4.5/skills/retro-pdf/retro-pdf.md new file mode 100644 index 0000000..40a2388 --- /dev/null +++ b/claude-code-4.5/skills/retro-pdf/retro-pdf.md @@ -0,0 +1,382 @@ +# Retro LaTeX-Style PDF Generation + +Convert markdown documents to professional, retro LaTeX-style PDFs with academic formatting. + +## Features + +- **LaTeX/Academic styling** - Libre Baskerville font (similar to Computer Modern), tight paragraph spacing +- **Clickable Table of Contents** - Auto-generated TOC with anchor links to all sections +- **Geek Corner / Example Corner boxes** - Open-ended table style with horizontal rules (title separated from content by thin line) +- **Academic tables** - Horizontal rules only, italic headers, no vertical borders +- **Full references section** - Proper citations with clickable URLs +- **No headers/footers** - Clean pages without date/time stamps + +## Usage + +When the user asks to convert a markdown document to a retro/LaTeX-style PDF, follow this process: + +### Step 1: Create the HTML Template + +Create an HTML file with this exact structure and styling: + +```html + + + + + {{DOCUMENT_TITLE}} + + + + + + +``` + +### Step 2: Structure the Content + +#### Title and Subtitle +```html +

Document Title

+

Subtitle or tagline in italics

+
+``` + +#### Table of Contents +```html + +
+``` + +#### Section Headings (with anchor IDs) +```html +

1. Section Title

+

1.1 Subsection Title

+

Step 1: Sub-subsection (italic)

+``` + +#### Geek Corner / Example Corner Box +```html +
+
+ Geek Corner: Title Here +
+
+ "The witty or insightful quote goes here in italics." +
+
+``` + +#### Tables (Academic Style) +```html + + + + + + + + + + + +
Column 1Column 2Column 3
DataDataData
+``` + +#### Footnote References (in text) +```html +[1] +``` + +#### References Section +```html +
+

References

+
+

[1] Author, Name. "Article Title." Publication (Year). https://url.com — Brief description.

+

[2] ...

+
+``` + +#### Key Takeaway +```html +

Key Takeaway: Important summary text here.

+``` + +### Step 3: Generate the PDF + +Use Chrome headless to generate the PDF without headers/footers: + +```bash +"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + --headless \ + --disable-gpu \ + --print-to-pdf="/path/to/output.pdf" \ + --print-to-pdf-no-header \ + --no-pdf-header-footer \ + "file:///path/to/input.html" +``` + +On Linux: +```bash +google-chrome --headless --disable-gpu --print-to-pdf="/path/to/output.pdf" --print-to-pdf-no-header --no-pdf-header-footer "file:///path/to/input.html" +``` + +## Style Guidelines + +1. **Paragraphs**: Keep text dense and justified. Collapse bullet points into flowing prose where appropriate. + +2. **Geek Corner**: Use for witty asides, technical insights, or memorable quotes. Always include: + - A catchy title (e.g., "Geek Corner: Amdahl's Law, App Edition") + - Italic content in quotes + +3. **Tables**: Use sparingly for data comparisons. Always use the open style (horizontal rules only). + +4. **References**: Always include full citations with: + - Author name + - Article/document title in quotes + - Publication/source and year + - Full clickable URL + - Brief description of what the reference covers + +5. **Spacing**: Keep tight - 6px paragraph margins, 1.35 line height. + +## Example Output + +See `/Users/stevengonsalvez/d/btg/multibrand/write-up-simple.html` for a complete example of this formatting applied to a technical write-up. diff --git a/claude-code-4.5/skills/tmux-monitor/SKILL.md b/claude-code-4.5/skills/tmux-monitor/SKILL.md new file mode 100644 index 0000000..42cb23c --- /dev/null +++ b/claude-code-4.5/skills/tmux-monitor/SKILL.md @@ -0,0 +1,370 @@ +--- +name: tmux-monitor +description: Monitor and report status of all tmux sessions including dev environments, spawned agents, and running processes. Uses tmuxwatch for enhanced visibility. +version: 1.0.0 +--- + +# tmux-monitor Skill + +## Purpose + +Provide comprehensive visibility into all active tmux sessions, running processes, and spawned agents. This skill enables checking what's running where without needing to manually inspect each session. + +## Capabilities + +1. **Session Discovery**: Find and categorize all tmux sessions +2. **Process Inspection**: Identify running servers, dev environments, agents +3. **Port Mapping**: Show which ports are in use and by what +4. **Status Reporting**: Generate detailed reports with recommendations +5. **tmuxwatch Integration**: Use tmuxwatch for enhanced real-time monitoring +6. **Metadata Extraction**: Read session metadata from .tmux-dev-session.json and agent JSON files + +## When to Use + +- User asks "what's running?" +- Before starting new dev environments (check port conflicts) +- After spawning agents (verify they started correctly) +- When debugging server/process issues +- Before session cleanup +- When context switching between projects + +## Implementation + +### Step 1: Check tmux Availability + +```bash +if ! command -v tmux &> /dev/null; then + echo "❌ tmux is not installed" + exit 1 +fi + +if ! tmux list-sessions 2>/dev/null; then + echo "✅ No tmux sessions currently running" + exit 0 +fi +``` + +### Step 2: Discover All Sessions + +```bash +# Get all sessions with metadata +SESSIONS=$(tmux list-sessions -F '#{session_name}|#{session_windows}|#{session_created}|#{session_attached}') + +# Count sessions +TOTAL_SESSIONS=$(echo "$SESSIONS" | wc -l | tr -d ' ') +``` + +### Step 3: Categorize Sessions + +Group by prefix pattern: + +- `dev-*` → Development environments +- `agent-*` → Spawned agents +- `claude-*` → Claude Code sessions +- `monitor-*` → Monitoring sessions +- Others → Miscellaneous + +```bash +DEV_SESSIONS=$(echo "$SESSIONS" | grep "^dev-" || true) +AGENT_SESSIONS=$(echo "$SESSIONS" | grep "^agent-" || true) +CLAUDE_SESSIONS=$(echo "$SESSIONS" | grep "^claude-" || true) +``` + +### Step 4: Extract Details for Each Session + +For each session, gather: + +**Window Information**: +```bash +tmux list-windows -t "$SESSION" -F '#{window_index}:#{window_name}:#{window_panes}' +``` + +**Running Processes** (from first pane of each window): +```bash +tmux capture-pane -t "$SESSION:0.0" -p -S -10 -E 0 +``` + +**Port Detection** (check for listening ports): +```bash +# Extract ports from session metadata +if [ -f ".tmux-dev-session.json" ]; then + BACKEND_PORT=$(jq -r '.backend.port // empty' .tmux-dev-session.json) + FRONTEND_PORT=$(jq -r '.frontend.port // empty' .tmux-dev-session.json) +fi + +# Or detect from process list +lsof -nP -iTCP -sTCP:LISTEN | grep -E "node|python|uv|npm" +``` + +### Step 5: Load Session Metadata + +**Dev Environment Metadata** (`.tmux-dev-session.json`): +```bash +if [ -f ".tmux-dev-session.json" ]; then + PROJECT=$(jq -r '.project' .tmux-dev-session.json) + TYPE=$(jq -r '.type' .tmux-dev-session.json) + BACKEND_PORT=$(jq -r '.backend.port // "N/A"' .tmux-dev-session.json) + FRONTEND_PORT=$(jq -r '.frontend.port // "N/A"' .tmux-dev-session.json) + CREATED=$(jq -r '.created' .tmux-dev-session.json) +fi +``` + +**Agent Metadata** (`~/.claude/agents/*.json`): +```bash +if [ -f "$HOME/.claude/agents/${SESSION}.json" ]; then + AGENT_TYPE=$(jq -r '.agent_type' "$HOME/.claude/agents/${SESSION}.json") + TASK=$(jq -r '.task' "$HOME/.claude/agents/${SESSION}.json") + STATUS=$(jq -r '.status' "$HOME/.claude/agents/${SESSION}.json") + DIRECTORY=$(jq -r '.directory' "$HOME/.claude/agents/${SESSION}.json") + CREATED=$(jq -r '.created' "$HOME/.claude/agents/${SESSION}.json") +fi +``` + +### Step 6: tmuxwatch Integration + +If tmuxwatch is available, offer enhanced view: + +```bash +if command -v tmuxwatch &> /dev/null; then + echo "" + echo "📊 Enhanced Monitoring Available:" + echo " Real-time TUI: tmuxwatch" + echo " JSON export: tmuxwatch --dump | jq" + echo "" + + # Optional: Use tmuxwatch for structured data + TMUXWATCH_DATA=$(tmuxwatch --dump 2>/dev/null || echo "{}") +fi +``` + +### Step 7: Generate Comprehensive Report + +```markdown +# tmux Sessions Overview + +**Total Active Sessions**: {count} +**Total Windows**: {window_count} +**Total Panes**: {pane_count} + +--- + +## Development Environments ({dev_count}) + +### 1. dev-myapp-1705161234 +- **Type**: fullstack +- **Project**: myapp +- **Status**: ⚡ Active (attached) +- **Windows**: 4 (servers, logs, claude-work, git) +- **Panes**: 8 +- **Backend**: Port 8432 → http://localhost:8432 +- **Frontend**: Port 3891 → http://localhost:3891 +- **Created**: 2025-01-13 14:30:00 (2h ago) +- **Attach**: `tmux attach -t dev-myapp-1705161234` + +--- + +## Spawned Agents ({agent_count}) + +### 2. agent-1705160000 +- **Agent Type**: codex +- **Task**: Refactor authentication module +- **Status**: ⚙️ Running (15 minutes) +- **Working Directory**: /Users/stevie/projects/myapp +- **Git Worktree**: worktrees/agent-1705160000 +- **Windows**: 1 (work) +- **Panes**: 2 (agent | monitoring) +- **Last Output**: "Analyzing auth.py dependencies..." +- **Attach**: `tmux attach -t agent-1705160000` +- **Metadata**: `~/.claude/agents/agent-1705160000.json` + +### 3. agent-1705161000 +- **Agent Type**: aider +- **Task**: Generate API documentation +- **Status**: ✅ Completed (5 minutes ago) +- **Output**: Documentation written to docs/api/ +- **Attach**: `tmux attach -t agent-1705161000` (review) +- **Cleanup**: `tmux kill-session -t agent-1705161000` + +--- + +## Running Processes Summary + +| Port | Service | Session | Status | +|------|--------------|--------------------------|---------| +| 8432 | Backend API | dev-myapp-1705161234 | Running | +| 3891 | Frontend Dev | dev-myapp-1705161234 | Running | +| 5160 | Supabase | dev-shotclubhouse-xxx | Running | + +--- + +## Quick Actions + +**Attach to session**: +```bash +tmux attach -t +``` + +**Kill session**: +```bash +tmux kill-session -t +``` + +**List all sessions**: +```bash +tmux ls +``` + +**Kill all completed agents**: +```bash +for session in $(tmux ls | grep "^agent-" | cut -d: -f1); do + STATUS=$(jq -r '.status' "$HOME/.claude/agents/${session}.json" 2>/dev/null) + if [ "$STATUS" = "completed" ]; then + tmux kill-session -t "$session" + fi +done +``` + +--- + +## Recommendations + +{generated based on findings} +``` + +### Step 8: Provide Contextual Recommendations + +**If completed agents found**: +``` +⚠️ Found 1 completed agent session: + - agent-1705161000: Task completed 5 minutes ago + +Recommendation: Review results and clean up: + tmux attach -t agent-1705161000 # Review + tmux kill-session -t agent-1705161000 # Cleanup +``` + +**If long-running detached sessions**: +``` +💡 Found detached session running for 2h 40m: + - dev-api-service-1705159000 + +Recommendation: Check if still needed: + tmux attach -t dev-api-service-1705159000 +``` + +**If port conflicts detected**: +``` +⚠️ Port conflict detected: + - Port 3000 in use by dev-oldproject-xxx + - New session will use random port instead + +Recommendation: Clean up old session if no longer needed +``` + +## Output Formats + +### Compact (Default) + +``` +5 active sessions: +- dev-myapp-1705161234 (fullstack, 4 windows, active) +- dev-api-service-1705159000 (backend-only, 4 windows, detached) +- agent-1705160000 (codex, running 15m) +- agent-1705161000 (aider, completed ✓) +- claude-work (main session, current) + +3 running servers: +- Port 8432: Backend API (dev-myapp) +- Port 3891: Frontend Dev (dev-myapp) +- Port 5160: Supabase (dev-shotclubhouse) +``` + +### Detailed (Verbose) + +Full report with all metadata, sample output, recommendations. + +### JSON (Programmatic) + +```json +{ + "sessions": [ + { + "name": "dev-myapp-1705161234", + "type": "dev-environment", + "category": "fullstack", + "windows": 4, + "panes": 8, + "status": "attached", + "created": "2025-01-13T14:30:00Z", + "ports": { + "backend": 8432, + "frontend": 3891 + }, + "metadata_file": ".tmux-dev-session.json" + }, + { + "name": "agent-1705160000", + "type": "spawned-agent", + "agent_type": "codex", + "task": "Refactor authentication module", + "status": "running", + "runtime": "15m", + "directory": "/Users/stevie/projects/myapp", + "worktree": "worktrees/agent-1705160000", + "metadata_file": "~/.claude/agents/agent-1705160000.json" + } + ], + "summary": { + "total_sessions": 5, + "total_windows": 12, + "total_panes": 28, + "running_servers": 3, + "active_agents": 1, + "completed_agents": 1 + }, + "ports": [ + {"port": 8432, "service": "Backend API", "session": "dev-myapp-1705161234"}, + {"port": 3891, "service": "Frontend Dev", "session": "dev-myapp-1705161234"}, + {"port": 5160, "service": "Supabase", "session": "dev-shotclubhouse-xxx"} + ] +} +``` + +## Integration with Commands + +This skill is used by: +- `/tmux-status` command (user-facing command) +- Automatically before starting new dev environments (conflict detection) +- By spawned agents to check session status + +## Dependencies + +- `tmux` (required) +- `jq` (required for JSON parsing) +- `lsof` (optional, for port detection) +- `tmuxwatch` (optional, for enhanced monitoring) + +## File Structure + +``` +~/.claude/agents/ + agent-{timestamp}.json # Agent metadata + +.tmux-dev-session.json # Dev environment metadata (per project) + +/tmp/tmux-monitor-cache.json # Optional cache for performance +``` + +## Related Commands + +- `/tmux-status` - User-facing wrapper around this skill +- `/spawn-agent` - Creates sessions that this skill monitors +- `/start-local`, `/start-ios`, `/start-android` - Create dev environments + +## Notes + +- This skill is read-only, never modifies sessions +- Safe to run anytime without side effects +- Provides snapshot of current state +- Can be cached for performance (TTL: 10 seconds) +- Should be run before potentially conflicting operations diff --git a/claude-code-4.5/skills/tmux-monitor/scripts/monitor.sh b/claude-code-4.5/skills/tmux-monitor/scripts/monitor.sh new file mode 100755 index 0000000..0b1d3f5 --- /dev/null +++ b/claude-code-4.5/skills/tmux-monitor/scripts/monitor.sh @@ -0,0 +1,417 @@ +#!/bin/bash + +# ABOUTME: tmux session monitoring script - discovers, categorizes, and reports status of all active tmux sessions + +set -euo pipefail + +# Output mode: compact (default), detailed, json +OUTPUT_MODE="${1:-compact}" + +# Check if tmux is available +if ! command -v tmux &> /dev/null; then + echo "❌ tmux is not installed" + exit 1 +fi + +# Check if there are any sessions +if ! tmux list-sessions 2>/dev/null | grep -q .; then + if [ "$OUTPUT_MODE" = "json" ]; then + echo '{"sessions": [], "summary": {"total_sessions": 0, "total_windows": 0, "total_panes": 0}}' + else + echo "✅ No tmux sessions currently running" + fi + exit 0 +fi + +# Initialize counters +TOTAL_SESSIONS=0 +TOTAL_WINDOWS=0 +TOTAL_PANES=0 + +# Arrays to store sessions by category +declare -a DEV_SESSIONS +declare -a AGENT_SESSIONS +declare -a MONITOR_SESSIONS +declare -a CLAUDE_SESSIONS +declare -a OTHER_SESSIONS + +# Get all sessions +SESSIONS=$(tmux list-sessions -F '#{session_name}|#{session_windows}|#{session_created}|#{session_attached}' 2>/dev/null) + +# Parse and categorize sessions +while IFS='|' read -r SESSION_NAME WINDOW_COUNT CREATED ATTACHED; do + TOTAL_SESSIONS=$((TOTAL_SESSIONS + 1)) + TOTAL_WINDOWS=$((TOTAL_WINDOWS + WINDOW_COUNT)) + + # Get pane count for this session + PANE_COUNT=$(tmux list-panes -t "$SESSION_NAME" 2>/dev/null | wc -l | tr -d ' ') + TOTAL_PANES=$((TOTAL_PANES + PANE_COUNT)) + + # Categorize by prefix + if [[ "$SESSION_NAME" == dev-* ]]; then + DEV_SESSIONS+=("$SESSION_NAME|$WINDOW_COUNT|$PANE_COUNT|$ATTACHED") + elif [[ "$SESSION_NAME" == agent-* ]]; then + AGENT_SESSIONS+=("$SESSION_NAME|$WINDOW_COUNT|$PANE_COUNT|$ATTACHED") + elif [[ "$SESSION_NAME" == monitor-* ]]; then + MONITOR_SESSIONS+=("$SESSION_NAME|$WINDOW_COUNT|$PANE_COUNT|$ATTACHED") + elif [[ "$SESSION_NAME" == claude-* ]] || [[ "$SESSION_NAME" == *claude* ]]; then + CLAUDE_SESSIONS+=("$SESSION_NAME|$WINDOW_COUNT|$PANE_COUNT|$ATTACHED") + else + OTHER_SESSIONS+=("$SESSION_NAME|$WINDOW_COUNT|$PANE_COUNT|$ATTACHED") + fi +done <<< "$SESSIONS" + +# Helper function to get session metadata +get_dev_metadata() { + local SESSION_NAME=$1 + local METADATA_FILE=".tmux-dev-session.json" + + if [ -f "$METADATA_FILE" ]; then + local SESSION_IN_FILE=$(jq -r '.session // empty' "$METADATA_FILE" 2>/dev/null) + if [ "$SESSION_IN_FILE" = "$SESSION_NAME" ]; then + echo "$METADATA_FILE" + fi + fi + + # Try iOS-specific metadata + if [ -f ".tmux-ios-session.json" ]; then + local SESSION_IN_FILE=$(jq -r '.session // empty' ".tmux-ios-session.json" 2>/dev/null) + if [ "$SESSION_IN_FILE" = "$SESSION_NAME" ]; then + echo ".tmux-ios-session.json" + fi + fi + + # Try Android-specific metadata + if [ -f ".tmux-android-session.json" ]; then + local SESSION_IN_FILE=$(jq -r '.session // empty' ".tmux-android-session.json" 2>/dev/null) + if [ "$SESSION_IN_FILE" = "$SESSION_NAME" ]; then + echo ".tmux-android-session.json" + fi + fi +} + +get_agent_metadata() { + local SESSION_NAME=$1 + local METADATA_FILE="$HOME/.claude/agents/${SESSION_NAME}.json" + + if [ -f "$METADATA_FILE" ]; then + echo "$METADATA_FILE" + fi +} + +# Get running ports +get_running_ports() { + if command -v lsof &> /dev/null; then + lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | grep -E "node|python|uv|npm|ruby|java" | awk '{print $9}' | cut -d':' -f2 | sort -u || true + fi +} + +RUNNING_PORTS=$(get_running_ports) + +# Output functions + +output_compact() { + echo "${TOTAL_SESSIONS} active sessions:" + + # Dev environments + if [[ -v DEV_SESSIONS[@] ]] && [ ${#DEV_SESSIONS[@]} -gt 0 ]; then + for session_data in "${DEV_SESSIONS[@]}"; do + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + STATUS="detached" + [ "$ATTACHED" = "1" ] && STATUS="active" + + # Try to get metadata + METADATA_FILE=$(get_dev_metadata "$SESSION_NAME") + if [ -n "$METADATA_FILE" ]; then + PROJECT_TYPE=$(jq -r '.type // "dev"' "$METADATA_FILE" 2>/dev/null) + echo "- $SESSION_NAME ($PROJECT_TYPE, $WINDOW_COUNT windows, $STATUS)" + else + echo "- $SESSION_NAME ($WINDOW_COUNT windows, $STATUS)" + fi + done + fi + + # Agent sessions + if [[ -v AGENT_SESSIONS[@] ]] && [ ${#AGENT_SESSIONS[@]} -gt 0 ]; then + for session_data in "${AGENT_SESSIONS[@]}"; do + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + + # Try to get agent metadata + METADATA_FILE=$(get_agent_metadata "$SESSION_NAME") + if [ -n "$METADATA_FILE" ]; then + AGENT_TYPE=$(jq -r '.agent_type // "unknown"' "$METADATA_FILE" 2>/dev/null) + STATUS_=$(jq -r '.status // "running"' "$METADATA_FILE" 2>/dev/null) + echo "- $SESSION_NAME ($AGENT_TYPE, $STATUS_)" + else + echo "- $SESSION_NAME (agent)" + fi + done + fi + + # Claude sessions + if [[ -v CLAUDE_SESSIONS[@] ]] && [ ${#CLAUDE_SESSIONS[@]} -gt 0 ]; then + for session_data in "${CLAUDE_SESSIONS[@]}"; do + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + STATUS="detached" + [ "$ATTACHED" = "1" ] && STATUS="current" + echo "- $SESSION_NAME (main session, $STATUS)" + done + fi + + # Other sessions + if [[ -v OTHER_SESSIONS[@] ]] && [ ${#OTHER_SESSIONS[@]} -gt 0 ]; then + for session_data in "${OTHER_SESSIONS[@]}"; do + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + echo "- $SESSION_NAME ($WINDOW_COUNT windows)" + done + fi + + # Port summary + if [ -n "$RUNNING_PORTS" ]; then + PORT_COUNT=$(echo "$RUNNING_PORTS" | wc -l | tr -d ' ') + echo "" + echo "$PORT_COUNT running servers on ports: $(echo $RUNNING_PORTS | tr '\n' ',' | sed 's/,$//')" + fi + + echo "" + echo "Use /tmux-status --detailed for full report" +} + +output_detailed() { + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "📊 tmux Sessions Overview" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "**Total Active Sessions**: $TOTAL_SESSIONS" + echo "**Total Windows**: $TOTAL_WINDOWS" + echo "**Total Panes**: $TOTAL_PANES" + echo "" + + # Dev environments + if [[ -v DEV_SESSIONS[@] ]] && [ ${#DEV_SESSIONS[@]} -gt 0 ]; then + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "## Development Environments (${#DEV_SESSIONS[@]})" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + + local INDEX=1 + for session_data in "${DEV_SESSIONS[@]}"; do + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + STATUS="🔌 Detached" + [ "$ATTACHED" = "1" ] && STATUS="⚡ Active (attached)" + + echo "### $INDEX. $SESSION_NAME" + echo "- **Status**: $STATUS" + echo "- **Windows**: $WINDOW_COUNT" + echo "- **Panes**: $PANE_COUNT" + + # Get metadata if available + METADATA_FILE=$(get_dev_metadata "$SESSION_NAME") + if [ -n "$METADATA_FILE" ]; then + PROJECT=$(jq -r '.project // "unknown"' "$METADATA_FILE" 2>/dev/null) + PROJECT_TYPE=$(jq -r '.type // "unknown"' "$METADATA_FILE" 2>/dev/null) + CREATED=$(jq -r '.created // "unknown"' "$METADATA_FILE" 2>/dev/null) + + echo "- **Project**: $PROJECT ($PROJECT_TYPE)" + echo "- **Created**: $CREATED" + + # Check for ports + if jq -e '.dev_port' "$METADATA_FILE" &>/dev/null; then + DEV_PORT=$(jq -r '.dev_port' "$METADATA_FILE" 2>/dev/null) + echo "- **Dev Server**: http://localhost:$DEV_PORT" + fi + + if jq -e '.services' "$METADATA_FILE" &>/dev/null; then + echo "- **Services**: $(jq -r '.services | keys | join(", ")' "$METADATA_FILE" 2>/dev/null)" + fi + fi + + echo "- **Attach**: \`tmux attach -t $SESSION_NAME\`" + echo "" + + INDEX=$((INDEX + 1)) + done + fi + + # Agent sessions + if [[ -v AGENT_SESSIONS[@] ]] && [ ${#AGENT_SESSIONS[@]} -gt 0 ]; then + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "## Spawned Agents (${#AGENT_SESSIONS[@]})" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + + local INDEX=1 + for session_data in "${AGENT_SESSIONS[@]}"; do + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + + echo "### $INDEX. $SESSION_NAME" + + # Get agent metadata + METADATA_FILE=$(get_agent_metadata "$SESSION_NAME") + if [ -n "$METADATA_FILE" ]; then + AGENT_TYPE=$(jq -r '.agent_type // "unknown"' "$METADATA_FILE" 2>/dev/null) + TASK=$(jq -r '.task // "unknown"' "$METADATA_FILE" 2>/dev/null) + STATUS_=$(jq -r '.status // "running"' "$METADATA_FILE" 2>/dev/null) + DIRECTORY=$(jq -r '.directory // "unknown"' "$METADATA_FILE" 2>/dev/null) + CREATED=$(jq -r '.created // "unknown"' "$METADATA_FILE" 2>/dev/null) + + echo "- **Agent Type**: $AGENT_TYPE" + echo "- **Task**: $TASK" + echo "- **Status**: $([ "$STATUS_" = "completed" ] && echo "✅ Completed" || echo "⚙️ Running")" + echo "- **Working Directory**: $DIRECTORY" + echo "- **Created**: $CREATED" + + # Check for worktree + if jq -e '.worktree' "$METADATA_FILE" &>/dev/null; then + WORKTREE=$(jq -r '.worktree' "$METADATA_FILE" 2>/dev/null) + if [ "$WORKTREE" = "true" ]; then + AGENT_BRANCH=$(jq -r '.agent_branch // "unknown"' "$METADATA_FILE" 2>/dev/null) + echo "- **Git Worktree**: Yes (branch: $AGENT_BRANCH)" + fi + fi + fi + + echo "- **Attach**: \`tmux attach -t $SESSION_NAME\`" + echo "- **Metadata**: \`cat $METADATA_FILE\`" + echo "" + + INDEX=$((INDEX + 1)) + done + fi + + # Claude sessions + if [[ -v CLAUDE_SESSIONS[@] ]] && [ ${#CLAUDE_SESSIONS[@]} -gt 0 ]; then + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "## Other Sessions (${#CLAUDE_SESSIONS[@]})" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + + for session_data in "${CLAUDE_SESSIONS[@]}"; do + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + STATUS="Detached" + [ "$ATTACHED" = "1" ] && STATUS="⚡ Active (current session)" + echo "- $SESSION_NAME: $STATUS" + done + echo "" + fi + + # Running processes summary + if [ -n "$RUNNING_PORTS" ]; then + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "## Running Processes Summary" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "| Port | Service | Status |" + echo "|------|---------|--------|" + for PORT in $RUNNING_PORTS; do + echo "| $PORT | Running | ✅ |" + done + echo "" + fi + + # Quick actions + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "## Quick Actions" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "**List all sessions**:" + echo "\`\`\`bash" + echo "tmux ls" + echo "\`\`\`" + echo "" + echo "**Attach to session**:" + echo "\`\`\`bash" + echo "tmux attach -t " + echo "\`\`\`" + echo "" + echo "**Kill session**:" + echo "\`\`\`bash" + echo "tmux kill-session -t " + echo "\`\`\`" + echo "" +} + +output_json() { + echo "{" + echo " \"sessions\": [" + + local FIRST_SESSION=true + + # Dev sessions + for session_data in "${DEV_SESSIONS[@]}"; do + [ "$FIRST_SESSION" = false ] && echo "," + FIRST_SESSION=false + + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + + echo " {" + echo " \"name\": \"$SESSION_NAME\"," + echo " \"type\": \"dev-environment\"," + echo " \"windows\": $WINDOW_COUNT," + echo " \"panes\": $PANE_COUNT," + echo " \"attached\": $([ "$ATTACHED" = "1" ] && echo "true" || echo "false")" + + # Get metadata if available + METADATA_FILE=$(get_dev_metadata "$SESSION_NAME") + if [ -n "$METADATA_FILE" ]; then + echo " ,\"metadata_file\": \"$METADATA_FILE\"" + fi + + echo -n " }" + done + + # Agent sessions + for session_data in "${AGENT_SESSIONS[@]}"; do + [ "$FIRST_SESSION" = false ] && echo "," + FIRST_SESSION=false + + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + + echo " {" + echo " \"name\": \"$SESSION_NAME\"," + echo " \"type\": \"spawned-agent\"," + echo " \"windows\": $WINDOW_COUNT," + echo " \"panes\": $PANE_COUNT" + + # Get agent metadata + METADATA_FILE=$(get_agent_metadata "$SESSION_NAME") + if [ -n "$METADATA_FILE" ]; then + AGENT_TYPE=$(jq -r '.agent_type // "unknown"' "$METADATA_FILE" 2>/dev/null) + STATUS_=$(jq -r '.status // "running"' "$METADATA_FILE" 2>/dev/null) + echo " ,\"agent_type\": \"$AGENT_TYPE\"," + echo " \"status\": \"$STATUS_\"," + echo " \"metadata_file\": \"$METADATA_FILE\"" + fi + + echo -n " }" + done + + echo "" + echo " ]," + echo " \"summary\": {" + echo " \"total_sessions\": $TOTAL_SESSIONS," + echo " \"total_windows\": $TOTAL_WINDOWS," + echo " \"total_panes\": $TOTAL_PANES," + echo " \"dev_sessions\": ${#DEV_SESSIONS[@]}," + echo " \"agent_sessions\": ${#AGENT_SESSIONS[@]}" + echo " }" + echo "}" +} + +# Main output +case "$OUTPUT_MODE" in + compact) + output_compact + ;; + detailed) + output_detailed + ;; + json) + output_json + ;; + *) + echo "Unknown output mode: $OUTPUT_MODE" + echo "Usage: monitor.sh [compact|detailed|json]" + exit 1 + ;; +esac diff --git a/claude-code-4.5/skills/webapp-testing/SKILL.md b/claude-code-4.5/skills/webapp-testing/SKILL.md index 4726215..bf2f534 100644 --- a/claude-code-4.5/skills/webapp-testing/SKILL.md +++ b/claude-code-4.5/skills/webapp-testing/SKILL.md @@ -13,6 +13,98 @@ To test local web applications, write native Python Playwright scripts. **Always run scripts with `--help` first** to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window. +## Browser Tools (Direct Chrome DevTools Control) + +The `bin/browser-tools` utility provides lightweight, context-rot-proof browser automation using the Chrome DevTools Protocol directly (no MCP overhead). + +**Available Commands**: + +```bash +# Launch browser and get connection details +bin/browser-tools start [--port PORT] [--headless] + +# Navigate to URL +bin/browser-tools nav [--wait-for {load|networkidle|domcontentloaded}] + +# Evaluate JavaScript +bin/browser-tools eval "" + +# Take screenshot +bin/browser-tools screenshot [--full-page] [--selector CSS_SELECTOR] + +# Interactive element picker (returns selectors) +bin/browser-tools pick + +# Get console logs +bin/browser-tools console [--level {log|warn|error|all}] + +# Search page content +bin/browser-tools search "" [--case-sensitive] + +# Extract page content (markdown, links, text) +bin/browser-tools content [--format {markdown|links|text}] + +# Get/set cookies +bin/browser-tools cookies [--set NAME=VALUE] [--domain DOMAIN] + +# Inspect element details +bin/browser-tools inspect + +# Terminate browser session +bin/browser-tools kill +``` + +**When to Use Browser-Tools vs Playwright**: + +✅ **Use browser-tools when**: +- Quick inspection/debugging of running apps +- Need to identify selectors interactively (`pick` command) +- Extracting page content for analysis +- Running simple JavaScript snippets +- Monitoring console logs in real-time +- Taking quick screenshots without writing scripts + +✅ **Use Playwright when**: +- Complex multi-step automation workflows +- Need full test assertion framework +- Handling multi-step forms +- Database integration (Supabase utilities) +- Comprehensive test coverage +- Generating test reports + +**Example Workflow**: + +```bash +# 1. Start browser pointed at your app +bin/browser-tools start --port 9222 + +# 2. Navigate to the page +bin/browser-tools nav http://localhost:3000 + +# 3. Use interactive picker to find selectors +bin/browser-tools pick +# Click on elements in the browser, get their selectors + +# 4. Inspect specific elements +bin/browser-tools inspect "button.submit" + +# 5. Take screenshot for documentation +bin/browser-tools screenshot /tmp/page.png --full-page + +# 6. Check console for errors +bin/browser-tools console --level error + +# 7. Clean up +bin/browser-tools kill +``` + +**Benefits**: +- **No MCP overhead**: Direct Chrome DevTools Protocol communication +- **Context-rot proof**: No dependency on evolving MCP specifications +- **Interactive picker**: Visual element selection for selector discovery +- **Lightweight**: Faster than full Playwright for simple tasks +- **Pre-compiled binary**: Ready to use, no compilation needed (auto-compiled via create-rule.js) + ## Decision Tree: Choosing Your Approach ``` @@ -38,14 +130,14 @@ To start a server, run `--help` first, then use the helper: **Single server:** ```bash -python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_automation.py +python scripts/with_server.py --server "npm run dev" --port 3000 -- python your_automation.py ``` **Multiple servers (e.g., backend + frontend):** ```bash python scripts/with_server.py \ - --server "cd backend && python server.py" --port 3000 \ - --server "cd frontend && npm run dev" --port 5173 \ + --server "cd backend && python server.py" --port 8000 \ + --server "cd frontend && npm run dev" --port 3000 \ -- python your_automation.py ``` @@ -53,10 +145,12 @@ To create an automation script, include only Playwright logic (servers are manag ```python from playwright.sync_api import sync_playwright +APP_PORT = 3000 # Match the port from --port argument + with sync_playwright() as p: browser = p.chromium.launch(headless=True) # Always launch chromium in headless mode page = browser.new_page() - page.goto('http://localhost:5173') # Server already running and ready + page.goto(f'http://localhost:{APP_PORT}') # Server already running and ready page.wait_for_load_state('networkidle') # CRITICAL: Wait for JS to execute # ... your automation logic browser.close() @@ -88,9 +182,352 @@ with sync_playwright() as p: - Use descriptive selectors: `text=`, `role=`, CSS selectors, or IDs - Add appropriate waits: `page.wait_for_selector()` or `page.wait_for_timeout()` +## Utility Modules + +The skill now includes comprehensive utilities for common testing patterns: + +### UI Interactions (`utils/ui_interactions.py`) + +Handle common UI patterns automatically: + +```python +from utils.ui_interactions import ( + dismiss_cookie_banner, + dismiss_modal, + click_with_header_offset, + force_click_if_needed, + wait_for_no_overlay, + wait_for_stable_dom +) + +# Dismiss cookie consent +dismiss_cookie_banner(page) + +# Close welcome modal +dismiss_modal(page, modal_identifier="Welcome") + +# Click button behind fixed header +click_with_header_offset(page, 'button#submit', header_height=100) + +# Try click with force fallback +force_click_if_needed(page, 'button#action') + +# Wait for loading overlays to disappear +wait_for_no_overlay(page) + +# Wait for DOM to stabilize +wait_for_stable_dom(page) +``` + +### Smart Form Filling (`utils/form_helpers.py`) + +Intelligently handle form variations: + +```python +from utils.form_helpers import ( + SmartFormFiller, + handle_multi_step_form, + auto_fill_form +) + +# Works with both "Full Name" and "First/Last Name" fields +filler = SmartFormFiller() +filler.fill_name_field(page, "Jane Doe") +filler.fill_email_field(page, "jane@example.com") +filler.fill_password_fields(page, "SecurePass123!") +filler.fill_phone_field(page, "+447700900123") +filler.fill_date_field(page, "1990-01-15", field_hint="birth") + +# Auto-fill entire form +results = auto_fill_form(page, { + 'email': 'test@example.com', + 'password': 'Pass123!', + 'full_name': 'Test User', + 'phone': '+447700900123', + 'date_of_birth': '1990-01-15' +}) + +# Handle multi-step forms +steps = [ + {'fields': {'email': 'test@example.com', 'password': 'Pass123!'}, 'checkbox': True}, + {'fields': {'full_name': 'Test User', 'date_of_birth': '1990-01-15'}}, + {'complete': True} +] +handle_multi_step_form(page, steps) +``` + +### Supabase Testing (`utils/supabase.py`) + +Database operations for Supabase-based apps: + +```python +from utils.supabase import SupabaseTestClient, quick_cleanup + +# Initialize client +client = SupabaseTestClient( + url="https://project.supabase.co", + service_key="your-service-role-key", + db_password="your-db-password" +) + +# Create test user +user_id = client.create_user("test@example.com", "password123") + +# Create invite code +client.create_invite_code("TEST2024", code_type="general") + +# Bypass email verification +client.confirm_email(user_id) + +# Cleanup after test +client.cleanup_related_records(user_id) +client.delete_user(user_id) + +# Quick cleanup helper +quick_cleanup("test@example.com", "db_password", "https://project.supabase.co") +``` + +### Advanced Wait Strategies (`utils/wait_strategies.py`) + +Better alternatives to simple sleep(): + +```python +from utils.wait_strategies import ( + wait_for_api_call, + wait_for_element_stable, + smart_navigation_wait, + combined_wait +) + +# Wait for specific API response +response = wait_for_api_call(page, '**/api/profile**') + +# Wait for element to stop moving +wait_for_element_stable(page, '.dropdown-menu', stability_ms=1000) + +# Smart navigation with URL check +page.click('button#login') +smart_navigation_wait(page, expected_url_pattern='**/dashboard**') + +# Comprehensive wait (network + DOM + overlays) +combined_wait(page) +``` + +### Smart Selectors (`utils/smart_selectors.py`) ⭐ NEW + +Automatically try multiple selector strategies to find elements, reducing test brittleness: + +```python +from utils.smart_selectors import SelectorStrategies + +# Find and fill email field (tries 7 different selector strategies) +success = SelectorStrategies.smart_fill(page, 'email', 'test@example.com') +# Output: ✓ Found field via placeholder: input[placeholder*="email" i] +# ✓ Filled 'email' with value + +# Find and click button (tries 8 different strategies) +success = SelectorStrategies.smart_click(page, 'Sign In') +# Output: ✓ Found button via case-insensitive text: button:text-matches("Sign In", "i") +# ✓ Clicked 'Sign In' button + +# Manual control - find input field selector +selector = SelectorStrategies.find_input_field(page, 'password') +if selector: + page.fill(selector, 'my-password') + +# Manual control - find button selector +selector = SelectorStrategies.find_button(page, 'Submit') +if selector: + page.click(selector) + +# Try custom selectors with fallback +selectors = ['button#submit', 'button.submit-btn', 'input[type="submit"]'] +selector = SelectorStrategies.find_any_element(page, selectors) +``` + +**Selector Strategies Tried (in order):** + +For input fields: +1. Test IDs: `[data-testid*="email"]` +2. ARIA labels: `input[aria-label*="email"]` +3. Placeholder: `input[placeholder*="email"]` +4. Name attribute: `input[name*="email"]` +5. Type attribute: `input[type="email"]` +6. ID exact: `#email` +7. ID partial: `input[id*="email"]` + +For buttons: +1. Test IDs: `[data-testid*="sign-in"]` +2. Name attribute: `button[name*="Sign In"]` +3. Exact text: `button:has-text("Sign In")` +4. Case-insensitive: `button:text-matches("Sign In", "i")` +5. Link as button: `a:has-text("Sign In")` +6. Submit input: `input[type="submit"][value*="Sign In"]` +7. Role button: `[role="button"]:has-text("Sign In")` + +**When to use:** +- ✅ Forms where HTML structure changes frequently +- ✅ Third-party components with unpredictable selectors +- ✅ Multi-application test suites +- ❌ Performance-critical tests (adds 5s timeout per strategy) + +**Performance:** +- Timeout per strategy: 5 seconds (configurable) +- Max timeout: 10 seconds across all strategies +- Typical: First strategy works (1-2 seconds) + +### Browser Configuration (`utils/browser_config.py`) ⭐ NEW + +Auto-configure browser context for testing environments: + +```python +from utils.browser_config import BrowserConfig + +# Auto-detect CSP bypass for localhost +context = BrowserConfig.create_test_context( + browser, + 'http://localhost:3000' +) +# Output: +# ============================================================ +# Browser Context Configuration +# ============================================================ +# Base URL: http://localhost:3000 +# 🔓 CSP bypass: ENABLED (testing on localhost) +# ⚠️ HTTPS errors: IGNORED (self-signed certs OK) +# 📐 Viewport: 1280x720 +# ============================================================ + +# Production testing (no CSP bypass) +context = BrowserConfig.create_test_context( + browser, + 'https://production.example.com' +) +# Output: 🔒 CSP bypass: DISABLED (production mode) + +# Mobile device emulation +context = BrowserConfig.create_mobile_context( + browser, + device='iPhone 12', + base_url='http://localhost:3000' +) +# Output: 📱 Mobile context: iPhone 12 +# Viewport: {'width': 390, 'height': 844} +# 🔓 CSP bypass: ENABLED + +# Manual override +context = BrowserConfig.create_test_context( + browser, + 'http://localhost:3000', + bypass_csp=False, # Force disable even for localhost + record_video=True, # Record test session + extra_http_headers={'Authorization': 'Bearer token'} +) +``` + +**Features:** +- **Auto CSP detection**: Enables bypass for localhost, disables for production +- **Self-signed certs**: Ignores HTTPS errors by default +- **Consistent viewport**: 1280x720 default for reproducible tests +- **Mobile emulation**: Built-in device profiles +- **Video recording**: Optional session recording +- **Verbose logging**: See exactly what's configured + +**When to use:** +- ✅ Every test (replaces manual `browser.new_context()`) +- ✅ Testing on localhost with CSP restrictions +- ✅ Mobile-responsive testing +- ✅ Need consistent browser configuration across tests + +### CSP Monitoring (`utils/ui_interactions.py`) ⭐ NEW + +Auto-detect and suggest fixes for Content Security Policy violations: + +```python +from utils.ui_interactions import setup_page_with_csp_handling +from utils.browser_config import BrowserConfig + +context = BrowserConfig.create_test_context(browser, 'http://localhost:7160') +page = context.new_page() + +# Enable CSP violation monitoring +setup_page_with_csp_handling(page) + +page.goto('http://localhost:7160') + +# If CSP violation occurs, you'll see: +# ====================================================================== +# ⚠️ CSP VIOLATION DETECTED +# ====================================================================== +# Message: Refused to execute inline script because it violates... +# +# 💡 SUGGESTION: +# For localhost testing, use: +# +# from utils.browser_config import BrowserConfig +# context = BrowserConfig.create_test_context( +# browser, 'http://localhost:3000' +# ) +# # Auto-enables CSP bypass for localhost +# +# Or manually: +# context = browser.new_context(bypass_csp=True) +# ====================================================================== +``` + +**When to use:** +- ✅ Debugging why tests fail on localhost +- ✅ Identifying CSP configuration issues +- ✅ Verifying CSP bypass is working +- ❌ Production testing (CSP should be enforced) + +## Complete Examples + +### Multi-Step Registration + +See `examples/multi_step_registration.py` for a complete example showing: +- Database setup (invite codes) +- Cookie banner dismissal +- Multi-step form automation +- Email verification bypass +- Login flow +- Dashboard verification +- Cleanup + +Run it: +```bash +python examples/multi_step_registration.py +``` + +## Using the Webapp-Testing Subagent + +A specialized subagent is available for testing automation. Use it to keep your main conversation focused on development: + +``` +You: "Use webapp-testing agent to register test@example.com and verify the parent role switch works" + +Main Agent: [Launches webapp-testing subagent] + +Webapp-Testing Agent: [Runs complete automation, returns results] +``` + +**Benefits:** +- Keeps main context clean +- Specialized for Playwright automation +- Access to all skill utilities +- Automatic screenshot capture +- Clear result reporting + ## Reference Files - **examples/** - Examples showing common patterns: - `element_discovery.py` - Discovering buttons, links, and inputs on a page - `static_html_automation.py` - Using file:// URLs for local HTML - - `console_logging.py` - Capturing console logs during automation \ No newline at end of file + - `console_logging.py` - Capturing console logs during automation + - `multi_step_registration.py` - Complete registration flow example (NEW) + +- **utils/** - Reusable utility modules (NEW): + - `ui_interactions.py` - Cookie banners, modals, overlays, stable waits + - `form_helpers.py` - Smart form filling, multi-step automation + - `supabase.py` - Database operations for Supabase apps + - `wait_strategies.py` - Advanced waiting patterns \ No newline at end of file diff --git a/claude-code-4.5/skills/webapp-testing/bin/.gitignore b/claude-code-4.5/skills/webapp-testing/bin/.gitignore new file mode 100644 index 0000000..7ed25dd --- /dev/null +++ b/claude-code-4.5/skills/webapp-testing/bin/.gitignore @@ -0,0 +1,12 @@ +# Dependencies +node_modules/ + +# Build artifacts +*.bun-build +.*.bun-build +bun.lockb +bun.lock + +# Logs +logs/ +*.log diff --git a/claude-code-4.5/skills/webapp-testing/bin/browser-tools b/claude-code-4.5/skills/webapp-testing/bin/browser-tools new file mode 100755 index 0000000..9366a65 Binary files /dev/null and b/claude-code-4.5/skills/webapp-testing/bin/browser-tools differ diff --git a/claude-code-4.5/skills/webapp-testing/bin/browser-tools.ts b/claude-code-4.5/skills/webapp-testing/bin/browser-tools.ts new file mode 100644 index 0000000..0b2855a --- /dev/null +++ b/claude-code-4.5/skills/webapp-testing/bin/browser-tools.ts @@ -0,0 +1,984 @@ +#!/usr/bin/env ts-node + +/** + * Minimal Chrome DevTools helpers inspired by Mario Zechner's + * "What if you don't need MCP?" article. + * + * Keeps everything in one TypeScript CLI so agents (or humans) can drive Chrome + * directly via the DevTools protocol without pulling in a large MCP server. + */ +import { Command } from 'commander'; +import { execSync, spawn } from 'node:child_process'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; +import { inspect } from 'node:util'; +import puppeteer from 'puppeteer-core'; + +/** Utility type so TypeScript knows the async function constructor */ +type AsyncFunctionCtor = new (...args: string[]) => (...fnArgs: unknown[]) => Promise; + +const DEFAULT_PORT = 9222; +const DEFAULT_PROFILE_DIR = path.join(os.homedir(), '.cache', 'scraping'); +const DEFAULT_CHROME_BIN = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + +function browserURL(port: number): string { + return `http://localhost:${port}`; +} + +async function connectBrowser(port: number) { + return puppeteer.connect({ browserURL: browserURL(port), defaultViewport: null }); +} + +async function getActivePage(port: number) { + const browser = await connectBrowser(port); + const pages = await browser.pages(); + const page = pages.at(-1); + if (!page) { + await browser.disconnect(); + throw new Error('No active tab found'); + } + return { browser, page }; +} + +const program = new Command(); +program + .name('browser-tools') + .description('Lightweight Chrome DevTools helpers (no MCP required).') + .configureHelp({ sortSubcommands: true }) + .showSuggestionAfterError(); + +program + .command('start') + .description('Launch Chrome with remote debugging enabled.') + .option('-p, --port ', 'Remote debugging port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .option('--profile', 'Copy your default Chrome profile before launch.', false) + .option('--profile-dir ', 'Directory for the temporary Chrome profile.', DEFAULT_PROFILE_DIR) + .option('--chrome-path ', 'Path to the Chrome binary.', DEFAULT_CHROME_BIN) + .option('--kill-existing', 'Stop any running Google Chrome before launch (default: false).', false) + .action(async (options) => { + const { port, profile, profileDir, chromePath, killExisting } = options as { + port: number; + profile: boolean; + profileDir: string; + chromePath: string; + killExisting: boolean; + }; + + if (killExisting) { + try { + execSync("killall 'Google Chrome'", { stdio: 'ignore' }); + } catch { + // ignore missing processes + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + execSync(`mkdir -p "${profileDir}"`); + if (profile) { + const source = `${path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')}/`; + execSync(`rsync -a --delete "${source}" "${profileDir}/"`, { stdio: 'ignore' }); + } + + spawn(chromePath, [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-first-run', '--disable-popup-blocking'], { + detached: true, + stdio: 'ignore', + }).unref(); + + let connected = false; + for (let attempt = 0; attempt < 30; attempt++) { + try { + const browser = await connectBrowser(port); + await browser.disconnect(); + connected = true; + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + + if (!connected) { + console.error(`✗ Failed to start Chrome on port ${port}`); + process.exit(1); + } + console.log(`✓ Chrome listening on http://localhost:${port}${profile ? ' (profile copied)' : ''}`); + }); + +program + .command('nav ') + .description('Navigate the current tab or open a new tab.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .option('--new', 'Open in a new tab.', false) + .action(async (url: string, options) => { + const port = options.port as number; + const browser = await connectBrowser(port); + try { + if (options.new) { + const page = await browser.newPage(); + await page.goto(url, { waitUntil: 'domcontentloaded' }); + console.log('✓ Opened in new tab:', url); + } else { + const pages = await browser.pages(); + const page = pages.at(-1); + if (!page) { + throw new Error('No active tab found'); + } + await page.goto(url, { waitUntil: 'domcontentloaded' }); + console.log('✓ Navigated current tab to:', url); + } + } finally { + await browser.disconnect(); + } + }); + +program + .command('eval ') + .description('Evaluate JavaScript in the active page context.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .option('--pretty-print', 'Format array/object results with indentation.', false) + .action(async (code: string[], options) => { + const snippet = code.join(' '); + const port = options.port as number; + const pretty = Boolean(options.prettyPrint); + const useColor = process.stdout.isTTY; + + const printPretty = (value: unknown) => { + console.log( + inspect(value, { + depth: 6, + colors: useColor, + maxArrayLength: 50, + breakLength: 80, + compact: false, + }), + ); + }; + + const { browser, page } = await getActivePage(port); + try { + const result = await page.evaluate((body) => { + const ASYNC_FN = Object.getPrototypeOf(async () => {}).constructor as AsyncFunctionCtor; + return new ASYNC_FN(`return (${body})`)(); + }, snippet); + + if (pretty) { + printPretty(result); + } else if (Array.isArray(result)) { + result.forEach((entry, index) => { + if (index > 0) { + console.log(''); + } + Object.entries(entry).forEach(([key, value]) => { + console.log(`${key}: ${value}`); + }); + }); + } else if (typeof result === 'object' && result !== null) { + Object.entries(result).forEach(([key, value]) => { + console.log(`${key}: ${value}`); + }); + } else { + console.log(result); + } + } finally { + await browser.disconnect(); + } + }); + +program + .command('screenshot') + .description('Capture the current viewport and print the temp PNG path.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .action(async (options) => { + const port = options.port as number; + const { browser, page } = await getActivePage(port); + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filePath = path.join(os.tmpdir(), `screenshot-${timestamp}.png`); + await page.screenshot({ path: filePath }); + console.log(filePath); + } finally { + await browser.disconnect(); + } + }); + +program + .command('pick ') + .description('Interactive DOM picker that prints metadata for clicked elements.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .action(async (messageParts: string[], options) => { + const message = messageParts.join(' '); + const port = options.port as number; + const { browser, page } = await getActivePage(port); + try { + await page.evaluate(() => { + const scope = globalThis as typeof globalThis & { + pickOverlayInjected?: boolean; + pick?: (prompt: string) => Promise; + }; + if (scope.pickOverlayInjected) { + return; + } + scope.pickOverlayInjected = true; + scope.pick = async (prompt: string) => + new Promise((resolve) => { + const selections: unknown[] = []; + const selectedElements = new Set(); + + const overlay = document.createElement('div'); + overlay.style.cssText = + 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647;pointer-events:none'; + + const highlight = document.createElement('div'); + highlight.style.cssText = + 'position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);transition:all 0.05s ease'; + overlay.appendChild(highlight); + + const banner = document.createElement('div'); + banner.style.cssText = + 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1f2937;color:#fff;padding:12px 24px;border-radius:8px;font:14px system-ui;box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;z-index:2147483647'; + + const updateBanner = () => { + banner.textContent = `${prompt} (${selections.length} selected, Cmd/Ctrl+click to add, Enter to finish, ESC to cancel)`; + }; + + const cleanup = () => { + document.removeEventListener('mousemove', onMove, true); + document.removeEventListener('click', onClick, true); + document.removeEventListener('keydown', onKey, true); + overlay.remove(); + banner.remove(); + selectedElements.forEach((el) => { + el.style.outline = ''; + }); + }; + + const serialize = (el: HTMLElement) => { + const parents: string[] = []; + let current = el.parentElement; + while (current && current !== document.body) { + const id = current.id ? `#${current.id}` : ''; + const cls = current.className ? `.${current.className.trim().split(/\s+/).join('.')}` : ''; + parents.push(`${current.tagName.toLowerCase()}${id}${cls}`); + current = current.parentElement; + } + return { + tag: el.tagName.toLowerCase(), + id: el.id || null, + class: el.className || null, + text: el.textContent?.trim()?.slice(0, 200) || null, + html: el.outerHTML.slice(0, 500), + parents: parents.join(' > '), + }; + }; + + const onMove = (event: MouseEvent) => { + const node = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null; + if (!node || overlay.contains(node) || banner.contains(node)) return; + const rect = node.getBoundingClientRect(); + highlight.style.cssText = `position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);top:${rect.top}px;left:${rect.left}px;width:${rect.width}px;height:${rect.height}px`; + }; + const onClick = (event: MouseEvent) => { + if (banner.contains(event.target as Node)) return; + event.preventDefault(); + event.stopPropagation(); + const node = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null; + if (!node || overlay.contains(node) || banner.contains(node)) return; + + if (event.metaKey || event.ctrlKey) { + if (!selectedElements.has(node)) { + selectedElements.add(node); + node.style.outline = '3px solid #10b981'; + selections.push(serialize(node)); + updateBanner(); + } + } else { + cleanup(); + const info = serialize(node); + resolve(selections.length > 0 ? selections : info); + } + }; + + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + cleanup(); + resolve(null); + } else if (event.key === 'Enter' && selections.length > 0) { + cleanup(); + resolve(selections); + } + }; + + document.addEventListener('mousemove', onMove, true); + document.addEventListener('click', onClick, true); + document.addEventListener('keydown', onKey, true); + + document.body.append(overlay, banner); + updateBanner(); + }); + }); + + const result = await page.evaluate((msg) => { + const pickFn = (window as Window & { pick?: (message: string) => Promise }).pick; + if (!pickFn) { + return null; + } + return pickFn(msg); + }, message); + + if (Array.isArray(result)) { + result.forEach((entry, index) => { + if (index > 0) { + console.log(''); + } + Object.entries(entry).forEach(([key, value]) => { + console.log(`${key}: ${value}`); + }); + }); + } else if (result && typeof result === 'object') { + Object.entries(result).forEach(([key, value]) => { + console.log(`${key}: ${value}`); + }); + } else { + console.log(result); + } + } finally { + await browser.disconnect(); + } + }); + +program + .command('console') + .description('Capture and display console logs from the active tab.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .option('--types ', 'Comma-separated log types to show (e.g., log,error,warn). Default: all types') + .option('--follow', 'Continuous monitoring mode (like tail -f)', false) + .option('--timeout ', 'Capture duration in seconds (default: 5 for one-shot, infinite for --follow)', (value) => Number.parseInt(value, 10)) + .option('--color', 'Force color output') + .option('--no-color', 'Disable color output') + .option('--no-serialize', 'Disable object serialization (show raw text only)', false) + .action(async (options) => { + const port = options.port as number; + const follow = options.follow as boolean; + const timeout = options.timeout as number | undefined; + const typesFilter = options.types as string | undefined; + const noSerialize = options.noSerialize as boolean; + const serialize = !noSerialize; + + // Track explicit color flags by looking at argv to avoid Commander defaults overriding TTY detection. + const argv = process.argv.slice(2); + const colorFlag = argv.includes('--color') ? true : argv.includes('--no-color') ? false : undefined; + + // Determine if we should use colors: explicit flag or TTY auto-detection + const useColor = colorFlag ?? process.stdout.isTTY; + + // Parse types filter + const normalizeType = (value: string) => { + const lower = value.toLowerCase(); + if (lower === 'warning') return 'warn'; + return lower; + }; + + const allowedTypes = typesFilter + ? new Set(typesFilter.split(',').map((t) => normalizeType(t.trim()))) + : null; // null means show all types + + // Color functions (no-op if colors disabled) + const colorize = (text: string, colorCode: string) => (useColor ? `\x1b[${colorCode}m${text}\x1b[0m` : text); + const red = (text: string) => colorize(text, '31'); + const yellow = (text: string) => colorize(text, '33'); + const cyan = (text: string) => colorize(text, '36'); + const gray = (text: string) => colorize(text, '90'); + const white = (text: string) => text; + + const typeColors: Record string> = { + error: red, + warn: yellow, + warning: yellow, + info: cyan, + debug: gray, + log: white, + pageerror: red, + }; + + // Helper function definitions (outside try/catch as they don't need error handling) + const formatTimestamp = () => { + const now = new Date(); + return now.toTimeString().split(' ')[0] + '.' + now.getMilliseconds().toString().padStart(3, '0'); + }; + + // Serialize value in Node.js util.inspect style with depth limit + const formatValue = (value: any, depth = 0, maxDepth = 10): string => { + if (depth > maxDepth) { + return '[Object]'; + } + + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string') return `'${value}'`; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (typeof value === 'function') return '[Function]'; + + if (Array.isArray(value)) { + const items = value.map((v) => formatValue(v, depth + 1, maxDepth)); + return `[ ${items.join(', ')} ]`; + } + + if (typeof value === 'object') { + const entries = Object.entries(value).map(([k, v]) => `${k}: ${formatValue(v, depth + 1, maxDepth)}`); + return entries.length > 0 ? `{ ${entries.join(', ')} }` : '{}'; + } + + return String(value); + }; + + // Serialize console message arguments + const serializeArgs = async (msg: any): Promise => { + try { + const args = msg.args(); + const values = await Promise.all( + args.map(async (arg: any) => { + try { + const value = await arg.jsonValue(); + return formatValue(value); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (errorMsg.includes('circular')) return '[Circular]'; + if (errorMsg.includes('reference chain')) return '[DeepObject]'; + return '[Unserializable]'; + } + }) + ); + return values.join(' '); + } catch { + // Fallback to text representation + return msg.text(); + } + }; + + const formatMessage = (type: string, text: string, location?: { url?: string; lineNumber?: number }) => { + const color = typeColors[type] || white; + const timestamp = formatTimestamp(); + const loc = location?.url && location?.lineNumber ? ` ${location.url}:${location.lineNumber}` : ''; + return color(`[${type.toUpperCase()}] ${timestamp} ${text}${loc}`); + }; + + // Execution code (needs try/catch for error handling) + const { browser, page } = await getActivePage(port); + + try { + // Set up console listener + page.on('console', async (msg) => { + const type = normalizeType(msg.type()); + if (allowedTypes && !allowedTypes.has(type)) { + return; // Filter out unwanted types + } + + const text = serialize ? await serializeArgs(msg) : msg.text(); + console.log(formatMessage(type, text, msg.location())); + }); + + // Set up page error listener + page.on('pageerror', (error) => { + if (allowedTypes && !allowedTypes.has('pageerror') && !allowedTypes.has('error')) { + return; + } + console.log(formatMessage('pageerror', error.message)); + }); + + if (follow) { + // Continuous monitoring mode + console.log(gray('Monitoring console logs (Ctrl+C to stop)...')); + const waitForExit = () => + new Promise((resolve) => { + const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP']; + const onSignal = () => { + cleanup(); + resolve(); + }; + const onBeforeExit = () => { + cleanup(); + resolve(); + }; + const cleanup = () => { + signals.forEach((signal) => process.off(signal, onSignal)); + process.off('beforeExit', onBeforeExit); + }; + signals.forEach((signal) => process.on(signal, onSignal)); + process.on('beforeExit', onBeforeExit); + }); + + await waitForExit(); + } else { + // One-shot mode with timeout + const duration = timeout ?? 5; + console.log(gray(`Capturing console logs for ${duration} seconds...`)); + await new Promise((resolve) => setTimeout(resolve, duration * 1000)); + } + } finally { + await browser.disconnect(); + } + }); + +program + .command('search ') + .description('Google search with optional readable content extraction.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .option('-n, --count ', 'Number of results to return (default: 5, max: 50)', (value) => Number.parseInt(value, 10), 5) + .option('--content', 'Fetch readable content for each result (slower).', false) + .option('--timeout ', 'Per-navigation timeout in seconds (default: 10).', (value) => Number.parseInt(value, 10), 10) + .action(async (queryWords: string[], options) => { + const port = options.port as number; + const count = Math.max(1, Math.min(options.count as number, 50)); + const fetchContent = Boolean(options.content); + const timeoutMs = Math.max(3, (options.timeout as number) ?? 10) * 1000; + const query = queryWords.join(' '); + + const { browser, page } = await getActivePage(port); + try { + const results: { title: string; link: string; snippet: string; content?: string }[] = []; + let start = 0; + while (results.length < count) { + const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}&start=${start}`; + await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: timeoutMs }).catch(() => {}); + await page.waitForSelector('div.MjjYud', { timeout: 3000 }).catch(() => {}); + + const pageResults = await page.evaluate(() => { + const items: { title: string; link: string; snippet: string }[] = []; + document.querySelectorAll('div.MjjYud').forEach((result) => { + const titleEl = result.querySelector('h3'); + const linkEl = result.querySelector('a'); + const snippetEl = result.querySelector('div.VwiC3b, div[data-sncf]'); + const link = linkEl?.getAttribute('href') ?? ''; + if (titleEl && linkEl && link && !link.startsWith('https://www.google.com')) { + items.push({ + title: titleEl.textContent?.trim() ?? '', + link, + snippet: snippetEl?.textContent?.trim() ?? '', + }); + } + }); + return items; + }); + + for (const r of pageResults) { + if (results.length >= count) break; + if (!results.some((existing) => existing.link === r.link)) { + results.push(r); + } + } + + if (pageResults.length === 0 || start >= 90) { + break; + } + start += 10; + } + + if (fetchContent) { + for (const result of results) { + try { + await page.goto(result.link, { waitUntil: 'networkidle2', timeout: timeoutMs }).catch(() => {}); + const article = await extractReadableContent(page); + result.content = article.content ?? '(No readable content)'; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + result.content = `(Error fetching content: ${message})`; + } + } + } + + results.forEach((r, index) => { + console.log(`--- Result ${index + 1} ---`); + console.log(`Title: ${r.title}`); + console.log(`Link: ${r.link}`); + if (r.snippet) { + console.log(`Snippet: ${r.snippet}`); + } + if (r.content) { + console.log(`Content:\n${r.content}`); + } + console.log(''); + }); + + if (results.length === 0) { + console.log('No results found.'); + } + } finally { + await browser.disconnect(); + } + }); + +program + .command('content ') + .description('Extract readable content from a URL as markdown-like text.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .option('--timeout ', 'Navigation timeout in seconds (default: 10).', (value) => Number.parseInt(value, 10), 10) + .action(async (url: string, options) => { + const port = options.port as number; + const timeoutMs = Math.max(3, (options.timeout as number) ?? 10) * 1000; + const { browser, page } = await getActivePage(port); + try { + await page.goto(url, { waitUntil: 'networkidle2', timeout: timeoutMs }).catch(() => {}); + const article = await extractReadableContent(page); + console.log(`URL: ${article.url}`); + if (article.title) { + console.log(`Title: ${article.title}`); + } + console.log(''); + console.log(article.content ?? '(No readable content)'); + } finally { + await browser.disconnect(); + } + }); + +program + .command('cookies') + .description('Dump cookies from the active tab as JSON.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .action(async (options) => { + const port = options.port as number; + const { browser, page } = await getActivePage(port); + try { + const cookies = await page.cookies(); + console.log(JSON.stringify(cookies, null, 2)); + } finally { + await browser.disconnect(); + } + }); + +program + .command('inspect') + .description('List Chrome processes launched with --remote-debugging-port and show their open tabs.') + .option('--ports ', 'Comma-separated list of ports to include.', parseNumberListArg) + .option('--pids ', 'Comma-separated list of PIDs to include.', parseNumberListArg) + .option('--json', 'Emit machine-readable JSON output.', false) + .action(async (options) => { + const ports = (options.ports as number[] | undefined)?.filter((entry) => Number.isFinite(entry) && entry > 0); + const pids = (options.pids as number[] | undefined)?.filter((entry) => Number.isFinite(entry) && entry > 0); + const sessions = await describeChromeSessions({ + ports, + pids, + includeAll: !ports?.length && !pids?.length, + }); + if (options.json) { + console.log(JSON.stringify(sessions, null, 2)); + return; + } + if (sessions.length === 0) { + console.log('No Chrome instances with DevTools ports found.'); + return; + } + sessions.forEach((session, index) => { + if (index > 0) { + console.log(''); + } + const transport = session.port !== undefined ? `port ${session.port}` : session.usesPipe ? 'debugging pipe' : 'unknown transport'; + const header = [`Chrome PID ${session.pid}`, `(${transport})`]; + if (session.version?.Browser) { + header.push(`- ${session.version.Browser}`); + } + console.log(header.join(' ')); + if (session.tabs.length === 0) { + console.log(' (no tabs reported)'); + return; + } + session.tabs.forEach((tab, idx) => { + const title = tab.title || '(untitled)'; + const url = tab.url || '(no url)'; + console.log(` Tab ${idx + 1}: ${title}`); + console.log(` ${url}`); + }); + }); + }); + +program + .command('kill') + .description('Terminate Chrome instances that have DevTools ports open.') + .option('--ports ', 'Comma-separated list of ports to target.', parseNumberListArg) + .option('--pids ', 'Comma-separated list of PIDs to target.', parseNumberListArg) + .option('--all', 'Kill every matching Chrome instance.', false) + .option('--force', 'Skip the confirmation prompt.', false) + .action(async (options) => { + const ports = (options.ports as number[] | undefined)?.filter((entry) => Number.isFinite(entry) && entry > 0); + const pids = (options.pids as number[] | undefined)?.filter((entry) => Number.isFinite(entry) && entry > 0); + const killAll = Boolean(options.all); + if (!killAll && (!ports?.length && !pids?.length)) { + console.error('Specify --all, --ports , or --pids to select targets.'); + process.exit(1); + } + const sessions = await describeChromeSessions({ ports, pids, includeAll: killAll }); + if (sessions.length === 0) { + console.log('No matching Chrome instances found.'); + return; + } + if (!options.force) { + console.log('About to terminate the following Chrome sessions:'); + sessions.forEach((session) => { + const transport = session.port !== undefined ? `port ${session.port}` : session.usesPipe ? 'debugging pipe' : 'unknown transport'; + console.log(` PID ${session.pid} (${transport})`); + }); + const rl = readline.createInterface({ input, output }); + const answer = (await rl.question('Proceed? [y/N] ')).trim().toLowerCase(); + rl.close(); + if (answer !== 'y' && answer !== 'yes') { + console.log('Aborted.'); + return; + } + } + const failures: { pid: number; error: string }[] = []; + sessions.forEach((session) => { + try { + process.kill(session.pid); + const transport = session.port !== undefined ? `port ${session.port}` : session.usesPipe ? 'debugging pipe' : 'unknown transport'; + console.log(`✓ Killed Chrome PID ${session.pid} (${transport})`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`✗ Failed to kill PID ${session.pid}: ${message}`); + failures.push({ pid: session.pid, error: message }); + } + }); + if (failures.length > 0) { + process.exitCode = 1; + } + }); + +interface ChromeProcessInfo { + pid: number; + port?: number; + usesPipe: boolean; + command: string; +} + +interface ChromeTabInfo { + id?: string; + title?: string; + url?: string; + type?: string; +} + +interface ChromeSessionDescription extends ChromeProcessInfo { + version?: Record; + tabs: ChromeTabInfo[]; +} + +async function ensureReadability(page: any) { + try { + await page.setBypassCSP?.(true); + } catch { + // ignore + } + const scripts = [ + 'https://unpkg.com/@mozilla/readability@0.4.4/Readability.js', + 'https://unpkg.com/turndown@7.1.2/dist/turndown.js', + 'https://unpkg.com/turndown-plugin-gfm@1.0.2/dist/turndown-plugin-gfm.js', + ]; + for (const src of scripts) { + try { + const alreadyLoaded = await page.evaluate((url) => { + return Boolean(document.querySelector(`script[src="${url}"]`)); + }, src); + if (!alreadyLoaded) { + await page.addScriptTag({ url: src }); + } + } catch { + // best-effort; continue + } + } +} + +async function extractReadableContent(page: any): Promise<{ title?: string; content?: string; url: string }> { + await ensureReadability(page); + const result = await page.evaluate(() => { + const asMarkdown = (html: string | null | undefined) => { + if (!html) return ''; + const TurndownService = (window as any).TurndownService; + const turndownPluginGfm = (window as any).turndownPluginGfm; + if (!TurndownService) { + return ''; + } + const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); + if (turndownPluginGfm?.gfm) { + turndown.use(turndownPluginGfm.gfm); + } + return turndown + .turndown(html) + .replace(/\n{3,}/g, '\n\n') + .trim(); + }; + + const fallbackText = () => { + const main = + document.querySelector('main, article, [role="main"], .content, #content') || document.body || document.documentElement; + return main?.textContent?.trim() ?? ''; + }; + + let title = document.title; + let content = ''; + + try { + const Readability = (window as any).Readability; + if (Readability) { + const clone = document.cloneNode(true) as Document; + const article = new Readability(clone).parse(); + title = article?.title || title; + content = asMarkdown(article?.content) || article?.textContent || ''; + } + } catch { + // ignore readability failures + } + + if (!content) { + content = fallbackText(); + } + + content = content?.trim().slice(0, 8000); + + return { title, content, url: location.href }; + }); + return result; +} + +function parseNumberListArg(value: string): number[] { + return parseNumberList(value) ?? []; +} + +function parseNumberList(inputValue: string | undefined): number[] | undefined { + if (!inputValue) { + return undefined; + } + const parsed = inputValue + .split(',') + .map((entry) => Number.parseInt(entry.trim(), 10)) + .filter((value) => Number.isFinite(value)); + return parsed.length > 0 ? parsed : undefined; +} + +async function describeChromeSessions(options: { + ports?: number[]; + pids?: number[]; + includeAll?: boolean; +}): Promise { + const { ports, pids, includeAll } = options; + const processes = await listDevtoolsChromes(); + const portSet = new Set(ports ?? []); + const pidSet = new Set(pids ?? []); + const candidates = processes.filter((proc) => { + if (includeAll) { + return true; + } + if (portSet.size > 0 && proc.port !== undefined && portSet.has(proc.port)) { + return true; + } + if (pidSet.size > 0 && pidSet.has(proc.pid)) { + return true; + } + return false; + }); + const results: ChromeSessionDescription[] = []; + for (const proc of candidates) { + let version: Record | undefined; + let filteredTabs: ChromeTabInfo[] = []; + if (proc.port !== undefined) { + const [versionResp, tabs] = await Promise.all([ + fetchJson(`http://localhost:${proc.port}/json/version`).catch(() => undefined), + fetchJson(`http://localhost:${proc.port}/json/list`).catch(() => []), + ]); + version = versionResp as Record | undefined; + filteredTabs = Array.isArray(tabs) + ? (tabs as ChromeTabInfo[]).filter((tab) => { + const type = tab.type?.toLowerCase() ?? ''; + if (type && type !== 'page' && type !== 'app') { + if (!tab.url || tab.url.startsWith('devtools://') || tab.url.startsWith('chrome-extension://')) { + return false; + } + } + if (!tab.url || tab.url.trim().length === 0) { + return false; + } + return true; + }) + : []; + } + results.push({ + ...proc, + version, + tabs: filteredTabs, + }); + } + return results; +} + +async function listDevtoolsChromes(): Promise { + if (process.platform !== 'darwin' && process.platform !== 'linux') { + console.warn('Chrome inspection is only supported on macOS and Linux for now.'); + return []; + } + let output = ''; + try { + output = execSync('ps -ax -o pid=,command=', { encoding: 'utf8' }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to enumerate processes: ${message}`); + } + const processes: ChromeProcessInfo[] = []; + output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .forEach((line) => { + const match = line.match(/^(\d+)\s+(.+)$/); + if (!match) { + return; + } + const pid = Number.parseInt(match[1], 10); + const command = match[2]; + if (!Number.isFinite(pid) || pid <= 0) { + return; + } + if (!/chrome/i.test(command) || (!/--remote-debugging-port/.test(command) && !/--remote-debugging-pipe/.test(command))) { + return; + } + const portMatch = command.match(/--remote-debugging-port(?:=|\s+)(\d+)/); + if (portMatch) { + const port = Number.parseInt(portMatch[1], 10); + if (!Number.isFinite(port)) { + return; + } + processes.push({ pid, port, usesPipe: false, command }); + return; + } + if (/--remote-debugging-pipe/.test(command)) { + processes.push({ pid, usesPipe: true, command }); + } + }); + return processes; +} + +function fetchJson(url: string, timeoutMs = 2000): Promise { + return new Promise((resolve, reject) => { + const request = http.get(url, { timeout: timeoutMs }, (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + if ((response.statusCode ?? 500) >= 400) { + reject(new Error(`HTTP ${response.statusCode} for ${url}`)); + return; + } + try { + resolve(JSON.parse(body)); + } catch { + resolve(undefined); + } + }); + }); + request.on('timeout', () => { + request.destroy(new Error(`Request to ${url} timed out`)); + }); + request.on('error', (error) => { + reject(error); + }); + }); +} + +program.parseAsync(process.argv); diff --git a/claude-code-4.5/skills/webapp-testing/bin/package.json b/claude-code-4.5/skills/webapp-testing/bin/package.json new file mode 100644 index 0000000..f59d07d --- /dev/null +++ b/claude-code-4.5/skills/webapp-testing/bin/package.json @@ -0,0 +1,7 @@ +{ + "type": "module", + "dependencies": { + "commander": "^12.1.0", + "puppeteer-core": "^23.6.0" + } +} diff --git a/claude-code-4.5/skills/webapp-testing/examples/element_discovery.py b/claude-code-4.5/skills/webapp-testing/examples/element_discovery.py index 917ba72..8ddc5af 100755 --- a/claude-code-4.5/skills/webapp-testing/examples/element_discovery.py +++ b/claude-code-4.5/skills/webapp-testing/examples/element_discovery.py @@ -7,7 +7,7 @@ page = browser.new_page() # Navigate to page and wait for it to fully load - page.goto('http://localhost:5173') + page.goto('http://localhost:3000') # Replace with your app URL page.wait_for_load_state('networkidle') # Discover all buttons on the page diff --git a/claude-code-4.5/skills/webapp-testing/examples/multi_step_registration.py b/claude-code-4.5/skills/webapp-testing/examples/multi_step_registration.py new file mode 100644 index 0000000..e960ff6 --- /dev/null +++ b/claude-code-4.5/skills/webapp-testing/examples/multi_step_registration.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +Multi-Step Registration Example + +Demonstrates complete registration flow using all webapp-testing utilities: +- UI interactions (cookie banners, modals) +- Smart form filling (handles field variations) +- Database operations (invite codes, email verification) +- Advanced wait strategies + +This example is based on a real-world React/Supabase app with 3-step registration. +""" + +import sys +import os +from playwright.sync_api import sync_playwright +import time + +# Add utils to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from utils.ui_interactions import dismiss_cookie_banner, dismiss_modal +from utils.form_helpers import SmartFormFiller, handle_multi_step_form +from utils.supabase import SupabaseTestClient +from utils.wait_strategies import combined_wait, smart_navigation_wait + + +def register_user_complete_flow(): + """ + Complete multi-step registration with database setup and verification. + + Flow: + 1. Create invite code in database + 2. Navigate to registration page + 3. Fill multi-step form (Code → Credentials → Personal Info → Avatar) + 4. Verify email via database + 5. Login + 6. Verify dashboard access + 7. Cleanup (optional) + """ + + # Configuration - adjust for your app + APP_URL = "http://localhost:3000" + REGISTER_URL = f"{APP_URL}/register" + + # Database config (adjust for your project) + DB_PASSWORD = "your-db-password" + SUPABASE_URL = "https://project.supabase.co" + SERVICE_KEY = "your-service-role-key" + + # Test user data + TEST_EMAIL = "test.user@example.com" + TEST_PASSWORD = "TestPass123!" + FULL_NAME = "Test User" + PHONE = "+447700900123" + DATE_OF_BIRTH = "1990-01-15" + INVITE_CODE = "TEST2024" + + print("\n" + "="*60) + print("MULTI-STEP REGISTRATION AUTOMATION") + print("="*60) + + # Step 1: Setup database + print("\n[1/8] Setting up database...") + db_client = SupabaseTestClient( + url=SUPABASE_URL, + service_key=SERVICE_KEY, + db_password=DB_PASSWORD + ) + + # Create invite code + if db_client.create_invite_code(INVITE_CODE, code_type="general"): + print(f" ✓ Created invite code: {INVITE_CODE}") + else: + print(f" ⚠️ Invite code may already exist") + + # Clean up any existing test user + existing_user = db_client.find_user_by_email(TEST_EMAIL) + if existing_user: + print(f" Cleaning up existing user...") + db_client.cleanup_related_records(existing_user) + db_client.delete_user(existing_user) + + # Step 2: Start browser automation + print("\n[2/8] Starting browser automation...") + + with sync_playwright() as p: + browser = p.chromium.launch(headless=False) + page = browser.new_page(viewport={'width': 1400, 'height': 1000}) + + try: + # Step 3: Navigate to registration + print("\n[3/8] Navigating to registration page...") + page.goto(REGISTER_URL, wait_until='networkidle') + time.sleep(2) + + # Handle cookie banner + if dismiss_cookie_banner(page): + print(" ✓ Dismissed cookie banner") + + page.screenshot(path='/tmp/reg_step1_start.png', full_page=True) + print(" ✓ Screenshot: /tmp/reg_step1_start.png") + + # Step 4: Fill multi-step form + print("\n[4/8] Filling multi-step registration form...") + + # Define form steps + steps = [ + { + 'name': 'Invite Code', + 'fields': {'invite_code': INVITE_CODE}, + 'custom_fill': lambda: page.locator('input').first.fill(INVITE_CODE), + 'custom_submit': lambda: page.locator('input').first.press('Enter'), + }, + { + 'name': 'Credentials', + 'fields': { + 'email': TEST_EMAIL, + 'password': TEST_PASSWORD, + }, + 'checkbox': True, # Terms of service + }, + { + 'name': 'Personal Info', + 'fields': { + 'full_name': FULL_NAME, + 'date_of_birth': DATE_OF_BIRTH, + 'phone': PHONE, + }, + }, + { + 'name': 'Avatar Selection', + 'complete': True, # Final step with COMPLETE button + } + ] + + # Process each step + filler = SmartFormFiller() + + for i, step in enumerate(steps): + print(f"\n Step {i+1}/4: {step['name']}") + + # Custom filling logic for first step (invite code) + if 'custom_fill' in step: + step['custom_fill']() + time.sleep(1) + + if 'custom_submit' in step: + step['custom_submit']() + else: + page.locator('button:has-text("CONTINUE")').first.click() + + time.sleep(4) + page.wait_for_load_state('networkidle') + time.sleep(2) + + # Standard form filling for other steps + elif 'fields' in step: + if 'email' in step['fields']: + filler.fill_email_field(page, step['fields']['email']) + print(" ✓ Email") + + if 'password' in step['fields']: + filler.fill_password_fields(page, step['fields']['password']) + print(" ✓ Password") + + if 'full_name' in step['fields']: + filler.fill_name_field(page, step['fields']['full_name']) + print(" ✓ Full Name") + + if 'date_of_birth' in step['fields']: + filler.fill_date_field(page, step['fields']['date_of_birth'], field_hint='birth') + print(" ✓ Date of Birth") + + if 'phone' in step['fields']: + filler.fill_phone_field(page, step['fields']['phone']) + print(" ✓ Phone") + + # Check terms checkbox if needed + if step.get('checkbox'): + page.locator('input[type="checkbox"]').first.check() + print(" ✓ Terms accepted") + + time.sleep(1) + + # Click continue + page.locator('button:has-text("CONTINUE")').first.click() + time.sleep(4) + page.wait_for_load_state('networkidle') + time.sleep(2) + + # Final step - click COMPLETE + elif step.get('complete'): + complete_btn = page.locator('button:has-text("COMPLETE")').first + complete_btn.click() + print(" ✓ Clicked COMPLETE") + + time.sleep(8) + page.wait_for_load_state('networkidle') + time.sleep(3) + + # Screenshot after each step + page.screenshot(path=f'/tmp/reg_step{i+1}_complete.png', full_page=True) + print(f" ✓ Screenshot: /tmp/reg_step{i+1}_complete.png") + + print("\n ✓ Multi-step form completed!") + + # Step 5: Handle post-registration + print("\n[5/8] Handling post-registration...") + + # Dismiss welcome modal if present + if dismiss_modal(page, modal_identifier="Welcome"): + print(" ✓ Dismissed welcome modal") + + current_url = page.url + print(f" Current URL: {current_url}") + + # Step 6: Verify email via database + print("\n[6/8] Verifying email via database...") + time.sleep(2) # Brief wait for user to be created in DB + + user_id = db_client.find_user_by_email(TEST_EMAIL) + if user_id: + print(f" ✓ Found user: {user_id}") + + if db_client.confirm_email(user_id): + print(" ✓ Email verified in database") + else: + print(" ⚠️ Could not verify email") + else: + print(" ⚠️ User not found in database") + + # Step 7: Login (if not already logged in) + print("\n[7/8] Logging in...") + + if 'login' in current_url.lower(): + print(" Needs login...") + + filler.fill_email_field(page, TEST_EMAIL) + filler.fill_password_fields(page, TEST_PASSWORD, confirm=False) + time.sleep(1) + + page.locator('button[type="submit"]').first.click() + time.sleep(6) + page.wait_for_load_state('networkidle') + time.sleep(3) + + print(" ✓ Logged in") + else: + print(" ✓ Already logged in") + + # Step 8: Verify dashboard access + print("\n[8/8] Verifying dashboard access...") + + # Navigate to dashboard/perform if not already there + if 'perform' not in page.url.lower() and 'dashboard' not in page.url.lower(): + page.goto(f"{APP_URL}/perform", wait_until='networkidle') + time.sleep(3) + + page.screenshot(path='/tmp/reg_final_dashboard.png', full_page=True) + print(" ✓ Screenshot: /tmp/reg_final_dashboard.png") + + # Check if we're on the dashboard + if 'perform' in page.url.lower() or 'dashboard' in page.url.lower(): + print(" ✓ Successfully reached dashboard!") + else: + print(f" ⚠️ Unexpected URL: {page.url}") + + print("\n" + "="*60) + print("REGISTRATION COMPLETE!") + print("="*60) + print(f"\nUser: {TEST_EMAIL}") + print(f"Password: {TEST_PASSWORD}") + print(f"User ID: {user_id}") + print(f"\nScreenshots saved to /tmp/reg_step*.png") + print("="*60) + + # Keep browser open for inspection + print("\nKeeping browser open for 30 seconds...") + time.sleep(30) + + except Exception as e: + print(f"\n❌ Error: {e}") + import traceback + traceback.print_exc() + page.screenshot(path='/tmp/reg_error.png', full_page=True) + print(" Error screenshot: /tmp/reg_error.png") + + finally: + browser.close() + + # Optional cleanup + print("\n" + "="*60) + print("Cleanup") + print("="*60) + + cleanup = input("\nDelete test user? (y/N): ").strip().lower() + if cleanup == 'y' and user_id: + print("Cleaning up...") + db_client.cleanup_related_records(user_id) + db_client.delete_user(user_id) + print("✓ Test user deleted") + else: + print("Test user kept for manual testing") + + +if __name__ == '__main__': + print("\nMulti-Step Registration Automation Example") + print("=" * 60) + print("\nBefore running:") + print("1. Update configuration variables at the top of the script") + print("2. Ensure your app is running (e.g., npm run dev)") + print("3. Have database credentials ready") + print("\n" + "=" * 60) + + proceed = input("\nProceed with registration? (y/N): ").strip().lower() + + if proceed == 'y': + register_user_complete_flow() + else: + print("\nCancelled.") diff --git a/claude-code-4.5/skills/webapp-testing/utils/__pycache__/form_helpers.cpython-312.pyc b/claude-code-4.5/skills/webapp-testing/utils/__pycache__/form_helpers.cpython-312.pyc new file mode 100644 index 0000000..93f6999 Binary files /dev/null and b/claude-code-4.5/skills/webapp-testing/utils/__pycache__/form_helpers.cpython-312.pyc differ diff --git a/claude-code-4.5/skills/webapp-testing/utils/__pycache__/supabase.cpython-312.pyc b/claude-code-4.5/skills/webapp-testing/utils/__pycache__/supabase.cpython-312.pyc new file mode 100644 index 0000000..989c929 Binary files /dev/null and b/claude-code-4.5/skills/webapp-testing/utils/__pycache__/supabase.cpython-312.pyc differ diff --git a/claude-code-4.5/skills/webapp-testing/utils/__pycache__/ui_interactions.cpython-312.pyc b/claude-code-4.5/skills/webapp-testing/utils/__pycache__/ui_interactions.cpython-312.pyc new file mode 100644 index 0000000..0b547b4 Binary files /dev/null and b/claude-code-4.5/skills/webapp-testing/utils/__pycache__/ui_interactions.cpython-312.pyc differ diff --git a/claude-code-4.5/skills/webapp-testing/utils/__pycache__/wait_strategies.cpython-312.pyc b/claude-code-4.5/skills/webapp-testing/utils/__pycache__/wait_strategies.cpython-312.pyc new file mode 100644 index 0000000..2de673e Binary files /dev/null and b/claude-code-4.5/skills/webapp-testing/utils/__pycache__/wait_strategies.cpython-312.pyc differ diff --git a/claude-code-4.5/skills/webapp-testing/utils/browser_config.py b/claude-code-4.5/skills/webapp-testing/utils/browser_config.py new file mode 100644 index 0000000..0555ffa --- /dev/null +++ b/claude-code-4.5/skills/webapp-testing/utils/browser_config.py @@ -0,0 +1,217 @@ +""" +Smart Browser Configuration for Testing + +Automatically configures browser context with appropriate settings +based on the testing environment (localhost vs production). + +Usage: + from utils.browser_config import BrowserConfig + + context = BrowserConfig.create_test_context( + browser, + 'http://localhost:3000' + ) +""" + +from typing import Optional, Dict +from playwright.sync_api import Browser, BrowserContext +from urllib.parse import urlparse + + +class BrowserConfig: + """Smart browser configuration for testing environments""" + + @staticmethod + def is_localhost_url(url: str) -> bool: + """ + Check if URL is localhost or local development environment. + + Args: + url: URL to check + + Returns: + True if localhost/127.0.0.1, False otherwise + + Examples: + >>> BrowserConfig.is_localhost_url('http://localhost:3000') + True + >>> BrowserConfig.is_localhost_url('http://127.0.0.1:8080') + True + >>> BrowserConfig.is_localhost_url('https://production.com') + False + """ + try: + parsed = urlparse(url) + hostname = parsed.hostname or parsed.netloc + + localhost_patterns = [ + 'localhost', + '127.0.0.1', + '0.0.0.0', + '::1', # IPv6 localhost + ] + + return any(pattern in hostname.lower() for pattern in localhost_patterns) + except Exception: + return False + + @staticmethod + def create_test_context( + browser: Browser, + base_url: str = 'http://localhost', + bypass_csp: Optional[bool] = None, + ignore_https_errors: bool = True, + extra_http_headers: Optional[Dict[str, str]] = None, + viewport: Optional[Dict[str, int]] = None, + record_video: bool = False, + verbose: bool = True + ) -> BrowserContext: + """ + Create browser context optimized for testing. + + Auto-detects CSP bypass need: + - If base_url contains 'localhost' or '127.0.0.1' → bypass_csp=True + - Otherwise → bypass_csp=False + + Args: + browser: Playwright browser instance + base_url: Base URL of application under test + bypass_csp: Override auto-detection (None = auto-detect) + ignore_https_errors: Ignore HTTPS errors (self-signed certs) + extra_http_headers: Additional HTTP headers to send + viewport: Custom viewport size (default: 1280x720) + record_video: Record video of test session + verbose: Print configuration choices + + Returns: + Configured browser context + + Example: + # Auto-detect CSP bypass for localhost + context = BrowserConfig.create_test_context( + browser, + 'http://localhost:7160' + ) + # Output: 🔓 CSP bypass enabled (testing on localhost) + + # Manually override for production testing + context = BrowserConfig.create_test_context( + browser, + 'https://production.com', + bypass_csp=False + ) + """ + # Auto-detect CSP bypass if not specified + if bypass_csp is None: + bypass_csp = BrowserConfig.is_localhost_url(base_url) + + # Default viewport for consistent testing + if viewport is None: + viewport = {'width': 1280, 'height': 720} + + # Build context options + context_options = { + 'bypass_csp': bypass_csp, + 'ignore_https_errors': ignore_https_errors, + 'viewport': viewport, + } + + # Add extra headers if provided + if extra_http_headers: + context_options['extra_http_headers'] = extra_http_headers + + # Add video recording if requested + if record_video: + context_options['record_video_dir'] = '/tmp/playwright-videos' + + # Create context + context = browser.new_context(**context_options) + + # Print configuration for visibility + if verbose: + print("\n" + "=" * 60) + print(" Browser Context Configuration") + print("=" * 60) + print(f" Base URL: {base_url}") + + if bypass_csp: + print(" 🔓 CSP bypass: ENABLED (testing on localhost)") + else: + print(" 🔒 CSP bypass: DISABLED (production mode)") + + if ignore_https_errors: + print(" ⚠️ HTTPS errors: IGNORED (self-signed certs OK)") + + print(f" 📐 Viewport: {viewport['width']}x{viewport['height']}") + + if extra_http_headers: + print(f" 📨 Extra headers: {len(extra_http_headers)} header(s)") + + if record_video: + print(" 🎥 Video recording: ENABLED") + + print("=" * 60 + "\n") + + return context + + @staticmethod + def create_mobile_context( + browser: Browser, + device: str = 'iPhone 12', + base_url: str = 'http://localhost', + bypass_csp: Optional[bool] = None, + verbose: bool = True + ) -> BrowserContext: + """ + Create mobile browser context with device emulation. + + Args: + browser: Playwright browser instance + device: Device to emulate (e.g., 'iPhone 12', 'Pixel 5') + base_url: Base URL of application under test + bypass_csp: Override auto-detection + verbose: Print configuration + + Returns: + Mobile browser context + + Example: + context = BrowserConfig.create_mobile_context( + browser, + device='iPhone 12', + base_url='http://localhost:3000' + ) + """ + from playwright.sync_api import devices + + # Get device descriptor + if device not in devices: + available = ', '.join(list(devices.keys())[:5]) + raise ValueError( + f"Unknown device: {device}. " + f"Available: {available}, ..." + ) + + device_descriptor = devices[device] + + # Auto-detect CSP bypass + if bypass_csp is None: + bypass_csp = BrowserConfig.is_localhost_url(base_url) + + # Merge with our defaults + context_options = { + **device_descriptor, + 'bypass_csp': bypass_csp, + 'ignore_https_errors': True, + } + + context = browser.new_context(**context_options) + + if verbose: + print(f"\n📱 Mobile context: {device}") + print(f" Viewport: {device_descriptor['viewport']}") + if bypass_csp: + print(f" 🔓 CSP bypass: ENABLED") + print() + + return context diff --git a/claude-code-4.5/skills/webapp-testing/utils/form_helpers.py b/claude-code-4.5/skills/webapp-testing/utils/form_helpers.py new file mode 100644 index 0000000..e011f5f --- /dev/null +++ b/claude-code-4.5/skills/webapp-testing/utils/form_helpers.py @@ -0,0 +1,463 @@ +""" +Smart Form Filling Helpers + +Handles common form patterns across web applications: +- Multi-step forms with validation +- Dynamic field variations (full name vs first/last name) +- Retry strategies for flaky selectors +- Intelligent field detection +""" + +from playwright.sync_api import Page +from typing import Dict, List, Any, Optional +import time + + +class SmartFormFiller: + """ + Intelligent form filling that handles variations in field structures. + + Example: + ```python + filler = SmartFormFiller() + filler.fill_name_field(page, "John Doe") # Tries full name or first/last + filler.fill_email_field(page, "test@example.com") + filler.fill_password_fields(page, "SecurePass123!") + ``` + """ + + @staticmethod + def fill_name_field(page: Page, full_name: str, timeout: int = 5000) -> bool: + """ + Fill name field(s) - handles both single "Full Name" and separate "First/Last Name" fields. + + Args: + page: Playwright Page object + full_name: Full name as string (e.g., "John Doe") + timeout: Maximum time to wait for fields (milliseconds) + + Returns: + True if successful, False otherwise + + Example: + ```python + # Works with both field structures: + # - Single field: "Full Name" + # - Separate fields: "First Name" and "Last Name" + fill_name_field(page, "Jane Smith") + ``` + """ + # Strategy 1: Try single "Full Name" field + full_name_selectors = [ + 'input[name*="full" i][name*="name" i]', + 'input[placeholder*="full name" i]', + 'input[placeholder*="name" i]', + 'input[id*="fullname" i]', + 'input[id*="full-name" i]', + ] + + for selector in full_name_selectors: + try: + field = page.locator(selector).first + if field.is_visible(timeout=1000): + field.fill(full_name) + return True + except: + continue + + # Strategy 2: Try separate First/Last Name fields + parts = full_name.split(' ', 1) + first_name = parts[0] if parts else full_name + last_name = parts[1] if len(parts) > 1 else '' + + first_name_selectors = [ + 'input[name*="first" i][name*="name" i]', + 'input[placeholder*="first name" i]', + 'input[id*="firstname" i]', + 'input[id*="first-name" i]', + ] + + last_name_selectors = [ + 'input[name*="last" i][name*="name" i]', + 'input[placeholder*="last name" i]', + 'input[id*="lastname" i]', + 'input[id*="last-name" i]', + ] + + first_filled = False + last_filled = False + + # Fill first name + for selector in first_name_selectors: + try: + field = page.locator(selector).first + if field.is_visible(timeout=1000): + field.fill(first_name) + first_filled = True + break + except: + continue + + # Fill last name + for selector in last_name_selectors: + try: + field = page.locator(selector).first + if field.is_visible(timeout=1000): + field.fill(last_name) + last_filled = True + break + except: + continue + + return first_filled or last_filled + + @staticmethod + def fill_email_field(page: Page, email: str, timeout: int = 5000) -> bool: + """ + Fill email field with multiple selector strategies. + + Args: + page: Playwright Page object + email: Email address + timeout: Maximum time to wait for field (milliseconds) + + Returns: + True if successful, False otherwise + """ + email_selectors = [ + 'input[type="email"]', + 'input[name="email" i]', + 'input[placeholder*="email" i]', + 'input[id*="email" i]', + 'input[autocomplete="email"]', + ] + + for selector in email_selectors: + try: + field = page.locator(selector).first + if field.is_visible(timeout=1000): + field.fill(email) + return True + except: + continue + + return False + + @staticmethod + def fill_password_fields(page: Page, password: str, confirm: bool = True, timeout: int = 5000) -> bool: + """ + Fill password field(s) - handles both single password and password + confirm. + + Args: + page: Playwright Page object + password: Password string + confirm: Whether to also fill confirmation field (default True) + timeout: Maximum time to wait for fields (milliseconds) + + Returns: + True if successful, False otherwise + """ + password_fields = page.locator('input[type="password"]').all() + + if not password_fields: + return False + + # Fill first password field + try: + password_fields[0].fill(password) + except: + return False + + # Fill confirmation field if requested and exists + if confirm and len(password_fields) > 1: + try: + password_fields[1].fill(password) + except: + pass + + return True + + @staticmethod + def fill_phone_field(page: Page, phone: str, timeout: int = 5000) -> bool: + """ + Fill phone number field with multiple selector strategies. + + Args: + page: Playwright Page object + phone: Phone number string + timeout: Maximum time to wait for field (milliseconds) + + Returns: + True if successful, False otherwise + """ + phone_selectors = [ + 'input[type="tel"]', + 'input[name*="phone" i]', + 'input[placeholder*="phone" i]', + 'input[id*="phone" i]', + 'input[autocomplete="tel"]', + ] + + for selector in phone_selectors: + try: + field = page.locator(selector).first + if field.is_visible(timeout=1000): + field.fill(phone) + return True + except: + continue + + return False + + @staticmethod + def fill_date_field(page: Page, date_value: str, field_hint: str = None, timeout: int = 5000) -> bool: + """ + Fill date field (handles both date input and text input). + + Args: + page: Playwright Page object + date_value: Date as string (format: YYYY-MM-DD for date inputs) + field_hint: Optional hint about field (e.g., "birth", "start", "end") + timeout: Maximum time to wait for field (milliseconds) + + Returns: + True if successful, False otherwise + + Example: + ```python + fill_date_field(page, "1990-01-15", field_hint="birth") + ``` + """ + # Build selectors based on hint + date_selectors = ['input[type="date"]'] + + if field_hint: + date_selectors.extend([ + f'input[name*="{field_hint}" i]', + f'input[placeholder*="{field_hint}" i]', + f'input[id*="{field_hint}" i]', + ]) + + for selector in date_selectors: + try: + field = page.locator(selector).first + if field.is_visible(timeout=1000): + field.fill(date_value) + return True + except: + continue + + return False + + +def fill_with_retry(page: Page, selectors: List[str], value: str, max_attempts: int = 3) -> bool: + """ + Try multiple selectors with retry logic. + + Args: + page: Playwright Page object + selectors: List of CSS selectors to try + value: Value to fill + max_attempts: Maximum retry attempts per selector + + Returns: + True if any selector succeeded, False otherwise + + Example: + ```python + selectors = ['input#email', 'input[name="email"]', 'input[type="email"]'] + fill_with_retry(page, selectors, 'test@example.com') + ``` + """ + for selector in selectors: + for attempt in range(max_attempts): + try: + field = page.locator(selector).first + if field.is_visible(timeout=1000): + field.fill(value) + time.sleep(0.3) + # Verify value was set + if field.input_value() == value: + return True + except: + if attempt < max_attempts - 1: + time.sleep(0.5) + continue + + return False + + +def handle_multi_step_form(page: Page, steps: List[Dict[str, Any]], continue_button_text: str = "CONTINUE") -> bool: + """ + Automate multi-step form completion. + + Args: + page: Playwright Page object + steps: List of step configurations, each with fields and actions + continue_button_text: Text of button to advance steps + + Returns: + True if all steps completed successfully, False otherwise + + Example: + ```python + steps = [ + { + 'fields': {'email': 'test@example.com', 'password': 'Pass123!'}, + 'checkbox': 'terms', # Optional checkbox to check + 'wait_after': 2, # Optional wait time after step + }, + { + 'fields': {'full_name': 'John Doe', 'date_of_birth': '1990-01-15'}, + }, + { + 'complete': True, # Final step, click complete/finish button + } + ] + handle_multi_step_form(page, steps) + ``` + """ + filler = SmartFormFiller() + + for i, step in enumerate(steps): + print(f" Processing step {i+1}/{len(steps)}...") + + # Fill fields in this step + if 'fields' in step: + for field_type, value in step['fields'].items(): + if field_type == 'email': + filler.fill_email_field(page, value) + elif field_type == 'password': + filler.fill_password_fields(page, value) + elif field_type == 'full_name': + filler.fill_name_field(page, value) + elif field_type == 'phone': + filler.fill_phone_field(page, value) + elif field_type.startswith('date'): + hint = field_type.replace('date_', '').replace('_', ' ') + filler.fill_date_field(page, value, field_hint=hint) + else: + # Generic field - try to find and fill + print(f" Warning: Unknown field type '{field_type}'") + + # Check checkbox if specified + if 'checkbox' in step: + try: + checkbox = page.locator('input[type="checkbox"]').first + checkbox.check() + except: + print(f" Warning: Could not check checkbox") + + # Wait if specified + if 'wait_after' in step: + time.sleep(step['wait_after']) + else: + time.sleep(1) + + # Click continue/submit button + if i < len(steps) - 1: # Not the last step + button_selectors = [ + f'button:has-text("{continue_button_text}")', + 'button[type="submit"]', + 'button:has-text("Next")', + 'button:has-text("Continue")', + ] + + clicked = False + for selector in button_selectors: + try: + button = page.locator(selector).first + if button.is_visible(timeout=2000): + button.click() + clicked = True + break + except: + continue + + if not clicked: + print(f" Warning: Could not find continue button for step {i+1}") + return False + + # Wait for next step to load + page.wait_for_load_state('networkidle') + time.sleep(2) + + else: # Last step + if step.get('complete', False): + complete_selectors = [ + 'button:has-text("COMPLETE")', + 'button:has-text("Complete")', + 'button:has-text("FINISH")', + 'button:has-text("Finish")', + 'button:has-text("SUBMIT")', + 'button:has-text("Submit")', + 'button[type="submit"]', + ] + + for selector in complete_selectors: + try: + button = page.locator(selector).first + if button.is_visible(timeout=2000): + button.click() + page.wait_for_load_state('networkidle') + time.sleep(3) + return True + except: + continue + + print(" Warning: Could not find completion button") + return False + + return True + + +def auto_fill_form(page: Page, field_mapping: Dict[str, str]) -> Dict[str, bool]: + """ + Automatically fill a form based on field mapping. + + Intelligently detects field types and uses appropriate filling strategies. + + Args: + page: Playwright Page object + field_mapping: Dictionary mapping field types to values + + Returns: + Dictionary with results for each field (True = filled, False = failed) + + Example: + ```python + results = auto_fill_form(page, { + 'email': 'test@example.com', + 'password': 'SecurePass123!', + 'full_name': 'Jane Doe', + 'phone': '+447700900123', + 'date_of_birth': '1990-01-15', + }) + print(f"Email filled: {results['email']}") + ``` + """ + filler = SmartFormFiller() + results = {} + + for field_type, value in field_mapping.items(): + if field_type == 'email': + results[field_type] = filler.fill_email_field(page, value) + elif field_type == 'password': + results[field_type] = filler.fill_password_fields(page, value) + elif 'name' in field_type.lower(): + results[field_type] = filler.fill_name_field(page, value) + elif 'phone' in field_type.lower(): + results[field_type] = filler.fill_phone_field(page, value) + elif 'date' in field_type.lower(): + hint = field_type.replace('date_of_', '').replace('_', ' ') + results[field_type] = filler.fill_date_field(page, value, field_hint=hint) + else: + # Try generic fill + try: + field = page.locator(f'input[name="{field_type}"]').first + field.fill(value) + results[field_type] = True + except: + results[field_type] = False + + return results diff --git a/claude-code-4.5/skills/webapp-testing/utils/smart_selectors.py b/claude-code-4.5/skills/webapp-testing/utils/smart_selectors.py new file mode 100644 index 0000000..eaca263 --- /dev/null +++ b/claude-code-4.5/skills/webapp-testing/utils/smart_selectors.py @@ -0,0 +1,306 @@ +""" +Smart Selector Strategies for Robust Web Testing + +Automatically tries multiple selector strategies to find elements, +reducing test brittleness when HTML structure changes. + +Usage: + from utils.smart_selectors import SelectorStrategies + + # Find and fill email field + SelectorStrategies.smart_fill(page, 'email', 'test@example.com') + + # Find and click button + SelectorStrategies.smart_click(page, 'Sign In') +""" + +from typing import Optional, List +from playwright.sync_api import Page, TimeoutError as PlaywrightTimeoutError + + +class SelectorStrategies: + """Multiple strategies for finding common elements""" + + # Reduced timeouts for faster failure (5s per strategy instead of default 30s) + DEFAULT_TIMEOUT = 5000 # 5 seconds per strategy attempt + MAX_TOTAL_TIMEOUT = 10000 # 10 seconds max across all strategies + + @staticmethod + def find_input_field( + page: Page, + field_type: str, + timeout: int = DEFAULT_TIMEOUT, + verbose: bool = True + ) -> Optional[str]: + """ + Find input field using multiple strategies in order of reliability. + + Strategies (in order): + 1. Test IDs: [data-testid*="field_type"] + 2. ARIA labels: input[aria-label*="field_type" i] + 3. Placeholder: input[placeholder*="field_type" i] + 4. Name attribute: input[name*="field_type" i] + 5. Type attribute: input[type="field_type"] + 6. ID attribute: #field_type, input[id*="field_type" i] + + Args: + page: Playwright Page object + field_type: Type of field to find (e.g., 'email', 'password') + timeout: Timeout per strategy in milliseconds (default: 5000) + verbose: Print which strategy succeeded (default: True) + + Returns: + Selector string that worked, or None if not found + + Example: + selector = SelectorStrategies.find_input_field(page, 'email') + if selector: + page.fill(selector, 'test@example.com') + """ + strategies = [ + # Strategy 1: Test IDs (most reliable) + (f'[data-testid*="{field_type}" i]', 'data-testid'), + + # Strategy 2: ARIA labels (accessibility best practice) + (f'input[aria-label*="{field_type}" i]', 'aria-label'), + + # Strategy 3: Placeholder text + (f'input[placeholder*="{field_type}" i]', 'placeholder'), + + # Strategy 4: Name attribute + (f'input[name*="{field_type}" i]', 'name attribute'), + + # Strategy 5: Type attribute (works for email, password, text) + (f'input[type="{field_type}"]', 'type attribute'), + + # Strategy 6: ID attribute (exact match) + (f'#{field_type}', 'id (exact)'), + + # Strategy 7: ID attribute (partial match) + (f'input[id*="{field_type}" i]', 'id (partial)'), + ] + + for selector, strategy_name in strategies: + try: + locator = page.locator(selector).first + if locator.is_visible(timeout=timeout): + if verbose: + print(f"✓ Found field via {strategy_name}: {selector}") + return selector + except PlaywrightTimeoutError: + continue + except Exception: + # Catch other errors (element not found, etc.) + continue + + if verbose: + print(f"✗ Could not find field for '{field_type}' using any strategy") + return None + + @staticmethod + def find_button( + page: Page, + button_text: str, + timeout: int = DEFAULT_TIMEOUT, + verbose: bool = True + ) -> Optional[str]: + """ + Find button by text using multiple strategies. + + Strategies: + 1. Test ID: [data-testid*="button-text"] + 2. Role with name: button[name="button_text"] + 3. Exact text: button:has-text("Button Text") + 4. Partial text (case-insensitive): button:text-matches("button text", "i") + 5. Link as button: a:has-text("Button Text") + 6. Input submit: input[type="submit"][value*="button text" i] + + Args: + page: Playwright Page object + button_text: Text on the button + timeout: Timeout per strategy in milliseconds + verbose: Print which strategy succeeded + + Returns: + Selector string that worked, or None if not found + + Example: + selector = SelectorStrategies.find_button(page, 'Sign In') + if selector: + page.click(selector) + """ + # Normalize button text for test-id matching + test_id = button_text.lower().replace(' ', '-') + + strategies = [ + # Strategy 1: Test IDs + (f'[data-testid*="{test_id}" i]', 'data-testid'), + + # Strategy 2: Button with name attribute + (f'button[name*="{button_text}" i]', 'button name'), + + # Strategy 3: Exact text match + (f'button:has-text("{button_text}")', 'exact text'), + + # Strategy 4: Case-insensitive text match + (f'button:text-matches("{button_text}", "i")', 'case-insensitive text'), + + # Strategy 5: Link styled as button + (f'a:has-text("{button_text}")', 'link (exact text)'), + + # Strategy 6: Link case-insensitive + (f'a:text-matches("{button_text}", "i")', 'link (case-insensitive)'), + + # Strategy 7: Input submit button + (f'input[type="submit"][value*="{button_text}" i]', 'submit input'), + + # Strategy 8: Any clickable element with text + (f'[role="button"]:has-text("{button_text}")', 'role=button'), + ] + + for selector, strategy_name in strategies: + try: + locator = page.locator(selector).first + if locator.is_visible(timeout=timeout): + if verbose: + print(f"✓ Found button via {strategy_name}: {selector}") + return selector + except PlaywrightTimeoutError: + continue + except Exception: + continue + + if verbose: + print(f"✗ Could not find button '{button_text}' using any strategy") + return None + + @staticmethod + def smart_fill( + page: Page, + field_type: str, + value: str, + timeout: int = MAX_TOTAL_TIMEOUT, + verbose: bool = True + ) -> bool: + """ + Find and fill a field automatically using smart selector strategies. + + Args: + page: Playwright Page object + field_type: Type of field (e.g., 'email', 'password', 'username') + value: Value to fill + timeout: Max timeout across all strategies + verbose: Print progress messages + + Returns: + True if successful, False otherwise + + Example: + success = SelectorStrategies.smart_fill(page, 'email', 'test@example.com') + if not success: + print("Failed to fill email field") + """ + selector = SelectorStrategies.find_input_field( + page, field_type, timeout=timeout // 2, verbose=verbose + ) + + if selector: + try: + page.fill(selector, value) + if verbose: + print(f"✓ Filled '{field_type}' with value") + return True + except Exception as e: + if verbose: + print(f"✗ Found field but failed to fill: {e}") + return False + + return False + + @staticmethod + def smart_click( + page: Page, + button_text: str, + timeout: int = MAX_TOTAL_TIMEOUT, + verbose: bool = True + ) -> bool: + """ + Find and click a button automatically using smart selector strategies. + + Args: + page: Playwright Page object + button_text: Text on the button to click + timeout: Max timeout across all strategies + verbose: Print progress messages + + Returns: + True if successful, False otherwise + + Example: + success = SelectorStrategies.smart_click(page, 'Sign In') + if not success: + print("Failed to click Sign In button") + """ + selector = SelectorStrategies.find_button( + page, button_text, timeout=timeout // 2, verbose=verbose + ) + + if selector: + try: + page.click(selector) + if verbose: + print(f"✓ Clicked '{button_text}' button") + return True + except Exception as e: + if verbose: + print(f"✗ Found button but failed to click: {e}") + return False + + return False + + @staticmethod + def find_any_element( + page: Page, + selectors: List[str], + timeout: int = DEFAULT_TIMEOUT, + verbose: bool = True + ) -> Optional[str]: + """ + Try multiple custom selectors and return the first one that works. + + Useful when you have specific selectors to try but want fallback logic. + + Args: + page: Playwright Page object + selectors: List of CSS selectors to try + timeout: Timeout per selector + verbose: Print which selector worked + + Returns: + First selector that found a visible element, or None + + Example: + selectors = [ + 'button#submit', + 'button.submit-btn', + 'input[type="submit"]' + ] + selector = SelectorStrategies.find_any_element(page, selectors) + if selector: + page.click(selector) + """ + for selector in selectors: + try: + locator = page.locator(selector).first + if locator.is_visible(timeout=timeout): + if verbose: + print(f"✓ Found element: {selector}") + return selector + except PlaywrightTimeoutError: + continue + except Exception: + continue + + if verbose: + print(f"✗ Could not find element using any of {len(selectors)} selectors") + return None diff --git a/claude-code-4.5/skills/webapp-testing/utils/supabase.py b/claude-code-4.5/skills/webapp-testing/utils/supabase.py new file mode 100644 index 0000000..ecceac2 --- /dev/null +++ b/claude-code-4.5/skills/webapp-testing/utils/supabase.py @@ -0,0 +1,353 @@ +""" +Supabase Test Utilities + +Generic database helpers for testing with Supabase. +Supports user management, email verification, and test data cleanup. +""" + +import subprocess +import json +from typing import Dict, List, Optional, Any + + +class SupabaseTestClient: + """ + Generic Supabase test client for database operations during testing. + + Example: + ```python + client = SupabaseTestClient( + url="https://project.supabase.co", + service_key="your-service-role-key", + db_password="your-db-password" + ) + + # Create test user + user_id = client.create_user("test@example.com", "password123") + + # Verify email (bypass email sending) + client.confirm_email(user_id) + + # Cleanup after test + client.delete_user(user_id) + ``` + """ + + def __init__(self, url: str, service_key: str, db_password: str = None, db_host: str = None): + """ + Initialize Supabase test client. + + Args: + url: Supabase project URL (e.g., "https://project.supabase.co") + service_key: Service role key for admin operations + db_password: Database password for direct SQL operations + db_host: Database host (if different from default) + """ + self.url = url.rstrip('/') + self.service_key = service_key + self.db_password = db_password + + # Extract DB host from URL if not provided + if not db_host: + # Convert https://abc123.supabase.co to db.abc123.supabase.co + project_ref = url.split('//')[1].split('.')[0] + self.db_host = f"db.{project_ref}.supabase.co" + else: + self.db_host = db_host + + def _run_sql(self, sql: str) -> Dict[str, Any]: + """ + Execute SQL directly against the database. + + Args: + sql: SQL query to execute + + Returns: + Dictionary with 'success', 'output', 'error' keys + """ + if not self.db_password: + return {'success': False, 'error': 'Database password not provided'} + + try: + result = subprocess.run( + [ + 'psql', + '-h', self.db_host, + '-p', '5432', + '-U', 'postgres', + '-c', sql, + '-t', # Tuples only + '-A', # Unaligned output + ], + env={'PGPASSWORD': self.db_password}, + capture_output=True, + text=True, + timeout=10 + ) + + return { + 'success': result.returncode == 0, + 'output': result.stdout.strip(), + 'error': result.stderr.strip() if result.returncode != 0 else None + } + except Exception as e: + return {'success': False, 'error': str(e)} + + def create_user(self, email: str, password: str, metadata: Dict = None) -> Optional[str]: + """ + Create a test user via Auth Admin API. + + Args: + email: User email + password: User password + metadata: Optional user metadata + + Returns: + User ID if successful, None otherwise + + Example: + ```python + user_id = client.create_user( + "test@example.com", + "SecurePass123!", + metadata={"full_name": "Test User"} + ) + ``` + """ + import requests + + payload = { + 'email': email, + 'password': password, + 'email_confirm': True + } + + if metadata: + payload['user_metadata'] = metadata + + try: + response = requests.post( + f"{self.url}/auth/v1/admin/users", + headers={ + 'Authorization': f'Bearer {self.service_key}', + 'apikey': self.service_key, + 'Content-Type': 'application/json' + }, + json=payload, + timeout=10 + ) + + if response.ok: + return response.json().get('id') + else: + print(f"Error creating user: {response.text}") + return None + except Exception as e: + print(f"Exception creating user: {e}") + return None + + def confirm_email(self, user_id: str = None, email: str = None) -> bool: + """ + Confirm user email (bypass email verification for testing). + + Args: + user_id: User ID (if known) + email: User email (alternative to user_id) + + Returns: + True if successful, False otherwise + + Example: + ```python + # By user ID + client.confirm_email(user_id="abc-123") + + # Or by email + client.confirm_email(email="test@example.com") + ``` + """ + if user_id: + sql = f"UPDATE auth.users SET email_confirmed_at = NOW() WHERE id = '{user_id}';" + elif email: + sql = f"UPDATE auth.users SET email_confirmed_at = NOW() WHERE email = '{email}';" + else: + return False + + result = self._run_sql(sql) + return result['success'] + + def delete_user(self, user_id: str = None, email: str = None) -> bool: + """ + Delete a test user and related data. + + Args: + user_id: User ID + email: User email (alternative to user_id) + + Returns: + True if successful, False otherwise + + Example: + ```python + client.delete_user(email="test@example.com") + ``` + """ + # Get user ID if email provided + if email and not user_id: + result = self._run_sql(f"SELECT id FROM auth.users WHERE email = '{email}';") + if result['success'] and result['output']: + user_id = result['output'].strip() + else: + return False + + if not user_id: + return False + + # Delete from profiles first (foreign key) + self._run_sql(f"DELETE FROM public.profiles WHERE id = '{user_id}';") + + # Delete from auth.users + result = self._run_sql(f"DELETE FROM auth.users WHERE id = '{user_id}';") + + return result['success'] + + def cleanup_related_records(self, user_id: str, tables: List[str] = None) -> Dict[str, bool]: + """ + Clean up user-related records from multiple tables. + + Args: + user_id: User ID + tables: List of tables to clean (defaults to common tables) + + Returns: + Dictionary mapping table names to cleanup success status + + Example: + ```python + results = client.cleanup_related_records( + user_id="abc-123", + tables=["profiles", "team_members", "coach_verification_requests"] + ) + ``` + """ + if not tables: + tables = [ + 'pending_profiles', + 'coach_verification_requests', + 'team_members', + 'team_join_requests', + 'profiles' + ] + + results = {} + + for table in tables: + # Try both user_id and id columns + sql = f"DELETE FROM public.{table} WHERE user_id = '{user_id}' OR id = '{user_id}';" + result = self._run_sql(sql) + results[table] = result['success'] + + return results + + def create_invite_code(self, code: str, code_type: str = 'general', max_uses: int = 999) -> bool: + """ + Create an invite code for testing. + + Args: + code: Invite code string + code_type: Type of code (e.g., 'general', 'team_join') + max_uses: Maximum number of uses + + Returns: + True if successful, False otherwise + + Example: + ```python + client.create_invite_code("TEST2024", code_type="general") + ``` + """ + sql = f""" + INSERT INTO public.invite_codes (code, code_type, is_valid, max_uses, expires_at) + VALUES ('{code}', '{code_type}', true, {max_uses}, NOW() + INTERVAL '30 days') + ON CONFLICT (code) DO UPDATE SET is_valid=true, max_uses={max_uses}, use_count=0; + """ + + result = self._run_sql(sql) + return result['success'] + + def find_user_by_email(self, email: str) -> Optional[str]: + """ + Find user ID by email address. + + Args: + email: User email + + Returns: + User ID if found, None otherwise + """ + sql = f"SELECT id FROM auth.users WHERE email = '{email}';" + result = self._run_sql(sql) + + if result['success'] and result['output']: + return result['output'].strip() + return None + + def get_user_privileges(self, user_id: str) -> Optional[List[str]]: + """ + Get user's privilege array. + + Args: + user_id: User ID + + Returns: + List of privileges if found, None otherwise + """ + sql = f"SELECT privileges FROM public.profiles WHERE id = '{user_id}';" + result = self._run_sql(sql) + + if result['success'] and result['output']: + # Parse PostgreSQL array format + privileges_str = result['output'].strip('{}') + return [p.strip() for p in privileges_str.split(',')] + return None + + +def quick_cleanup(email: str, db_password: str, project_url: str) -> bool: + """ + Quick cleanup helper - delete user and all related data. + + Args: + email: User email to delete + db_password: Database password + project_url: Supabase project URL + + Returns: + True if successful, False otherwise + + Example: + ```python + from utils.supabase import quick_cleanup + + # Clean up test user + quick_cleanup( + "test@example.com", + "db_password", + "https://project.supabase.co" + ) + ``` + """ + client = SupabaseTestClient( + url=project_url, + service_key="", # Not needed for SQL operations + db_password=db_password + ) + + user_id = client.find_user_by_email(email) + if not user_id: + return True # Already deleted + + # Clean up all related tables + client.cleanup_related_records(user_id) + + # Delete user + return client.delete_user(user_id) diff --git a/claude-code-4.5/skills/webapp-testing/utils/ui_interactions.py b/claude-code-4.5/skills/webapp-testing/utils/ui_interactions.py new file mode 100644 index 0000000..e385be7 --- /dev/null +++ b/claude-code-4.5/skills/webapp-testing/utils/ui_interactions.py @@ -0,0 +1,454 @@ +""" +UI Interaction Helpers for Web Automation + +Common UI patterns that appear across many web applications: +- Cookie consent banners +- Modal dialogs +- Loading overlays +- Welcome tours/onboarding +- Fixed headers blocking clicks +""" + +from playwright.sync_api import Page +import time + + +def dismiss_cookie_banner(page: Page, timeout: int = 3000) -> bool: + """ + Detect and dismiss cookie consent banners. + + Tries common patterns: + - "Accept" / "Accept All" / "OK" buttons + - "I Agree" / "Got it" buttons + - Cookie banner containers + + Args: + page: Playwright Page object + timeout: Maximum time to wait for banner (milliseconds) + + Returns: + True if banner was found and dismissed, False otherwise + + Example: + ```python + page.goto('https://example.com') + if dismiss_cookie_banner(page): + print("Cookie banner dismissed") + ``` + """ + cookie_button_selectors = [ + 'button:has-text("Accept")', + 'button:has-text("Accept All")', + 'button:has-text("Accept all")', + 'button:has-text("I Agree")', + 'button:has-text("I agree")', + 'button:has-text("OK")', + 'button:has-text("Got it")', + 'button:has-text("Allow")', + 'button:has-text("Allow all")', + '[data-testid="cookie-accept"]', + '[data-testid="accept-cookies"]', + '[id*="cookie-accept" i]', + '[id*="accept-cookie" i]', + '[class*="cookie-accept" i]', + ] + + for selector in cookie_button_selectors: + try: + button = page.locator(selector).first + if button.is_visible(timeout=timeout): + button.click() + time.sleep(0.5) # Brief wait for banner to disappear + return True + except: + continue + + return False + + +def dismiss_modal(page: Page, modal_identifier: str = None, timeout: int = 2000) -> bool: + """ + Close modal dialogs with multiple fallback strategies. + + Strategies: + 1. If identifier provided, close that specific modal + 2. Click close button (X, Close, Cancel, etc.) + 3. Press Escape key + 4. Click backdrop/overlay + + Args: + page: Playwright Page object + modal_identifier: Optional - specific text in modal to identify it + timeout: Maximum time to wait for modal (milliseconds) + + Returns: + True if modal was found and closed, False otherwise + + Example: + ```python + # Close any modal + dismiss_modal(page) + + # Close specific "Welcome" modal + dismiss_modal(page, modal_identifier="Welcome") + ``` + """ + # If specific modal identifier provided, wait for it first + if modal_identifier: + try: + modal = page.locator(f'[role="dialog"]:has-text("{modal_identifier}"), dialog:has-text("{modal_identifier}")').first + if not modal.is_visible(timeout=timeout): + return False + except: + return False + + # Strategy 1: Click close button + close_button_selectors = [ + 'button:has-text("Close")', + 'button:has-text("×")', + 'button:has-text("X")', + 'button:has-text("Cancel")', + 'button:has-text("GOT IT")', + 'button:has-text("Got it")', + 'button:has-text("OK")', + 'button:has-text("Dismiss")', + '[aria-label="Close"]', + '[aria-label="close"]', + '[data-testid="close-modal"]', + '[class*="close" i]', + '[class*="dismiss" i]', + ] + + for selector in close_button_selectors: + try: + button = page.locator(selector).first + if button.is_visible(timeout=500): + button.click() + time.sleep(0.5) + return True + except: + continue + + # Strategy 2: Press Escape key + try: + page.keyboard.press('Escape') + time.sleep(0.5) + # Check if modal is gone + modals = page.locator('[role="dialog"], dialog').all() + if all(not m.is_visible() for m in modals): + return True + except: + pass + + # Strategy 3: Click backdrop (if exists and clickable) + try: + backdrop = page.locator('[class*="backdrop" i], [class*="overlay" i]').first + if backdrop.is_visible(timeout=500): + backdrop.click(position={'x': 10, 'y': 10}) # Click corner, not center + time.sleep(0.5) + return True + except: + pass + + return False + + +def click_with_header_offset(page: Page, selector: str, header_height: int = 80, force: bool = False): + """ + Click an element while accounting for fixed headers that might block it. + + Scrolls the element into view with an offset to avoid fixed headers, + then clicks it. + + Args: + page: Playwright Page object + selector: CSS selector for the element to click + header_height: Height of fixed header in pixels (default 80) + force: Whether to use force click if normal click fails + + Example: + ```python + # Click button that might be behind a fixed header + click_with_header_offset(page, 'button#submit', header_height=100) + ``` + """ + element = page.locator(selector).first + + # Scroll element into view with offset + element.evaluate(f'el => el.scrollIntoView({{ block: "center", inline: "nearest" }})') + page.evaluate(f'window.scrollBy(0, -{header_height})') + time.sleep(0.3) # Brief wait for scroll to complete + + try: + element.click() + except Exception as e: + if force: + element.click(force=True) + else: + raise e + + +def force_click_if_needed(page: Page, selector: str, timeout: int = 5000) -> bool: + """ + Try normal click first, use force click if it fails (e.g., due to overlays). + + Args: + page: Playwright Page object + selector: CSS selector for the element to click + timeout: Maximum time to wait for element (milliseconds) + + Returns: + True if click succeeded (normal or forced), False otherwise + + Example: + ```python + # Try to click, handling potential overlays + if force_click_if_needed(page, 'button#submit'): + print("Button clicked successfully") + ``` + """ + try: + element = page.locator(selector).first + if not element.is_visible(timeout=timeout): + return False + + # Try normal click first + try: + element.click(timeout=timeout) + return True + except: + # Fall back to force click + element.click(force=True) + return True + except: + return False + + +def wait_for_no_overlay(page: Page, max_wait_seconds: int = 10) -> bool: + """ + Wait for loading overlays/spinners to disappear. + + Looks for common loading overlay patterns and waits until they're gone. + + Args: + page: Playwright Page object + max_wait_seconds: Maximum time to wait (seconds) + + Returns: + True if overlays disappeared, False if timeout + + Example: + ```python + page.click('button#submit') + wait_for_no_overlay(page) # Wait for loading to complete + ``` + """ + overlay_selectors = [ + '[class*="loading" i]', + '[class*="spinner" i]', + '[class*="overlay" i]', + '[class*="backdrop" i]', + '[data-loading="true"]', + '[aria-busy="true"]', + '.loader', + '.loading', + '#loading', + ] + + start_time = time.time() + + while time.time() - start_time < max_wait_seconds: + all_hidden = True + + for selector in overlay_selectors: + try: + overlays = page.locator(selector).all() + for overlay in overlays: + if overlay.is_visible(): + all_hidden = False + break + except: + continue + + if not all_hidden: + break + + if all_hidden: + return True + + time.sleep(0.5) + + return False + + +def handle_welcome_tour(page: Page, skip_button_text: str = "Skip") -> bool: + """ + Automatically skip onboarding tours or welcome wizards. + + Looks for and clicks "Skip", "Skip Tour", "Close", "Maybe Later" buttons. + + Args: + page: Playwright Page object + skip_button_text: Text to look for in skip buttons (default "Skip") + + Returns: + True if tour was skipped, False if no tour found + + Example: + ```python + page.goto('https://app.example.com') + handle_welcome_tour(page) # Skip any onboarding tour + ``` + """ + skip_selectors = [ + f'button:has-text("{skip_button_text}")', + 'button:has-text("Skip Tour")', + 'button:has-text("Maybe Later")', + 'button:has-text("No Thanks")', + 'button:has-text("Close Tour")', + '[data-testid="skip-tour"]', + '[data-testid="close-tour"]', + '[aria-label="Skip tour"]', + '[aria-label="Close tour"]', + ] + + for selector in skip_selectors: + try: + button = page.locator(selector).first + if button.is_visible(timeout=2000): + button.click() + time.sleep(0.5) + return True + except: + continue + + return False + + +def wait_for_stable_dom(page: Page, stability_duration_ms: int = 1000, max_wait_seconds: int = 10) -> bool: + """ + Wait for the DOM to stop changing (useful for dynamic content loading). + + Monitors for DOM mutations and waits until no changes occur for the specified duration. + + Args: + page: Playwright Page object + stability_duration_ms: Duration of no changes to consider stable (milliseconds) + max_wait_seconds: Maximum time to wait (seconds) + + Returns: + True if DOM stabilized, False if timeout + + Example: + ```python + page.goto('https://app.example.com') + wait_for_stable_dom(page) # Wait for all dynamic content to load + ``` + """ + # Inject mutation observer script + script = f""" + new Promise((resolve) => {{ + let lastMutation = Date.now(); + const observer = new MutationObserver(() => {{ + lastMutation = Date.now(); + }}); + + observer.observe(document.body, {{ + childList: true, + subtree: true, + attributes: true + }}); + + const checkStability = () => {{ + if (Date.now() - lastMutation >= {stability_duration_ms}) {{ + observer.disconnect(); + resolve(true); + }} else if (Date.now() - lastMutation > {max_wait_seconds * 1000}) {{ + observer.disconnect(); + resolve(false); + }} else {{ + setTimeout(checkStability, 100); + }} + }}; + + setTimeout(checkStability, {stability_duration_ms}); + }}) + """ + + try: + result = page.evaluate(script) + return result + except: + return False + + +def setup_page_with_csp_handling(page): + """ + Set up page with automatic CSP error detection and helpful suggestions. + + Monitors console for CSP violations and suggests fixes. + Call this once per page before navigation. + + Args: + page: Playwright Page object + + Example: + setup_page_with_csp_handling(page) + page.goto('http://localhost:7160') + # If CSP violation occurs: + # Output: ⚠️ CSP Violation detected: [error message] + # 💡 Suggestion: Use BrowserConfig.create_test_context() with auto CSP bypass + + Usage with BrowserConfig: + from utils.browser_config import BrowserConfig + + context = BrowserConfig.create_test_context(browser, 'http://localhost:3000') + page = context.new_page() + setup_page_with_csp_handling(page) + """ + csp_violations = [] + + def handle_console(msg): + """Handler for console messages that detects CSP violations""" + text = msg.text.lower() + + # Detect various CSP-related errors + csp_keywords = [ + 'content security policy', + 'csp', + 'refused to execute inline script', + 'refused to load', + 'blocked by content security policy', + ] + + if any(keyword in text for keyword in csp_keywords): + # Avoid duplicate messages + if msg.text not in csp_violations: + csp_violations.append(msg.text) + print("\n" + "=" * 70) + print("⚠️ CSP VIOLATION DETECTED") + print("=" * 70) + print(f"Message: {msg.text[:200]}") + print("\n💡 SUGGESTION:") + print(" For localhost testing, use:") + print(" ") + print(" from utils.browser_config import BrowserConfig") + print(" context = BrowserConfig.create_test_context(") + print(" browser, 'http://localhost:3000'") + print(" )") + print(" # Auto-enables CSP bypass for localhost") + print("\n Or manually:") + print(" context = browser.new_context(bypass_csp=True)") + print("=" * 70 + "\n") + + # Attach console listener + page.on('console', handle_console) + + # Also monitor for page errors related to CSP + def handle_page_error(error): + """Handler for page errors""" + error_text = str(error).lower() + if 'content security policy' in error_text or 'csp' in error_text: + print(f"\n⚠️ Page Error (CSP-related): {error}\n") + + page.on('pageerror', handle_page_error) diff --git a/claude-code-4.5/skills/webapp-testing/utils/wait_strategies.py b/claude-code-4.5/skills/webapp-testing/utils/wait_strategies.py new file mode 100644 index 0000000..f92d236 --- /dev/null +++ b/claude-code-4.5/skills/webapp-testing/utils/wait_strategies.py @@ -0,0 +1,312 @@ +""" +Advanced Wait Strategies for Reliable Web Automation + +Better alternatives to simple sleep() or networkidle for dynamic web applications. +""" + +from playwright.sync_api import Page +import time +from typing import Callable, Optional, Any + + +def wait_for_api_call(page: Page, url_pattern: str, timeout_seconds: int = 10) -> Optional[Any]: + """ + Wait for a specific API call to complete and return its response. + + Args: + page: Playwright Page object + url_pattern: URL pattern to match (can include wildcards) + timeout_seconds: Maximum time to wait + + Returns: + Response data if call completed, None if timeout + + Example: + ```python + # Wait for user profile API call + response = wait_for_api_call(page, '**/api/profile**') + if response: + print(f"Profile loaded: {response}") + ``` + """ + response_data = {'data': None, 'completed': False} + + def handle_response(response): + if url_pattern.replace('**', '') in response.url: + try: + response_data['data'] = response.json() + response_data['completed'] = True + except: + response_data['completed'] = True + + page.on('response', handle_response) + + start_time = time.time() + while not response_data['completed'] and (time.time() - start_time) < timeout_seconds: + time.sleep(0.1) + + page.remove_listener('response', handle_response) + + return response_data['data'] + + +def wait_for_element_stable(page: Page, selector: str, stability_ms: int = 1000, timeout_seconds: int = 10) -> bool: + """ + Wait for an element's position to stabilize (stop moving/changing). + + Useful for elements that animate or shift due to dynamic content loading. + + Args: + page: Playwright Page object + selector: CSS selector for the element + stability_ms: Duration element must remain stable (milliseconds) + timeout_seconds: Maximum time to wait + + Returns: + True if element stabilized, False if timeout + + Example: + ```python + # Wait for dropdown menu to finish animating + wait_for_element_stable(page, '.dropdown-menu', stability_ms=500) + ``` + """ + try: + element = page.locator(selector).first + + script = f""" + (element, stabilityMs) => {{ + return new Promise((resolve) => {{ + let lastRect = element.getBoundingClientRect(); + let lastChange = Date.now(); + + const checkStability = () => {{ + const currentRect = element.getBoundingClientRect(); + + if (currentRect.top !== lastRect.top || + currentRect.left !== lastRect.left || + currentRect.width !== lastRect.width || + currentRect.height !== lastRect.height) {{ + lastChange = Date.now(); + lastRect = currentRect; + }} + + if (Date.now() - lastChange >= stabilityMs) {{ + resolve(true); + }} else if (Date.now() - lastChange < {timeout_seconds * 1000}) {{ + setTimeout(checkStability, 50); + }} else {{ + resolve(false); + }} + }}; + + setTimeout(checkStability, stabilityMs); + }}); + }} + """ + + result = element.evaluate(script, stability_ms) + return result + except: + return False + + +def wait_with_retry(page: Page, condition_fn: Callable[[], bool], max_retries: int = 5, backoff_seconds: float = 0.5) -> bool: + """ + Wait for a condition with exponential backoff retry. + + Args: + page: Playwright Page object + condition_fn: Function that returns True when condition is met + max_retries: Maximum number of retry attempts + backoff_seconds: Initial backoff duration (doubles each retry) + + Returns: + True if condition met, False if all retries exhausted + + Example: + ```python + # Wait for specific element to appear with retry + def check_dashboard(): + return page.locator('#dashboard').is_visible() + + if wait_with_retry(page, check_dashboard): + print("Dashboard loaded!") + ``` + """ + wait_time = backoff_seconds + + for attempt in range(max_retries): + try: + if condition_fn(): + return True + except: + pass + + if attempt < max_retries - 1: + time.sleep(wait_time) + wait_time *= 2 # Exponential backoff + + return False + + +def smart_navigation_wait(page: Page, expected_url_pattern: str = None, timeout_seconds: int = 10) -> bool: + """ + Comprehensive wait strategy after navigation/interaction. + + Combines multiple strategies: + 1. Network idle + 2. DOM stability + 3. URL pattern match (if provided) + + Args: + page: Playwright Page object + expected_url_pattern: Optional URL pattern to wait for + timeout_seconds: Maximum time to wait + + Returns: + True if all conditions met, False if timeout + + Example: + ```python + page.click('button#login') + smart_navigation_wait(page, expected_url_pattern='**/dashboard**') + ``` + """ + start_time = time.time() + + # Step 1: Wait for network idle + try: + page.wait_for_load_state('networkidle', timeout=timeout_seconds * 1000) + except: + pass + + # Step 2: Check URL if pattern provided + if expected_url_pattern: + while (time.time() - start_time) < timeout_seconds: + current_url = page.url + pattern = expected_url_pattern.replace('**', '') + if pattern in current_url: + break + time.sleep(0.5) + else: + return False + + # Step 3: Brief wait for DOM stability + time.sleep(1) + + return True + + +def wait_for_data_load(page: Page, data_attribute: str = 'data-loaded', timeout_seconds: int = 10) -> bool: + """ + Wait for data-loading attribute to indicate completion. + + Args: + page: Playwright Page object + data_attribute: Data attribute to check (e.g., 'data-loaded') + timeout_seconds: Maximum time to wait + + Returns: + True if data loaded, False if timeout + + Example: + ```python + # Wait for element with data-loaded="true" + wait_for_data_load(page, data_attribute='data-loaded') + ``` + """ + start_time = time.time() + + while (time.time() - start_time) < timeout_seconds: + try: + elements = page.locator(f'[{data_attribute}="true"]').all() + if elements: + return True + except: + pass + + time.sleep(0.3) + + return False + + +def wait_until_no_element(page: Page, selector: str, timeout_seconds: int = 10) -> bool: + """ + Wait until an element is no longer visible (e.g., loading spinner disappears). + + Args: + page: Playwright Page object + selector: CSS selector for the element + timeout_seconds: Maximum time to wait + + Returns: + True if element disappeared, False if still visible after timeout + + Example: + ```python + # Wait for loading spinner to disappear + wait_until_no_element(page, '.loading-spinner') + ``` + """ + start_time = time.time() + + while (time.time() - start_time) < timeout_seconds: + try: + element = page.locator(selector).first + if not element.is_visible(timeout=500): + return True + except: + return True # Element not found = disappeared + + time.sleep(0.3) + + return False + + +def combined_wait(page: Page, timeout_seconds: int = 10) -> bool: + """ + Comprehensive wait combining multiple strategies for maximum reliability. + + Uses: + 1. Network idle + 2. No visible loading indicators + 3. DOM stability + 4. Brief settling time + + Args: + page: Playwright Page object + timeout_seconds: Maximum time to wait + + Returns: + True if all conditions met, False if timeout + + Example: + ```python + page.click('button#submit') + combined_wait(page) # Wait for everything to settle + ``` + """ + start_time = time.time() + + # Network idle + try: + page.wait_for_load_state('networkidle', timeout=timeout_seconds * 1000) + except: + pass + + # Wait for common loading indicators to disappear + loading_selectors = [ + '.loading', + '.spinner', + '[data-loading="true"]', + '[aria-busy="true"]', + ] + + for selector in loading_selectors: + wait_until_no_element(page, selector, timeout_seconds=3) + + # Final settling time + time.sleep(1) + + return (time.time() - start_time) < timeout_seconds diff --git a/claude-code-4.5/utils/git-worktree-utils.sh b/claude-code-4.5/utils/git-worktree-utils.sh new file mode 100755 index 0000000..3e9b9f8 --- /dev/null +++ b/claude-code-4.5/utils/git-worktree-utils.sh @@ -0,0 +1,111 @@ +#!/bin/bash + +# ABOUTME: Git worktree utilities for agent workspace isolation + +set -euo pipefail + +# Create agent worktree with isolated branch +create_agent_worktree() { + local AGENT_ID=$1 + local BASE_BRANCH=${2:-$(git branch --show-current)} + local TASK_SLUG=${3:-""} + + # Build directory name with optional task slug + if [ -n "$TASK_SLUG" ]; then + local WORKTREE_DIR="worktrees/agent-${AGENT_ID}-${TASK_SLUG}" + else + local WORKTREE_DIR="worktrees/agent-${AGENT_ID}" + fi + + local BRANCH_NAME="agent/agent-${AGENT_ID}" + + # Create worktrees directory if needed + mkdir -p worktrees + + # Create worktree with new branch (redirect git output to stderr) + git worktree add -b "$BRANCH_NAME" "$WORKTREE_DIR" "$BASE_BRANCH" >&2 + + # Echo only the directory path to stdout + echo "$WORKTREE_DIR" +} + +# Remove agent worktree +cleanup_agent_worktree() { + local AGENT_ID=$1 + local FORCE=${2:-false} + + # Find worktree directory (may have task slug suffix) + local WORKTREE_DIR=$(find worktrees -type d -name "agent-${AGENT_ID}*" 2>/dev/null | head -1) + local BRANCH_NAME="agent/agent-${AGENT_ID}" + + if [ -z "$WORKTREE_DIR" ] || [ ! -d "$WORKTREE_DIR" ]; then + echo "❌ Worktree not found for agent: $AGENT_ID" + return 1 + fi + + # Check for uncommitted changes + if ! git -C "$WORKTREE_DIR" diff --quiet 2>/dev/null; then + if [ "$FORCE" = false ]; then + echo "⚠️ Worktree has uncommitted changes. Use --force to remove anyway." + return 1 + fi + fi + + # Remove worktree + git worktree remove "$WORKTREE_DIR" $( [ "$FORCE" = true ] && echo "--force" ) + + # Delete branch (only if merged or forced) + git branch -d "$BRANCH_NAME" 2>/dev/null || \ + ( [ "$FORCE" = true ] && git branch -D "$BRANCH_NAME" ) +} + +# List all agent worktrees +list_agent_worktrees() { + git worktree list | grep "worktrees/agent-" || echo "No agent worktrees found" +} + +# Merge agent work into current branch +merge_agent_work() { + local AGENT_ID=$1 + local BRANCH_NAME="agent/agent-${AGENT_ID}" + + if ! git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then + echo "❌ Branch not found: $BRANCH_NAME" + return 1 + fi + + git merge "$BRANCH_NAME" +} + +# Check if worktree exists +worktree_exists() { + local AGENT_ID=$1 + local WORKTREE_DIR=$(find worktrees -type d -name "agent-${AGENT_ID}*" 2>/dev/null | head -1) + + [ -n "$WORKTREE_DIR" ] && [ -d "$WORKTREE_DIR" ] +} + +# Main CLI (only run if executed directly, not sourced) +if [ "${BASH_SOURCE[0]:-}" = "${0:-}" ]; then + case "${1:-help}" in + create) + create_agent_worktree "$2" "${3:-}" "${4:-}" + ;; + cleanup) + cleanup_agent_worktree "$2" "${3:-false}" + ;; + list) + list_agent_worktrees + ;; + merge) + merge_agent_work "$2" + ;; + exists) + worktree_exists "$2" + ;; + *) + echo "Usage: git-worktree-utils.sh {create|cleanup|list|merge|exists} [args]" + exit 1 + ;; + esac +fi diff --git a/claude-code-4.5/utils/orchestrator-agent.sh b/claude-code-4.5/utils/orchestrator-agent.sh new file mode 100755 index 0000000..4724dac --- /dev/null +++ b/claude-code-4.5/utils/orchestrator-agent.sh @@ -0,0 +1,120 @@ +#!/bin/bash + +# Agent Lifecycle Management Utility +# Handles agent spawning, status detection, and termination + +set -euo pipefail + +# Source the spawn-agent logic +SPAWN_AGENT_CMD="${HOME}/.claude/commands/spawn-agent.md" + +# detect_agent_status +# Detects agent status from tmux output +detect_agent_status() { + local tmux_session="$1" + + if ! tmux has-session -t "$tmux_session" 2>/dev/null; then + echo "killed" + return 0 + fi + + local output=$(tmux capture-pane -t "$tmux_session" -p -S -100 2>/dev/null || echo "") + + # Check for completion indicators + if echo "$output" | grep -qiE "complete|done|finished|✅.*complete"; then + if echo "$output" | grep -qE "git.*commit|Commit.*created"; then + echo "complete" + return 0 + fi + fi + + # Check for failure indicators + if echo "$output" | grep -qiE "error|failed|❌|fatal"; then + echo "failed" + return 0 + fi + + # Check for idle (no recent activity) + local last_line=$(echo "$output" | tail -1) + if echo "$last_line" | grep -qE "^>|^│|^─|Style:|bypass permissions"; then + echo "idle" + return 0 + fi + + # Active by default + echo "active" +} + +# check_idle_timeout +# Checks if agent has been idle too long +check_idle_timeout() { + local session_id="$1" + local agent_id="$2" + local timeout_minutes="$3" + + # Get agent's last_updated timestamp + local last_updated=$(~/.claude/utils/orchestrator-state.sh get-agent "$session_id" "$agent_id" | jq -r '.last_updated // empty') + + if [ -z "$last_updated" ]; then + echo "false" + return 0 + fi + + local now=$(date +%s) + local last=$(date -j -f "%Y-%m-%dT%H:%M:%S" "${last_updated:0:19}" +%s 2>/dev/null || echo "$now") + local diff=$(( (now - last) / 60 )) + + if [ "$diff" -gt "$timeout_minutes" ]; then + echo "true" + else + echo "false" + fi +} + +# kill_agent +# Kills an agent tmux session +kill_agent() { + local tmux_session="$1" + + if tmux has-session -t "$tmux_session" 2>/dev/null; then + tmux kill-session -t "$tmux_session" + echo "Killed agent session: $tmux_session" + fi +} + +# extract_cost_from_tmux +# Extracts cost from Claude status bar in tmux +extract_cost_from_tmux() { + local tmux_session="$1" + + local output=$(tmux capture-pane -t "$tmux_session" -p -S -50 2>/dev/null || echo "") + + # Look for "Cost: $X.XX" pattern + local cost=$(echo "$output" | grep -oE 'Cost:\s*\$[0-9]+\.[0-9]{2}' | tail -1 | grep -oE '[0-9]+\.[0-9]{2}') + + echo "${cost:-0.00}" +} + +case "${1:-}" in + detect-status) + detect_agent_status "$2" + ;; + check-idle) + check_idle_timeout "$2" "$3" "$4" + ;; + kill) + kill_agent "$2" + ;; + extract-cost) + extract_cost_from_tmux "$2" + ;; + *) + echo "Usage: orchestrator-agent.sh [args...]" + echo "Commands:" + echo " detect-status " + echo " check-idle " + echo " kill " + echo " extract-cost " + exit 1 + ;; +esac diff --git a/claude-code-4.5/utils/orchestrator-dag.sh b/claude-code-4.5/utils/orchestrator-dag.sh new file mode 100755 index 0000000..b0b9c03 --- /dev/null +++ b/claude-code-4.5/utils/orchestrator-dag.sh @@ -0,0 +1,125 @@ +#!/bin/bash + +# DAG (Directed Acyclic Graph) Utility +# Handles dependency resolution and wave calculation + +set -euo pipefail + +STATE_DIR="${HOME}/.claude/orchestration/state" + +# topological_sort +# Returns nodes in topological order (waves) +topological_sort() { + local dag_file="$1" + + # Extract nodes and edges + local nodes=$(jq -r '.nodes | keys[]' "$dag_file") + local edges=$(jq -r '.edges' "$dag_file") + + # Calculate in-degree for each node + declare -A indegree + for node in $nodes; do + local deps=$(jq -r --arg n "$node" '.edges[] | select(.to == $n) | .from' "$dag_file" | wc -l) + indegree[$node]=$deps + done + + # Topological sort using Kahn's algorithm + local wave=1 + local result="" + + while [ ${#indegree[@]} -gt 0 ]; do + local wave_nodes="" + + # Find all nodes with indegree 0 + for node in "${!indegree[@]}"; do + if [ "${indegree[$node]}" -eq 0 ]; then + wave_nodes="$wave_nodes $node" + fi + done + + if [ -z "$wave_nodes" ]; then + echo "Error: Cycle detected in DAG" >&2 + return 1 + fi + + # Output wave + echo "$wave:$wave_nodes" + + # Remove processed nodes and update indegrees + for node in $wave_nodes; do + unset indegree[$node] + + # Decrease indegree for dependent nodes + local dependents=$(jq -r --arg n "$node" '.edges[] | select(.from == $n) | .to' "$dag_file") + for dep in $dependents; do + if [ -n "${indegree[$dep]:-}" ]; then + indegree[$dep]=$((indegree[$dep] - 1)) + fi + done + done + + ((wave++)) + done +} + +# check_dependencies +# Checks if all dependencies for a node are satisfied +check_dependencies() { + local dag_file="$1" + local node_id="$2" + + local deps=$(jq -r --arg n "$node_id" '.edges[] | select(.to == $n) | .from' "$dag_file") + + if [ -z "$deps" ]; then + echo "true" + return 0 + fi + + # Check if all dependencies are complete + for dep in $deps; do + local status=$(jq -r --arg n "$dep" '.nodes[$n].status' "$dag_file") + if [ "$status" != "complete" ]; then + echo "false" + return 1 + fi + done + + echo "true" +} + +# get_next_wave +# Gets the next wave of nodes ready to execute +get_next_wave() { + local dag_file="$1" + + local nodes=$(jq -r '.nodes | to_entries[] | select(.value.status == "pending") | .key' "$dag_file") + + local wave_nodes="" + for node in $nodes; do + if [ "$(check_dependencies "$dag_file" "$node")" = "true" ]; then + wave_nodes="$wave_nodes $node" + fi + done + + echo "$wave_nodes" | tr -s ' ' +} + +case "${1:-}" in + topo-sort) + topological_sort "$2" + ;; + check-deps) + check_dependencies "$2" "$3" + ;; + next-wave) + get_next_wave "$2" + ;; + *) + echo "Usage: orchestrator-dag.sh [args...]" + echo "Commands:" + echo " topo-sort " + echo " check-deps " + echo " next-wave " + exit 1 + ;; +esac diff --git a/claude-code-4.5/utils/orchestrator-state.sh b/claude-code-4.5/utils/orchestrator-state.sh new file mode 100755 index 0000000..40b9a57 --- /dev/null +++ b/claude-code-4.5/utils/orchestrator-state.sh @@ -0,0 +1,431 @@ +#!/bin/bash + +# Orchestrator State Management Utility +# Manages sessions.json, completed.json, and DAG state files + +set -euo pipefail + +# Paths +STATE_DIR="${HOME}/.claude/orchestration/state" +SESSIONS_FILE="${STATE_DIR}/sessions.json" +COMPLETED_FILE="${STATE_DIR}/completed.json" +CONFIG_FILE="${STATE_DIR}/config.json" + +# Ensure jq is available +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed. Install with: brew install jq" + exit 1 +fi + +# ============================================================================ +# Session Management Functions +# ============================================================================ + +# create_session [config_json] +# Creates a new orchestration session +create_session() { + local session_id="$1" + local tmux_session="$2" + local custom_config="${3:-{}}" + + # Load default config + local default_config=$(jq -r '.orchestrator' "$CONFIG_FILE") + + # Merge custom config with defaults + local merged_config=$(echo "$default_config" | jq ". + $custom_config") + + # Create session object + local session=$(cat < "$SESSIONS_FILE" + + echo "$session_id" +} + +# get_session +# Retrieves a session by ID +get_session() { + local session_id="$1" + jq -r ".active_sessions[] | select(.session_id == \"$session_id\")" "$SESSIONS_FILE" +} + +# update_session +# Updates a session with new data (merges) +update_session() { + local session_id="$1" + local update="$2" + + local updated=$(jq \ + --arg id "$session_id" \ + --argjson upd "$update" \ + '(.active_sessions[] | select(.session_id == $id)) |= (. + $upd) | .last_updated = "'$(date -Iseconds)'"' \ + "$SESSIONS_FILE") + + echo "$updated" > "$SESSIONS_FILE" +} + +# update_session_status +# Updates session status +update_session_status() { + local session_id="$1" + local status="$2" + + update_session "$session_id" "{\"status\": \"$status\"}" +} + +# archive_session +# Moves session from active to completed +archive_session() { + local session_id="$1" + + # Get session data + local session=$(get_session "$session_id") + + if [ -z "$session" ]; then + echo "Error: Session $session_id not found" + return 1 + fi + + # Mark as complete with end time + local completed_session=$(echo "$session" | jq ". + {\"completed_at\": \"$(date -Iseconds)\"}") + + # Add to completed sessions + local updated_completed=$(jq ".completed_sessions += [$completed_session] | .last_updated = \"$(date -Iseconds)\"" "$COMPLETED_FILE") + echo "$updated_completed" > "$COMPLETED_FILE" + + # Update totals + local total_cost=$(echo "$completed_session" | jq -r '.total_cost_usd') + local updated_totals=$(jq \ + --arg cost "$total_cost" \ + '.total_cost_usd += ($cost | tonumber) | .total_agents_spawned += 1' \ + "$COMPLETED_FILE") + echo "$updated_totals" > "$COMPLETED_FILE" + + # Remove from active sessions + local updated_active=$(jq \ + --arg id "$session_id" \ + '.active_sessions = [.active_sessions[] | select(.session_id != $id)] | .last_updated = "'$(date -Iseconds)'"' \ + "$SESSIONS_FILE") + echo "$updated_active" > "$SESSIONS_FILE" + + echo "Session $session_id archived" +} + +# list_active_sessions +# Lists all active sessions +list_active_sessions() { + jq -r '.active_sessions[] | .session_id' "$SESSIONS_FILE" +} + +# ============================================================================ +# Agent Management Functions +# ============================================================================ + +# add_agent +# Adds an agent to a session +add_agent() { + local session_id="$1" + local agent_id="$2" + local agent_config="$3" + + local updated=$(jq \ + --arg sid "$session_id" \ + --arg aid "$agent_id" \ + --argjson cfg "$agent_config" \ + '(.active_sessions[] | select(.session_id == $sid).agents[$aid]) = $cfg | .last_updated = "'$(date -Iseconds)'"' \ + "$SESSIONS_FILE") + + echo "$updated" > "$SESSIONS_FILE" +} + +# update_agent_status +# Updates an agent's status +update_agent_status() { + local session_id="$1" + local agent_id="$2" + local status="$3" + + local updated=$(jq \ + --arg sid "$session_id" \ + --arg aid "$agent_id" \ + --arg st "$status" \ + '(.active_sessions[] | select(.session_id == $sid).agents[$aid].status) = $st | + (.active_sessions[] | select(.session_id == $sid).agents[$aid].last_updated) = "'$(date -Iseconds)'" | + .last_updated = "'$(date -Iseconds)'"' \ + "$SESSIONS_FILE") + + echo "$updated" > "$SESSIONS_FILE" +} + +# update_agent_cost +# Updates an agent's cost +update_agent_cost() { + local session_id="$1" + local agent_id="$2" + local cost_usd="$3" + + local updated=$(jq \ + --arg sid "$session_id" \ + --arg aid "$agent_id" \ + --arg cost "$cost_usd" \ + '(.active_sessions[] | select(.session_id == $sid).agents[$aid].cost_usd) = ($cost | tonumber) | + .last_updated = "'$(date -Iseconds)'"' \ + "$SESSIONS_FILE") + + echo "$updated" > "$SESSIONS_FILE" + + # Update session total cost + update_session_total_cost "$session_id" +} + +# update_session_total_cost +# Recalculates and updates session total cost +update_session_total_cost() { + local session_id="$1" + + local total=$(jq -r \ + --arg sid "$session_id" \ + '(.active_sessions[] | select(.session_id == $sid).agents | to_entries | map(.value.cost_usd // 0) | add) // 0' \ + "$SESSIONS_FILE") + + update_session "$session_id" "{\"total_cost_usd\": $total}" +} + +# get_agent +# Gets agent data +get_agent() { + local session_id="$1" + local agent_id="$2" + + jq -r \ + --arg sid "$session_id" \ + --arg aid "$agent_id" \ + '.active_sessions[] | select(.session_id == $sid).agents[$aid]' \ + "$SESSIONS_FILE" +} + +# list_agents +# Lists all agents in a session +list_agents() { + local session_id="$1" + + jq -r \ + --arg sid "$session_id" \ + '.active_sessions[] | select(.session_id == $sid).agents | keys[]' \ + "$SESSIONS_FILE" +} + +# ============================================================================ +# Wave Management Functions +# ============================================================================ + +# add_wave +# Adds a wave to the session +add_wave() { + local session_id="$1" + local wave_number="$2" + local agent_ids="$3" # JSON array like '["agent-1", "agent-2"]' + + local wave=$(cat < "$SESSIONS_FILE" +} + +# update_wave_status +# Updates wave status +update_wave_status() { + local session_id="$1" + local wave_number="$2" + local status="$3" + + local timestamp_field="" + if [ "$status" = "active" ]; then + timestamp_field="started_at" + elif [ "$status" = "complete" ] || [ "$status" = "failed" ]; then + timestamp_field="completed_at" + fi + + local jq_filter='(.active_sessions[] | select(.session_id == $sid).waves[] | select(.wave_number == ($wn | tonumber)).status) = $st' + + if [ -n "$timestamp_field" ]; then + jq_filter="$jq_filter | (.active_sessions[] | select(.session_id == \$sid).waves[] | select(.wave_number == (\$wn | tonumber)).$timestamp_field) = \"$(date -Iseconds)\"" + fi + + jq_filter="$jq_filter | .last_updated = \"$(date -Iseconds)\"" + + local updated=$(jq \ + --arg sid "$session_id" \ + --arg wn "$wave_number" \ + --arg st "$status" \ + "$jq_filter" \ + "$SESSIONS_FILE") + + echo "$updated" > "$SESSIONS_FILE" +} + +# get_current_wave +# Gets the current active or next pending wave number +get_current_wave() { + local session_id="$1" + + # First check for active waves + local active_wave=$(jq -r \ + --arg sid "$session_id" \ + '.active_sessions[] | select(.session_id == $sid).waves[] | select(.status == "active") | .wave_number' \ + "$SESSIONS_FILE" | head -1) + + if [ -n "$active_wave" ]; then + echo "$active_wave" + return + fi + + # Otherwise get first pending wave + local pending_wave=$(jq -r \ + --arg sid "$session_id" \ + '.active_sessions[] | select(.session_id == $sid).waves[] | select(.status == "pending") | .wave_number' \ + "$SESSIONS_FILE" | head -1) + + echo "${pending_wave:-0}" +} + +# ============================================================================ +# Utility Functions +# ============================================================================ + +# check_budget_limit +# Checks if session is within budget limits +check_budget_limit() { + local session_id="$1" + + local max_budget=$(jq -r '.resource_limits.max_budget_usd' "$CONFIG_FILE") + local warn_percent=$(jq -r '.resource_limits.warn_at_percent' "$CONFIG_FILE") + local stop_percent=$(jq -r '.resource_limits.hard_stop_at_percent' "$CONFIG_FILE") + + local current_cost=$(jq -r \ + --arg sid "$session_id" \ + '.active_sessions[] | select(.session_id == $sid).total_cost_usd' \ + "$SESSIONS_FILE") + + local percent=$(echo "scale=2; ($current_cost / $max_budget) * 100" | bc) + + if (( $(echo "$percent >= $stop_percent" | bc -l) )); then + echo "STOP" + return 1 + elif (( $(echo "$percent >= $warn_percent" | bc -l) )); then + echo "WARN" + return 0 + else + echo "OK" + return 0 + fi +} + +# pretty_print_session +# Pretty prints a session +pretty_print_session() { + local session_id="$1" + get_session "$session_id" | jq '.' +} + +# ============================================================================ +# Main CLI Interface +# ============================================================================ + +case "${1:-}" in + create) + create_session "$2" "$3" "${4:-{}}" + ;; + get) + get_session "$2" + ;; + update) + update_session "$2" "$3" + ;; + archive) + archive_session "$2" + ;; + list) + list_active_sessions + ;; + add-agent) + add_agent "$2" "$3" "$4" + ;; + update-agent-status) + update_agent_status "$2" "$3" "$4" + ;; + update-agent-cost) + update_agent_cost "$2" "$3" "$4" + ;; + get-agent) + get_agent "$2" "$3" + ;; + list-agents) + list_agents "$2" + ;; + add-wave) + add_wave "$2" "$3" "$4" + ;; + update-wave-status) + update_wave_status "$2" "$3" "$4" + ;; + get-current-wave) + get_current_wave "$2" + ;; + check-budget) + check_budget_limit "$2" + ;; + print) + pretty_print_session "$2" + ;; + *) + echo "Usage: orchestrator-state.sh [args...]" + echo "" + echo "Commands:" + echo " create [config_json]" + echo " get " + echo " update " + echo " archive " + echo " list" + echo " add-agent " + echo " update-agent-status " + echo " update-agent-cost " + echo " get-agent " + echo " list-agents " + echo " add-wave " + echo " update-wave-status " + echo " get-current-wave " + echo " check-budget " + echo " print " + exit 1 + ;; +esac diff --git a/claude-code/CLAUDE.md b/claude-code/CLAUDE.md index c30d6d1..b9ee44e 100644 --- a/claude-code/CLAUDE.md +++ b/claude-code/CLAUDE.md @@ -847,11 +847,12 @@ const applyDiscount = (price: number, discountRate: number): number => { # Background Process Management -CRITICAL: When starting any long-running server process (web servers, development servers, APIs, etc.), you MUST: +CRITICAL: When starting any long-running server process (web servers, development servers, APIs, etc.), you MUST use tmux for persistence and management: -1. **Always Run in Background** +1. **Always Run in tmux Sessions** - NEVER run servers in foreground as this will block the agent process indefinitely - - Use background execution (`&` or `nohup`) or container-use background mode + - ALWAYS use tmux for background execution (provides persistence across disconnects) + - Fallback to container-use background mode if tmux unavailable - Examples of foreground-blocking commands: - `npm run dev` or `npm start` - `python app.py` or `flask run` @@ -863,96 +864,164 @@ CRITICAL: When starting any long-running server process (web servers, developmen - ALWAYS use random/dynamic ports to avoid conflicts between parallel sessions - Generate random port: `PORT=$(shuf -i 3000-9999 -n 1)` - Pass port via environment variable or command line argument - - Document the assigned port in logs for reference + - Document the assigned port in session metadata -3. **Mandatory Log Redirection** - - Redirect all output to log files: `command > app.log 2>&1 &` - - Use descriptive log names: `server.log`, `api.log`, `dev-server.log` - - Include port in log name when possible: `server-${PORT}.log` - - Capture both stdout and stderr for complete debugging information +3. **tmux Session Naming Convention** + - Dev environments: `dev-{project}-{timestamp}` + - Spawned agents: `agent-{timestamp}` + - Monitoring: `monitor-{purpose}` + - Examples: `dev-myapp-1705161234`, `agent-1705161234` -4. **Container-use Background Mode** - - When using container-use, ALWAYS set `background: true` for server commands - - Use `ports` parameter to expose the randomly assigned port - - Example: `mcp__container-use__environment_run_cmd` with `background: true, ports: [PORT]` +4. **Session Metadata** + - Save session info to `.tmux-dev-session.json` (per project) + - Include: session name, ports, services, created timestamp + - Use metadata for session discovery and conflict detection -5. **Log Monitoring** - - After starting background process, immediately check logs with `tail -f logfile.log` - - Use `cat logfile.log` to view full log contents - - Monitor startup messages to ensure server started successfully - - Look for port assignment confirmation in logs +5. **Log Capture** + - Use `| tee logfile.log` to capture output to both tmux and file + - Use descriptive log names: `server.log`, `api.log`, `dev-server.log` + - Include port in log name when possible: `server-${PORT}.log` + - Logs visible in tmux pane AND saved to disk 6. **Safe Process Management** - - NEVER kill by process name (`pkill node`, `pkill vite`, `pkill uv`) - this affects other parallel sessions + - NEVER kill by process name (`pkill node`, `pkill vite`, `pkill uv`) - affects other sessions - ALWAYS kill by port to target specific server: `lsof -ti:${PORT} | xargs kill -9` - - Alternative port-based killing: `fuser -k ${PORT}/tcp` - - Check what's running on port before killing: `lsof -i :${PORT}` - - Clean up port-specific processes before starting new servers on same port + - Alternative: Kill entire tmux session: `tmux kill-session -t {session-name}` + - Check what's running on port: `lsof -i :${PORT}` **Examples:** ```bash -# ❌ WRONG - Will block forever and use default port +# ❌ WRONG - Will block forever npm run dev # ❌ WRONG - Killing by process name affects other sessions pkill node -# ✅ CORRECT - Complete workflow with random port +# ❌ DEPRECATED - Using & background jobs (no persistence) PORT=$(shuf -i 3000-9999 -n 1) -echo "Starting server on port $PORT" PORT=$PORT npm run dev > dev-server-${PORT}.log 2>&1 & -tail -f dev-server-${PORT}.log + +# ✅ CORRECT - Complete tmux workflow with random port +PORT=$(shuf -i 3000-9999 -n 1) +SESSION="dev-$(basename $(pwd))-$(date +%s)" + +# Create tmux session +tmux new-session -d -s "$SESSION" -n dev-server + +# Start server in tmux with log capture +tmux send-keys -t "$SESSION:dev-server" "PORT=$PORT npm run dev | tee dev-server-${PORT}.log" C-m + +# Save metadata +cat > .tmux-dev-session.json </dev/null && echo "Session running" -# ✅ CORRECT - Container-use with random port +# ✅ CORRECT - Attach to monitor logs +tmux attach -t "$SESSION" + +# ✅ CORRECT - Flask/Python in tmux +PORT=$(shuf -i 5000-5999 -n 1) +SESSION="dev-flask-$(date +%s)" +tmux new-session -d -s "$SESSION" -n server +tmux send-keys -t "$SESSION:server" "FLASK_RUN_PORT=$PORT flask run | tee flask-${PORT}.log" C-m + +# ✅ CORRECT - Next.js in tmux +PORT=$(shuf -i 3000-3999 -n 1) +SESSION="dev-nextjs-$(date +%s)" +tmux new-session -d -s "$SESSION" -n server +tmux send-keys -t "$SESSION:server" "PORT=$PORT npm run dev | tee nextjs-${PORT}.log" C-m +``` + +**Fallback: Container-use Background Mode** (when tmux unavailable): +```bash +# Only use if tmux is not available mcp__container-use__environment_run_cmd with: command: "PORT=${PORT} npm run dev" background: true ports: [PORT] - -# ✅ CORRECT - Flask/Python example -PORT=$(shuf -i 3000-9999 -n 1) -FLASK_RUN_PORT=$PORT python app.py > flask-${PORT}.log 2>&1 & - -# ✅ CORRECT - Next.js example -PORT=$(shuf -i 3000-9999 -n 1) -PORT=$PORT npm run dev > nextjs-${PORT}.log 2>&1 & ``` -**Playwright Testing Background Execution:** +**Playwright Testing in tmux:** -- **ALWAYS run Playwright tests in background** to prevent agent blocking -- **NEVER open test report servers** - they will block agent execution indefinitely -- Use `--reporter=json` and `--reporter=line` for programmatic result parsing -- Redirect all output to log files for later analysis +- **Run Playwright tests in tmux** for persistence and log monitoring +- **NEVER open test report servers** - they block agent execution +- Use `--reporter=json` and `--reporter=line` for programmatic parsing - Examples: ```bash -# ✅ CORRECT - Background Playwright execution -npx playwright test --reporter=json > playwright-results.log 2>&1 & +# ✅ CORRECT - Playwright in tmux session +SESSION="test-playwright-$(date +%s)" +tmux new-session -d -s "$SESSION" -n tests +tmux send-keys -t "$SESSION:tests" "npx playwright test --reporter=json | tee playwright-results.log" C-m -# ✅ CORRECT - Custom config with background execution -npx playwright test --config=custom.config.js --reporter=line > test-output.log 2>&1 & +# Monitor progress +tmux attach -t "$SESSION" + +# ❌ DEPRECATED - Background job (no persistence) +npx playwright test --reporter=json > playwright-results.log 2>&1 & # ❌ WRONG - Will block agent indefinitely npx playwright test --reporter=html npx playwright show-report # ✅ CORRECT - Parse results programmatically -cat playwright-results.json | jq '.stats' -tail -20 test-output.log +cat playwright-results.log | jq '.stats' ``` +**Using Generic /start-* Commands:** + +For common development scenarios, use the generic commands: + +```bash +# Start local web development (auto-detects framework) +/start-local development # Uses .env.development +/start-local staging # Uses .env.staging +/start-local production # Uses .env.production + +# Start iOS development (auto-detects project type) +/start-ios Debug # Uses .env.development +/start-ios Staging # Uses .env.staging +/start-ios Release # Uses .env.production + +# Start Android development (auto-detects project type) +/start-android debug # Uses .env.development +/start-android staging # Uses .env.staging +/start-android release # Uses .env.production +``` -RATIONALE: Background execution with random ports prevents agent process deadlock while enabling parallel sessions to coexist without interference. Port-based process management ensures safe cleanup without affecting other concurrent development sessions. This maintains full visibility into server status through logs while ensuring continuous agent operation. +These commands automatically: +- Create organized tmux sessions +- Assign random ports +- Start all required services +- Save session metadata +- Setup log monitoring + +**Session Persistence Benefits:** +- Survives SSH disconnects +- Survives terminal restarts +- Easy reattachment: `tmux attach -t {session-name}` +- Live log monitoring in split panes +- Organized multi-window layouts + +RATIONALE: tmux provides persistence across disconnects, better visibility through split panes, and session organization. Random ports prevent conflicts between parallel sessions. Port-based or session-based process management ensures safe cleanup. Generic /start-* commands provide consistent, framework-agnostic development environments. # GitHub Issue Management diff --git a/claude-code/commands/attach-agent-worktree.md b/claude-code/commands/attach-agent-worktree.md new file mode 100644 index 0000000..a141096 --- /dev/null +++ b/claude-code/commands/attach-agent-worktree.md @@ -0,0 +1,46 @@ +# /attach-agent-worktree - Attach to Agent Session + +Changes to agent worktree directory and attaches to its tmux session. + +## Usage + +```bash +/attach-agent-worktree {timestamp} +``` + +## Implementation + +```bash +#!/bin/bash + +AGENT_ID="$1" + +if [ -z "$AGENT_ID" ]; then + echo "❌ Agent ID required" + echo "Usage: /attach-agent-worktree {timestamp}" + exit 1 +fi + +# Find worktree directory +WORKTREE_DIR=$(find worktrees -type d -name "agent-${AGENT_ID}*" 2>/dev/null | head -1) + +if [ -z "$WORKTREE_DIR" ] || [ ! -d "$WORKTREE_DIR" ]; then + echo "❌ Worktree not found for agent: $AGENT_ID" + exit 1 +fi + +SESSION="agent-${AGENT_ID}" + +# Check if tmux session exists +if ! tmux has-session -t "$SESSION" 2>/dev/null; then + echo "❌ Tmux session not found: $SESSION" + exit 1 +fi + +echo "📂 Worktree: $WORKTREE_DIR" +echo "🔗 Attaching to session: $SESSION" +echo "" + +# Attach to session +tmux attach -t "$SESSION" +``` diff --git a/claude-code/commands/cleanup-agent-worktree.md b/claude-code/commands/cleanup-agent-worktree.md new file mode 100644 index 0000000..12f7ebb --- /dev/null +++ b/claude-code/commands/cleanup-agent-worktree.md @@ -0,0 +1,36 @@ +# /cleanup-agent-worktree - Remove Agent Worktree + +Removes a specific agent worktree and its branch. + +## Usage + +```bash +/cleanup-agent-worktree {timestamp} +/cleanup-agent-worktree {timestamp} --force +``` + +## Implementation + +```bash +#!/bin/bash + +AGENT_ID="$1" +FORCE="$2" + +if [ -z "$AGENT_ID" ]; then + echo "❌ Agent ID required" + echo "Usage: /cleanup-agent-worktree {timestamp} [--force]" + exit 1 +fi + +# Source utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../utils/git-worktree-utils.sh" + +# Cleanup worktree +if [ "$FORCE" = "--force" ]; then + cleanup_agent_worktree "$AGENT_ID" true +else + cleanup_agent_worktree "$AGENT_ID" false +fi +``` diff --git a/claude-code/commands/handover.md b/claude-code/commands/handover.md index e57f076..36a9e37 100644 --- a/claude-code/commands/handover.md +++ b/claude-code/commands/handover.md @@ -1,59 +1,187 @@ -# Handover Command +# /handover - Generate Session Handover Document -Use this command to generate a session handover document when transferring work to another team member or continuing work in a new session. +Generate a handover document for transferring work to another developer or spawning an async agent. ## Usage -``` -/handover [optional-notes] +```bash +/handover # Standard handover +/handover "notes about current work" # With notes +/handover --agent-spawn "task desc" # For spawning agent ``` -## Description +## Modes -This command generates a comprehensive handover document that includes: +### Standard Handover (default) -- Current session health status +For transferring work to another human or resuming later: +- Current session health - Task progress and todos -- Technical context and working files -- Instructions for resuming work -- Any blockers or important notes +- Technical context +- Resumption instructions -## Example +### Agent Spawn Mode (`--agent-spawn`) +For passing context to spawned agents: +- Focused on task context +- Technical stack details +- Success criteria +- Files to modify + +## Implementation + +### Detect Mode + +```bash +MODE="standard" +AGENT_TASK="" +NOTES="${1:-}" + +if [[ "$1" == "--agent-spawn" ]]; then + MODE="agent" + AGENT_TASK="${2:-}" + shift 2 +fi ``` -/handover Working on authentication refactor, need to complete OAuth integration + +### Generate Timestamp + +```bash +TIMESTAMP=$(date +"%Y-%m-%d-%H-%M-%S") +DISPLAY_TIME=$(date +"%Y-%m-%d %H:%M:%S") +FILENAME="handover-${TIMESTAMP}.md" +PRIMARY_LOCATION="${TOOL_DIR}/session/${FILENAME}" +BACKUP_LOCATION="./${FILENAME}" + +mkdir -p "${TOOL_DIR}/session" ``` -## Output Location +### Standard Handover Content + +```markdown +# Handover Document + +**Generated**: ${DISPLAY_TIME} +**Session**: $(tmux display-message -p '#S' 2>/dev/null || echo 'unknown') + +## Current Work + +[Describe what you're working on] + +## Task Progress + +[List todos and completion status] + +## Technical Context + +**Current Branch**: $(git branch --show-current) +**Last Commit**: $(git log -1 --oneline) +**Modified Files**: +$(git status --short) + +## Resumption Instructions + +1. Review changes: git diff +2. Continue work on [specific task] +3. Test with: [test command] + +## Notes + +${NOTES} +``` + +### Agent Spawn Handover Content + +```markdown +# Agent Handover - ${AGENT_TASK} + +**Generated**: ${DISPLAY_TIME} +**Parent Session**: $(tmux display-message -p '#S' 2>/dev/null || echo 'unknown') +**Agent Task**: ${AGENT_TASK} + +## Context Summary + +**Current Work**: [What's in progress] +**Current Branch**: $(git branch --show-current) +**Last Commit**: $(git log -1 --oneline) + +## Task Details + +**Agent Mission**: ${AGENT_TASK} + +**Requirements**: +- [List specific requirements] +- [What needs to be done] + +**Success Criteria**: +- [How to know when done] + +## Technical Context + +**Stack**: [Technology stack] +**Key Files**: +$(git status --short) + +**Modified Recently**: +$(git log --name-only -5 --oneline) -The handover document MUST be saved to: -- **Primary Location**: `.{{TOOL_DIR}}/session/handover-{{TIMESTAMP}}.md` -- **Backup Location**: `./handover-{{TIMESTAMP}}.md` (project root) +## Instructions for Agent -## File Naming Convention +1. Review current implementation +2. Make specified changes +3. Add/update tests +4. Verify all tests pass +5. Commit with clear message -Use this format: `handover-YYYY-MM-DD-HH-MM-SS.md` +## References -Example: `handover-2024-01-15-14-30-45.md` +**Documentation**: [Links to relevant docs] +**Related Work**: [Related PRs/issues] +``` + +### Save Document -**CRITICAL**: Always obtain the timestamp programmatically: ```bash -# Generate timestamp - NEVER type dates manually -TIMESTAMP=$(date +"%Y-%m-%d-%H-%M-%S") -FILENAME="handover-${TIMESTAMP}.md" +# Generate appropriate content based on MODE +if [ "$MODE" = "agent" ]; then + # Generate agent handover content + CONTENT="[Agent handover content from above]" +else + # Generate standard handover content + CONTENT="[Standard handover content from above]" +fi + +# Save to primary location +echo "$CONTENT" > "$PRIMARY_LOCATION" + +# Save backup +echo "$CONTENT" > "$BACKUP_LOCATION" + +echo "✅ Handover document generated" +echo "" +echo "Primary: $PRIMARY_LOCATION" +echo "Backup: $BACKUP_LOCATION" +echo "" ``` -## Implementation +## Output Location + +**Primary**: `${TOOL_DIR}/session/handover-{timestamp}.md` +**Backup**: `./handover-{timestamp}.md` + +## Integration with spawn-agent + +The `/spawn-agent` command automatically calls `/handover --agent-spawn` when `--with-handover` flag is used: + +```bash +/spawn-agent codex "refactor auth" --with-handover +# Internally calls: /handover --agent-spawn "refactor auth" +# Copies handover to agent worktree as .agent-handover.md +``` + +## Notes -1. **ALWAYS** get the current timestamp using `date` command: - ```bash - date +"%Y-%m-%d %H:%M:%S" # For document header - date +"%Y-%m-%d-%H-%M-%S" # For filename - ``` -2. Generate handover using `{{HOME_TOOL_DIR}}/templates/handover-template.md` -3. Replace all `{{VARIABLE}}` placeholders with actual values -4. Save to BOTH locations (primary and backup) -5. Display the full file path to the user for reference -6. Verify the date in the filename matches the date in the document header - -The handover document will be saved as a markdown file and can be used to seamlessly continue work in a new session. \ No newline at end of file +- Always uses programmatic timestamps (never manual) +- Saves to both primary and backup locations +- Agent mode focuses on task context, not session health +- Standard mode includes full session state diff --git a/claude-code/commands/list-agent-worktrees.md b/claude-code/commands/list-agent-worktrees.md new file mode 100644 index 0000000..3534902 --- /dev/null +++ b/claude-code/commands/list-agent-worktrees.md @@ -0,0 +1,16 @@ +# /list-agent-worktrees - List All Agent Worktrees + +Shows all active agent worktrees with their paths and branches. + +## Usage + +```bash +/list-agent-worktrees +``` + +## Implementation + +```bash +#!/bin/bash +git worktree list | grep "worktrees/agent-" || echo "No agent worktrees found" +``` diff --git a/claude-code/commands/m-implement.md b/claude-code/commands/m-implement.md new file mode 100644 index 0000000..9fc0988 --- /dev/null +++ b/claude-code/commands/m-implement.md @@ -0,0 +1,478 @@ +--- +description: Multi-agent implementation - Execute DAG in waves with automated monitoring +tags: [orchestration, implementation, multi-agent] +--- + +# Multi-Agent Implementation (`/m-implement`) + +You are now in **multi-agent implementation mode**. Your task is to execute a pre-planned DAG by spawning agents in waves and monitoring their progress. + +## Your Role + +Act as an **orchestrator** that manages parallel agent execution, monitors progress, and handles failures. + +## Prerequisites + +1. **DAG file must exist**: `~/.claude/orchestration/state/dag-.json` +2. **Session must be created**: Via `/m-plan` or manually +3. **Git worktrees setup**: Project must support git worktrees + +## Arguments + +- ``: Required. The orchestration session ID from /m-plan +- `--isolated`: Optional. Use tmux-based agents instead of Task tool subagents +- `--resume`: Optional. Resume a paused session + +--- + +## ISOLATED MODE (`--isolated` flag) + +When the `--isolated` flag is detected in the command arguments, you MUST follow these instructions instead of the default process. + +### CRITICAL INSTRUCTIONS FOR ISOLATED MODE + +You are in **ISOLATED** execution mode. This means: + +#### DO NOT USE: +- ❌ The **Task tool** for spawning agents +- ❌ Any subagent spawning via Task tool +- ❌ Background Task invocations + +#### YOU MUST USE: +- ✅ The **agent-orchestrator** skill scripts +- ✅ Direct **bash execution** of the scripts below +- ✅ **tmux** for all agent management + +#### WHY THIS WORKS: + +The "Claude Code is interactive" problem is **SOLVED**. The spawn.sh script: +1. Uses `--dangerously-skip-permissions` to skip interactive prompts +2. Uses `wait_for_claude_ready()` to detect when Claude is initialized AND handle any prompts +3. Uses `send_task_to_claude()` for robust multi-line task sending +4. Sends tasks via `tmux send-keys -l` for proper escaping + +### EXACT COMMANDS TO RUN: + +```bash +SKILL_DIR="$HOME/.claude/skills/agent-orchestrator/scripts" +SESSION_ID="" + +# For each wave in the DAG: +bash "$SKILL_DIR/orchestration/wave-spawn.sh" "$SESSION_ID" +bash "$SKILL_DIR/orchestration/wave-monitor.sh" "$SESSION_ID" + +# After all waves complete: +bash "$SKILL_DIR/orchestration/merge-waves.sh" "$SESSION_ID" +``` + +### EXECUTION FLOW: + +1. Load DAG from `~/.claude/orchestration/state/dag-.json` +2. For each wave (1 to N): + a. Run: `bash "$SKILL_DIR/orchestration/wave-spawn.sh" "$SESSION_ID" $wave` + b. Run: `bash "$SKILL_DIR/orchestration/wave-monitor.sh" "$SESSION_ID" $wave` + c. If wave fails, STOP and report +3. After all waves complete: + a. Run: `bash "$SKILL_DIR/orchestration/merge-waves.sh" "$SESSION_ID"` +4. Report final status + +### SPAWNING INDIVIDUAL AGENTS: + +For more control, use the core spawn script directly: + +```bash +bash "$SKILL_DIR/core/spawn.sh" "$TASK" \ + --with-worktree \ + --orchestration-session "$SESSION_ID" \ + --wave "$WAVE_NUMBER" \ + --workstream "$WORKSTREAM" \ + --agent-type "$AGENT_TYPE" +``` + +### MONITORING AGENTS: + +```bash +# Check status +bash "$SKILL_DIR/orchestration/session-status.sh" "$SESSION_ID" --detailed + +# Attach to specific agent +tmux attach -t agent-- + +# View agent output without attaching +tmux capture-pane -t agent-- -p -S -50 + +# List all agent sessions +tmux ls | grep agent- +``` + +### IF SOMETHING FAILS: + +**DO NOT** fall back to Task tool. Instead: +1. Check the tmux session: `tmux ls` +2. Attach and debug: `tmux attach -t ` +3. Check logs: `cat /tmp/spawn-agent--failure.log` +4. Report the error to the user + +--- + +## DEFAULT MODE (no `--isolated` flag) + +If `--isolated` is NOT present, follow the default process below. + +## Process + +### Step 1: Load DAG and Session + +```bash +# Load DAG file +DAG_FILE="~/.claude/orchestration/state/dag-${SESSION_ID}.json" + +# Verify DAG exists +if [ ! -f "$DAG_FILE" ]; then + echo "Error: DAG file not found: $DAG_FILE" + exit 1 +fi + +# Load session +SESSION=$(~/.claude/utils/orchestrator-state.sh get "$SESSION_ID") + +if [ -z "$SESSION" ]; then + echo "Error: Session not found: $SESSION_ID" + exit 1 +fi +``` + +### Step 2: Calculate Waves + +```bash +# Get waves from DAG (already calculated in /m-plan) +WAVES=$(jq -r '.waves[] | "\(.wave_number):\(.nodes | join(" "))"' "$DAG_FILE") + +# Example output: +# 1:ws-1 ws-3 +# 2:ws-2 ws-4 +# 3:ws-5 +``` + +### Step 3: Execute Wave-by-Wave + +**For each wave:** + +```bash +WAVE_NUMBER=1 + +# Get nodes in this wave +WAVE_NODES=$(echo "$WAVES" | grep "^${WAVE_NUMBER}:" | cut -d: -f2) + +echo "🌊 Starting Wave $WAVE_NUMBER: $WAVE_NODES" + +# Update wave status +~/.claude/utils/orchestrator-state.sh update-wave-status "$SESSION_ID" "$WAVE_NUMBER" "active" + +# Spawn all agents in wave (parallel) +for node in $WAVE_NODES; do + spawn_agent "$SESSION_ID" "$node" & +done + +# Wait for all agents in wave to complete +wait + +# Check if wave completed successfully +if wave_all_complete "$SESSION_ID" "$WAVE_NUMBER"; then + ~/.claude/utils/orchestrator-state.sh update-wave-status "$SESSION_ID" "$WAVE_NUMBER" "complete" + echo "✅ Wave $WAVE_NUMBER complete" +else + echo "❌ Wave $WAVE_NUMBER failed" + exit 1 +fi +``` + +### Step 4: Spawn Agent Function + +**Function to spawn a single agent:** + +```bash +spawn_agent() { + local session_id="$1" + local node_id="$2" + + # Get node details from DAG + local node=$(jq -r --arg n "$node_id" '.nodes[$n]' "$DAG_FILE") + local task=$(echo "$node" | jq -r '.task') + local agent_type=$(echo "$node" | jq -r '.agent_type') + local workstream_id=$(echo "$node" | jq -r '.workstream_id') + + # Create git worktree + local worktree_dir="worktrees/${workstream_id}-${node_id}" + local branch="feat/${workstream_id}" + + git worktree add "$worktree_dir" -b "$branch" 2>/dev/null || git worktree add "$worktree_dir" "$branch" + + # Create tmux session + local agent_id="agent-${workstream_id}-$(date +%s)" + tmux new-session -d -s "$agent_id" -c "$worktree_dir" + + # Start Claude in tmux + tmux send-keys -t "$agent_id" "claude --dangerously-skip-permissions" C-m + + # Wait for Claude to initialize + wait_for_claude_ready "$agent_id" + + # Send task + local full_task="$task + +AGENT ROLE: Act as a ${agent_type}. + +CRITICAL REQUIREMENTS: +- Work in worktree: $worktree_dir +- Branch: $branch +- When complete: Run tests, commit with clear message, report status + +DELIVERABLES: +$(echo "$node" | jq -r '.deliverables[]' | sed 's/^/- /') + +When complete: Commit all changes and report status." + + tmux send-keys -t "$agent_id" -l "$full_task" + tmux send-keys -t "$agent_id" C-m + + # Add agent to session state + local agent_config=$(cat <15min, killing..." + ~/.claude/utils/orchestrator-agent.sh kill "$tmux_session" + ~/.claude/utils/orchestrator-state.sh update-agent-status "$session_id" "$agent_id" "killed" + fi + done + + # Check if wave is complete + if wave_all_complete "$session_id" "$wave_number"; then + return 0 + fi + + # Check if wave failed + local failed_count=$(~/.claude/utils/orchestrator-state.sh list-agents "$session_id" | \ + xargs -I {} ~/.claude/utils/orchestrator-state.sh get-agent "$session_id" {} | \ + jq -r 'select(.status == "failed")' | wc -l) + + if [ "$failed_count" -gt 0 ]; then + echo "❌ Wave $wave_number failed ($failed_count agents failed)" + return 1 + fi + + # Sleep before next check + sleep 30 + done +} +``` + +### Step 7: Handle Completion + +**When all waves complete:** + +```bash +# Archive session +~/.claude/utils/orchestrator-state.sh archive "$SESSION_ID" + +# Print summary +echo "🎉 All waves complete!" +echo "" +echo "Summary:" +echo " Total Cost: \$$(jq -r '.total_cost_usd' sessions.json)" +echo " Total Agents: $(jq -r '.agents | length' sessions.json)" +echo " Duration: " +echo "" +echo "Next steps:" +echo " 1. Review agent outputs in worktrees" +echo " 2. Merge worktrees to main branch" +echo " 3. Run integration tests" +``` + +## Output Format + +**During execution, display:** + +``` +🚀 Multi-Agent Implementation: + +📊 Plan Summary: + - Total Workstreams: 7 + - Total Waves: 4 + - Max Concurrent: 4 + +🌊 Wave 1 (2 agents) + ✅ agent-ws1-xxx (complete) - Cost: $1.86 + ✅ agent-ws3-xxx (complete) - Cost: $0.79 + Duration: 8m 23s + +🌊 Wave 2 (2 agents) + 🔄 agent-ws2-xxx (active) - Cost: $0.45 + 🔄 agent-ws4-xxx (active) - Cost: $0.38 + Elapsed: 3m 12s + +🌊 Wave 3 (1 agent) + ⏸️ agent-ws5-xxx (pending) + +🌊 Wave 4 (2 agents) + ⏸️ agent-ws6-xxx (pending) + ⏸️ agent-ws7-xxx (pending) + +💰 Total Cost: $3.48 / $50.00 (7%) +⏱️ Total Time: 11m 35s + +Press Ctrl+C to pause monitoring (agents continue in background) +``` + +## Important Notes + +- **Non-blocking**: Agents run in background tmux sessions +- **Resumable**: Can exit and resume with `/m-monitor ` +- **Auto-recovery**: Idle agents are killed automatically +- **Budget limits**: Stops if budget exceeded +- **Parallel execution**: Multiple agents per wave (up to max_concurrent) + +## Error Handling + +**If agent fails:** +1. Mark agent as "failed" +2. Continue other agents in wave +3. Do not proceed to next wave +4. Present failure summary to user +5. Allow manual retry or skip + +**If timeout:** +1. Check if agent is actually running (may be false positive) +2. If truly stuck, kill and mark as failed +3. Offer retry option + +## Resume Support + +**To resume a paused/stopped session:** + +```bash +/m-implement --resume +``` + +**Resume logic:** +1. Load existing session state +2. Determine current wave +3. Check which agents are still running +4. Continue from where it left off + +## CLI Options + +```bash +/m-implement [options] + +Options: + --isolated Use tmux-based agents instead of Task tool subagents + --resume Resume from last checkpoint + --from-wave N Start from specific wave number + --dry-run Show what would be executed + --max-concurrent N Override max concurrent agents + --no-monitoring Spawn agents and exit (no monitoring loop) +``` + +## Integration with `/spawn-agent` + +This command reuses logic from `~/.claude/commands/spawn-agent.md`: +- Git worktree creation +- Claude initialization detection +- Task sending via tmux + +## Exit Conditions + +**Success:** +- All waves complete +- All agents have status "complete" +- No failures + +**Failure:** +- Any agent has status "failed" +- Budget limit exceeded +- User manually aborts + +**Pause:** +- User presses Ctrl+C +- Session state saved +- Agents continue in background +- Resume with `/m-monitor ` + +--- + +**End of `/m-implement` command** diff --git a/claude-code/commands/m-monitor.md b/claude-code/commands/m-monitor.md new file mode 100644 index 0000000..b1ecee6 --- /dev/null +++ b/claude-code/commands/m-monitor.md @@ -0,0 +1,118 @@ +--- +description: Multi-agent monitoring - Real-time dashboard for orchestration sessions +tags: [orchestration, monitoring, multi-agent] +--- + +# Multi-Agent Monitoring (`/m-monitor`) + +You are now in **multi-agent monitoring mode**. Display a real-time dashboard of the orchestration session status. + +## Your Role + +Act as a **monitoring dashboard** that displays live status of all agents, waves, costs, and progress. + +## Usage + +```bash +/m-monitor +``` + +## Display Format + +``` +🚀 Multi-Agent Session: orch-1763400000 + +📊 Plan Summary: + - Task: Implement BigCommerce migration + - Created: 2025-11-17 10:00:00 + - Total Workstreams: 7 + - Total Waves: 4 + - Max Concurrent: 4 + +🌊 Wave 1: Complete ✅ (Duration: 8m 23s) + ✅ agent-ws1-1763338466 (WS-1: Service Layer) + Status: complete | Cost: $1.86 | Branch: feat/ws-1 + Worktree: worktrees/ws-1-service-layer + Last Update: 2025-11-17 10:08:23 + + ✅ agent-ws3-1763338483 (WS-3: Database Schema) + Status: complete | Cost: $0.79 | Branch: feat/ws-3 + Worktree: worktrees/ws-3-database-schema + Last Update: 2025-11-17 10:08:15 + +🌊 Wave 2: Active 🔄 (Elapsed: 3m 12s) + 🔄 agent-ws2-1763341887 (WS-2: Edge Functions) + Status: active | Cost: $0.45 | Branch: feat/ws-2 + Worktree: worktrees/ws-2-edge-functions + Last Update: 2025-11-17 10:11:35 + Attach: tmux attach -t agent-ws2-1763341887 + + 🔄 agent-ws4-1763341892 (WS-4: Frontend UI) + Status: active | Cost: $0.38 | Branch: feat/ws-4 + Worktree: worktrees/ws-4-frontend-ui + Last Update: 2025-11-17 10:11:42 + Attach: tmux attach -t agent-ws4-1763341892 + +🌊 Wave 3: Pending ⏸️ + ⏸️ agent-ws5-pending (WS-5: Checkout Flow) + +🌊 Wave 4: Pending ⏸️ + ⏸️ agent-ws6-pending (WS-6: E2E Tests) + ⏸️ agent-ws7-pending (WS-7: Documentation) + +💰 Budget Status: + - Current Cost: $3.48 + - Budget Limit: $50.00 + - Usage: 7% 🟢 + +⏱️ Timeline: + - Total Elapsed: 11m 35s + - Estimated Remaining: ~5h 30m + +📋 Commands: + - Refresh: /m-monitor + - Attach to agent: tmux attach -t + - View agent output: tmux capture-pane -t -p + - Kill idle agent: ~/.claude/utils/orchestrator-agent.sh kill + - Pause session: Ctrl+C (agents continue in background) + - Resume session: /m-implement --resume + +Status Legend: + ✅ complete 🔄 active ⏸️ pending ⚠️ idle ❌ failed 💀 killed +``` + +## Implementation (Phase 2) + +**This is a stub command for Phase 1.** Full implementation in Phase 2 will include: + +1. **Live monitoring loop** - Refresh every 30s +2. **Interactive controls** - Pause, resume, kill agents +3. **Cost tracking** - Real-time budget updates +4. **Idle detection** - Highlight idle agents +5. **Failure alerts** - Notify on failures +6. **Performance metrics** - Agent completion times + +## Current Workaround + +**Until Phase 2 is complete, use these manual commands:** + +```bash +# View session status +~/.claude/utils/orchestrator-state.sh print + +# List all agents +~/.claude/utils/orchestrator-state.sh list-agents + +# Check specific agent +~/.claude/utils/orchestrator-state.sh get-agent + +# Attach to agent tmux session +tmux attach -t + +# View agent output without attaching +tmux capture-pane -t -p | tail -50 +``` + +--- + +**End of `/m-monitor` command (stub)** diff --git a/claude-code/commands/m-plan.md b/claude-code/commands/m-plan.md new file mode 100644 index 0000000..39607c7 --- /dev/null +++ b/claude-code/commands/m-plan.md @@ -0,0 +1,261 @@ +--- +description: Multi-agent planning - Decompose complex tasks into parallel workstreams with dependency DAG +tags: [orchestration, planning, multi-agent] +--- + +# Multi-Agent Planning (`/m-plan`) + +You are now in **multi-agent planning mode**. Your task is to decompose a complex task into parallel workstreams with a dependency graph (DAG). + +## Your Role + +Act as a **solution-architect** specialized in task decomposition and dependency analysis. + +## Process + +### 1. Understand the Task + +**Ask clarifying questions if needed:** +- What is the overall goal? +- Are there any constraints (time, budget, resources)? +- Are there existing dependencies or requirements? +- What is the desired merge strategy? + +### 2. Decompose into Workstreams + +**Break down the task into independent workstreams:** +- Each workstream should be a cohesive unit of work +- Workstreams should be as independent as possible +- Identify clear deliverables for each workstream +- Assign appropriate agent types (backend-developer, frontend-developer, etc.) + +**Workstream Guidelines:** +- **Size**: Each workstream should take 1-3 hours of agent time +- **Independence**: Minimize dependencies between workstreams +- **Clarity**: Clear, specific deliverables +- **Agent Type**: Match to specialized agent capabilities + +### 3. Identify Dependencies + +**For each workstream, determine:** +- What other workstreams must complete first? +- What outputs does it depend on? +- What outputs does it produce for others? + +**Dependency Types:** +- **Blocking**: Must complete before dependent can start +- **Data**: Provides data/files needed by dependent +- **Interface**: Provides API/interface contract + +### 4. Create DAG Structure + +**Generate a JSON DAG file:** +```json +{ + "session_id": "orch-", + "created_at": "", + "task_description": "", + "nodes": { + "ws-1-": { + "task": "", + "agent_type": "backend-developer", + "workstream_id": "ws-1", + "dependencies": [], + "status": "pending", + "deliverables": [ + "src/services/FooService.ts", + "tests for FooService" + ] + }, + "ws-2-": { + "task": "", + "agent_type": "frontend-developer", + "workstream_id": "ws-2", + "dependencies": ["ws-1"], + "status": "pending", + "deliverables": [ + "src/components/FooComponent.tsx" + ] + } + }, + "edges": [ + {"from": "ws-1", "to": "ws-2", "type": "blocking"} + ] +} +``` + +### 5. Calculate Waves + +Use the topological sort utility to calculate execution waves: + +```bash +~/.claude/utils/orchestrator-dag.sh topo-sort +``` + +**Add wave information to DAG:** +```json +{ + "waves": [ + { + "wave_number": 1, + "nodes": ["ws-1", "ws-3"], + "status": "pending", + "estimated_parallel_time_hours": 2 + }, + { + "wave_number": 2, + "nodes": ["ws-2", "ws-4"], + "status": "pending", + "estimated_parallel_time_hours": 1.5 + } + ] +} +``` + +### 6. Estimate Costs and Timeline + +**For each workstream:** +- Estimate agent time (hours) +- Estimate cost based on historical data (~$1-2 per hour) +- Calculate total cost and timeline + +**Wave-based timeline:** +- Wave 1: 2 hours (parallel) +- Wave 2: 1.5 hours (parallel) +- Total: 3.5 hours (not 7 hours due to parallelism) + +### 7. Save DAG File + +**Save to:** +``` +~/.claude/orchestration/state/dag-.json +``` + +**Create orchestration session:** +```bash +SESSION_ID=$(~/.claude/utils/orchestrator-state.sh create \ + "orch-$(date +%s)" \ + "orch-$(date +%s)-monitor" \ + '{}') + +echo "Created session: $SESSION_ID" +``` + +## Output Format + +**Present to user:** + +```markdown +# Multi-Agent Plan: + +## Summary +- **Total Workstreams**: X +- **Total Waves**: Y +- **Estimated Timeline**: Z hours (parallel) +- **Estimated Cost**: $A - $B +- **Max Concurrent Agents**: 4 + +## Workstreams + +### Wave 1 (No dependencies) +- **WS-1: ** (backend-developer) - + - Deliverables: ... + - Estimated: 2h, $2 + +- **WS-3: ** (migration) - + - Deliverables: ... + - Estimated: 1.5h, $1.50 + +### Wave 2 (Depends on Wave 1) +- **WS-2: ** (backend-developer) - + - Dependencies: WS-3 (needs database schema) + - Deliverables: ... + - Estimated: 1.5h, $1.50 + +### Wave 3 (Depends on Wave 2) +- **WS-4: ** (frontend-developer) - + - Dependencies: WS-1 (needs service interface) + - Deliverables: ... + - Estimated: 2h, $2 + +## Dependency Graph +``` + WS-1 + │ + ├─→ WS-2 + │ + WS-3 + │ + └─→ WS-4 +``` + +## Timeline +- Wave 1: 2h (WS-1, WS-3 in parallel) +- Wave 2: 1.5h (WS-2 waits for WS-3) +- Wave 3: 2h (WS-4 waits for WS-1) +- **Total: 5.5 hours** + +## Total Cost Estimate +- **Low**: $5.00 (efficient execution) +- **High**: $8.00 (with retries) + +## DAG File +Saved to: `~/.claude/orchestration/state/dag-.json` + +## Next Steps +To execute this plan: +```bash +/m-implement +``` + +To monitor progress: +```bash +/m-monitor +``` +``` + +## Important Notes + +- **Keep workstreams focused**: Don't create too many tiny workstreams +- **Minimize dependencies**: More parallelism = faster completion +- **Assign correct agent types**: Use specialized agents for best results +- **Include all deliverables**: Be specific about what each workstream produces +- **Estimate conservatively**: Better to over-estimate than under-estimate + +## Agent Types Available + +- `backend-developer` - Server-side code, APIs, services +- `frontend-developer` - UI components, React, TypeScript +- `migration` - Database schemas, Flyway migrations +- `test-writer-fixer` - E2E tests, test suites +- `documentation-specialist` - Docs, runbooks, guides +- `security-agent` - Security reviews, vulnerability fixes +- `performance-optimizer` - Performance analysis, optimization +- `devops-automator` - CI/CD, infrastructure, deployments + +## Example Usage + +**User Request:** +``` +/m-plan Implement authentication system with OAuth, JWT tokens, and user profile management +``` + +**Your Response:** +1. Ask clarifying questions (OAuth provider? Existing DB schema?) +2. Decompose into workstreams (auth service, OAuth integration, user profiles, frontend UI) +3. Identify dependencies (auth service → OAuth integration → frontend) +4. Create DAG JSON +5. Calculate waves +6. Estimate costs +7. Save DAG file +8. Present plan to user +9. Wait for approval before proceeding + +**After user approves:** +- Do NOT execute automatically +- Instruct user to run `/m-implement ` +- Provide monitoring commands + +--- + +**End of `/m-plan` command** diff --git a/claude-code/commands/merge-agent-work.md b/claude-code/commands/merge-agent-work.md new file mode 100644 index 0000000..f54bc4f --- /dev/null +++ b/claude-code/commands/merge-agent-work.md @@ -0,0 +1,30 @@ +# /merge-agent-work - Merge Agent Branch + +Merges an agent's branch into the current branch. + +## Usage + +```bash +/merge-agent-work {timestamp} +``` + +## Implementation + +```bash +#!/bin/bash + +AGENT_ID="$1" + +if [ -z "$AGENT_ID" ]; then + echo "❌ Agent ID required" + echo "Usage: /merge-agent-work {timestamp}" + exit 1 +fi + +# Source utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../utils/git-worktree-utils.sh" + +# Merge agent work +merge_agent_work "$AGENT_ID" +``` diff --git a/claude-code/commands/spawn-agent.md b/claude-code/commands/spawn-agent.md new file mode 100644 index 0000000..8ced240 --- /dev/null +++ b/claude-code/commands/spawn-agent.md @@ -0,0 +1,289 @@ +# /spawn-agent - Spawn Claude Agent in tmux Session + +Spawn a Claude Code agent in a separate tmux session with optional handover context. + +## Usage + +```bash +/spawn-agent "implement user authentication" +/spawn-agent "refactor the API layer" --with-handover +/spawn-agent "implement feature X" --with-worktree +/spawn-agent "review the PR" --with-worktree --with-handover +``` + +## Implementation + +```bash +#!/bin/bash + +# Function: Wait for Claude Code to be ready for input +wait_for_claude_ready() { + local SESSION=$1 + local TIMEOUT=30 + local START=$(date +%s) + + echo "⏳ Waiting for Claude to initialize..." + + while true; do + # Capture pane output (suppress errors if session not ready) + PANE_OUTPUT=$(tmux capture-pane -t "$SESSION" -p 2>/dev/null) + + # Check for Claude prompt/splash (any of these indicates readiness) + if echo "$PANE_OUTPUT" | grep -qE "Claude Code|Welcome back|──────|Style:|bypass permissions"; then + # Verify not in error state + if ! echo "$PANE_OUTPUT" | grep -qiE "error|crash|failed|command not found"; then + echo "✅ Claude initialized successfully" + return 0 + fi + fi + + # Timeout check + local ELAPSED=$(($(date +%s) - START)) + if [ $ELAPSED -gt $TIMEOUT ]; then + echo "❌ Timeout: Claude did not initialize within ${TIMEOUT}s" + echo "📋 Capturing debug output..." + tmux capture-pane -t "$SESSION" -p > "/tmp/spawn-agent-${SESSION}-failure.log" 2>&1 + echo "Debug output saved to /tmp/spawn-agent-${SESSION}-failure.log" + return 1 + fi + + sleep 0.2 + done +} + +# Parse arguments +TASK="$1" +WITH_HANDOVER=false +WITH_WORKTREE=false +shift + +# Parse flags +while [[ $# -gt 0 ]]; do + case $1 in + --with-handover) + WITH_HANDOVER=true + shift + ;; + --with-worktree) + WITH_WORKTREE=true + shift + ;; + *) + shift + ;; + esac +done + +if [ -z "$TASK" ]; then + echo "❌ Task description required" + echo "Usage: /spawn-agent \"task description\" [--with-handover] [--with-worktree]" + exit 1 +fi + +# Generate session info +TASK_ID=$(date +%s) +SESSION="agent-${TASK_ID}" + +# Setup working directory (worktree or current) +if [ "$WITH_WORKTREE" = true ]; then + # Detect transcrypt (informational only - works transparently with worktrees) + if git config --get-regexp '^transcrypt\.' >/dev/null 2>&1; then + echo "📦 Transcrypt detected - worktree will inherit encryption config automatically" + echo "" + fi + + # Get current branch as base + CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || echo "HEAD") + + # Generate task slug from task description + TASK_SLUG=$(echo "$TASK" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | tr -s ' ' '-' | cut -c1-40 | sed 's/-$//') + + # Source worktree utilities + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + source "$SCRIPT_DIR/../utils/git-worktree-utils.sh" + + # Create worktree with task slug + echo "🌳 Creating isolated git worktree..." + WORK_DIR=$(create_agent_worktree "$TASK_ID" "$CURRENT_BRANCH" "$TASK_SLUG") + AGENT_BRANCH="agent/agent-${TASK_ID}" + + echo "✅ Worktree created:" + echo " Directory: $WORK_DIR" + echo " Branch: $AGENT_BRANCH" + echo " Base: $CURRENT_BRANCH" + echo "" +else + WORK_DIR=$(pwd) + AGENT_BRANCH="" +fi + +echo "🚀 Spawning Claude agent in tmux session..." +echo "" + +# Generate handover if requested +HANDOVER_CONTENT="" +if [ "$WITH_HANDOVER" = true ]; then + echo "📝 Generating handover context..." + + # Get current branch and recent commits + CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown") + RECENT_COMMITS=$(git log --oneline -5 2>/dev/null || echo "No git history") + GIT_STATUS=$(git status -sb 2>/dev/null || echo "Not a git repo") + + # Create handover content + HANDOVER_CONTENT=$(cat << EOF + +# Handover Context + +## Current State +- Branch: $CURRENT_BRANCH +- Directory: $WORK_DIR +- Time: $(date) + +## Recent Commits +$RECENT_COMMITS + +## Git Status +$GIT_STATUS + +## Your Task +$TASK + +--- +Please review the above context and proceed with the task. +EOF +) + + echo "✅ Handover generated" + echo "" +fi + +# Create tmux session +tmux new-session -d -s "$SESSION" -c "$WORK_DIR" + +# Verify session creation +if ! tmux has-session -t "$SESSION" 2>/dev/null; then + echo "❌ Failed to create tmux session" + exit 1 +fi + +echo "✅ Created tmux session: $SESSION" +echo "" + +# Start Claude Code in the session +tmux send-keys -t "$SESSION" "claude --dangerously-skip-permissions" C-m + +# Wait for Claude to be ready (not just sleep!) +if ! wait_for_claude_ready "$SESSION"; then + echo "❌ Failed to start Claude agent - cleaning up..." + tmux kill-session -t "$SESSION" 2>/dev/null + exit 1 +fi + +# Additional small delay for UI stabilization +sleep 0.5 + +# Send handover context if generated (line-by-line to handle newlines) +if [ "$WITH_HANDOVER" = true ]; then + echo "📤 Sending handover context to agent..." + + # Send line-by-line to handle multi-line content properly + echo "$HANDOVER_CONTENT" | while IFS= read -r LINE || [ -n "$LINE" ]; do + # Use -l flag to send literal text (handles special characters) + tmux send-keys -t "$SESSION" -l "$LINE" + tmux send-keys -t "$SESSION" C-m + sleep 0.05 # Small delay between lines + done + + # Final Enter to submit + tmux send-keys -t "$SESSION" C-m + sleep 0.5 +fi + +# Send the task (use literal mode for safety with special characters) +echo "📤 Sending task to agent..." +tmux send-keys -t "$SESSION" -l "$TASK" +tmux send-keys -t "$SESSION" C-m + +# Small delay for Claude to start processing +sleep 1 + +# Verify task was received by checking if Claude is processing +CURRENT_OUTPUT=$(tmux capture-pane -t "$SESSION" -p 2>/dev/null) +if echo "$CURRENT_OUTPUT" | grep -qE "Thought for|Forming|Creating|Implement|⏳|✽|∴"; then + echo "✅ Task received and processing" +elif echo "$CURRENT_OUTPUT" | grep -qE "error|failed|crash"; then + echo "⚠️ Warning: Detected error in agent output" + echo "📋 Last 10 lines of output:" + tmux capture-pane -t "$SESSION" -p | tail -10 +else + echo "ℹ️ Task sent (unable to confirm receipt - agent may still be starting)" +fi + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "✨ Agent spawned successfully!" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "Session: $SESSION" +echo "Task: $TASK" +echo "Directory: $WORK_DIR" +echo "" +echo "To monitor:" +echo " tmux attach -t $SESSION" +echo "" +echo "To send more commands:" +echo " tmux send-keys -t $SESSION \"your command\" C-m" +echo "" +echo "To kill session:" +echo " tmux kill-session -t $SESSION" +echo "" + +# Save metadata +mkdir -p ~/.claude/agents +cat > ~/.claude/agents/${SESSION}.json < /dev/null && echo "❌ adb not found. Is Android SDK installed?" && exit 1 + +! emulator -list-avds 2>/dev/null | grep -q "^${DEVICE}$" && echo "❌ Emulator '$DEVICE' not found" && emulator -list-avds && exit 1 + +RUNNING_EMULATOR=$(adb devices | grep "emulator" | cut -f1) + +if [ -z "$RUNNING_EMULATOR" ]; then + emulator -avd "$DEVICE" -no-snapshot-load -no-boot-anim & + adb wait-for-device + sleep 5 + while [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do + sleep 2 + done +fi + +EMULATOR_SERIAL=$(adb devices | grep "emulator" | cut -f1 | head -1) +``` + +### Step 5: Setup Port Forwarding + +```bash +# For dev server access from emulator +if [ "$PROJECT_TYPE" = "react-native" ] || grep -q "\"dev\":" package.json 2>/dev/null; then + DEV_PORT=$(shuf -i 3000-9999 -n 1) + adb -s "$EMULATOR_SERIAL" reverse tcp:$DEV_PORT tcp:$DEV_PORT +fi +``` + +### Step 6: Configure Poltergeist (Optional) + +```bash +POLTERGEIST_AVAILABLE=false + +if command -v poltergeist &> /dev/null; then + POLTERGEIST_AVAILABLE=true + + [ ! -f ".poltergeist.yml" ] && cat > .poltergeist.yml </dev/null || echo "main") +TIMESTAMP=$(date +%s) +SESSION="android-${PROJECT_NAME}-${TIMESTAMP}" + +tmux new-session -d -s "$SESSION" -n build +``` + +### Step 8: Build & Install + +```bash +case $PROJECT_TYPE in + react-native) + tmux send-keys -t "$SESSION:build" "npx react-native run-android --variant=$VARIANT --deviceId=$EMULATOR_SERIAL" C-m + ;; + capacitor) + tmux send-keys -t "$SESSION:build" "npx cap sync android && npx cap run android --target=$EMULATOR_SERIAL" C-m + ;; + flutter) + tmux send-keys -t "$SESSION:build" "flutter run -d $EMULATOR_SERIAL --flavor $VARIANT" C-m + ;; + native) + tmux send-keys -t "$SESSION:build" "cd android && ./gradlew install${BUILD_TYPE} && cd .." C-m + ;; +esac +``` + +### Step 9: Setup Additional Windows + +```bash +# Dev server (if needed) +if [ "$PROJECT_TYPE" = "react-native" ] || grep -q "\"dev\":" package.json 2>/dev/null; then + tmux new-window -t "$SESSION" -n dev-server + tmux send-keys -t "$SESSION:dev-server" "PORT=$DEV_PORT npm start | tee dev-server.log" C-m +fi + +# Poltergeist (if available) +if [ "$POLTERGEIST_AVAILABLE" = true ]; then + tmux new-window -t "$SESSION" -n poltergeist + tmux send-keys -t "$SESSION:poltergeist" "poltergeist watch --platform android | tee poltergeist.log" C-m +fi + +# Logs +tmux new-window -t "$SESSION" -n logs +tmux send-keys -t "$SESSION:logs" "adb -s $EMULATOR_SERIAL logcat -v color" C-m + +# Git +tmux new-window -t "$SESSION" -n git +tmux send-keys -t "$SESSION:git" "git status" C-m +``` + +### Step 10: Save Metadata + +```bash +cat > .tmux-android-session.json < /dev/null; then + POLTERGEIST_AVAILABLE=true + + [ ! -f ".poltergeist.yml" ] && cat > .poltergeist.yml </dev/null || echo "main") +TIMESTAMP=$(date +%s) +SESSION="ios-${PROJECT_NAME}-${TIMESTAMP}" + +tmux new-session -d -s "$SESSION" -n build +``` + +### Step 7: Build & Install + +```bash +case $PROJECT_TYPE in + react-native) + tmux send-keys -t "$SESSION:build" "npx react-native run-ios --simulator='$DEVICE' --configuration $CONFIGURATION" C-m + ;; + capacitor) + tmux send-keys -t "$SESSION:build" "npx cap sync ios && npx cap run ios --target='$SIMULATOR_UDID' --configuration=$CONFIGURATION" C-m + ;; + native-pods|native) + WORKSPACE=$(find ios -name "*.xcworkspace" -maxdepth 1 | head -1) + if [ -n "$WORKSPACE" ]; then + tmux send-keys -t "$SESSION:build" "xcodebuild -workspace $WORKSPACE -scheme $SCHEME -configuration $CONFIGURATION -destination 'id=$SIMULATOR_UDID' build" C-m + fi + ;; +esac +``` + +### Step 8: Setup Additional Windows + +```bash +# Dev server (if needed) +if [ "$PROJECT_TYPE" = "react-native" ] || grep -q "\"dev\":" package.json 2>/dev/null; then + DEV_PORT=$(shuf -i 3000-9999 -n 1) + tmux new-window -t "$SESSION" -n dev-server + tmux send-keys -t "$SESSION:dev-server" "PORT=$DEV_PORT npm start | tee dev-server.log" C-m +fi + +# Poltergeist (if available) +if [ "$POLTERGEIST_AVAILABLE" = true ]; then + tmux new-window -t "$SESSION" -n poltergeist + tmux send-keys -t "$SESSION:poltergeist" "poltergeist watch --platform ios | tee poltergeist.log" C-m +fi + +# Logs +tmux new-window -t "$SESSION" -n logs +tmux send-keys -t "$SESSION:logs" "xcrun simctl spawn $SIMULATOR_UDID log stream --level debug" C-m + +# Git +tmux new-window -t "$SESSION" -n git +tmux send-keys -t "$SESSION:git" "git status" C-m +``` + +### Step 9: Save Metadata + +```bash +cat > .tmux-ios-session.json </dev/null + exit 1 +fi +``` + +### Step 2: Detect Project Type + +```bash +detect_project_type() { + if [ -f "package.json" ]; then + grep -q "\"next\":" package.json && echo "nextjs" && return + grep -q "\"vite\":" package.json && echo "vite" && return + grep -q "\"react-scripts\":" package.json && echo "cra" && return + grep -q "\"@vue/cli\":" package.json && echo "vue" && return + echo "node" + elif [ -f "requirements.txt" ] || [ -f "pyproject.toml" ]; then + grep -q "django" requirements.txt pyproject.toml 2>/dev/null && echo "django" && return + grep -q "flask" requirements.txt pyproject.toml 2>/dev/null && echo "flask" && return + echo "python" + elif [ -f "Cargo.toml" ]; then + echo "rust" + elif [ -f "go.mod" ]; then + echo "go" + else + echo "unknown" + fi +} + +PROJECT_TYPE=$(detect_project_type) +``` + +### Step 3: Detect Required Services + +```bash +NEEDS_SUPABASE=false +NEEDS_POSTGRES=false +NEEDS_REDIS=false + +[ -f "supabase/config.toml" ] && NEEDS_SUPABASE=true +grep -q "postgres" "$ENV_FILE" 2>/dev/null && NEEDS_POSTGRES=true +grep -q "redis" "$ENV_FILE" 2>/dev/null && NEEDS_REDIS=true +``` + +### Step 4: Generate Random Port + +```bash +DEV_PORT=$(shuf -i 3000-9999 -n 1) + +while lsof -i :$DEV_PORT >/dev/null 2>&1; do + DEV_PORT=$(shuf -i 3000-9999 -n 1) +done +``` + +### Step 5: Create tmux Session + +```bash +PROJECT_NAME=$(basename "$(pwd)") +BRANCH=$(git branch --show-current 2>/dev/null || echo "main") +TIMESTAMP=$(date +%s) +SESSION="dev-${PROJECT_NAME}-${TIMESTAMP}" + +tmux new-session -d -s "$SESSION" -n servers +``` + +### Step 6: Start Services + +```bash +PANE_COUNT=0 + +# Main dev server +case $PROJECT_TYPE in + nextjs|vite|cra|vue) + tmux send-keys -t "$SESSION:servers.${PANE_COUNT}" "PORT=$DEV_PORT npm run dev | tee dev-server-${DEV_PORT}.log" C-m + ;; + django) + tmux send-keys -t "$SESSION:servers.${PANE_COUNT}" "python manage.py runserver $DEV_PORT | tee dev-server-${DEV_PORT}.log" C-m + ;; + flask) + tmux send-keys -t "$SESSION:servers.${PANE_COUNT}" "FLASK_RUN_PORT=$DEV_PORT flask run | tee dev-server-${DEV_PORT}.log" C-m + ;; + *) + tmux send-keys -t "$SESSION:servers.${PANE_COUNT}" "PORT=$DEV_PORT npm run dev | tee dev-server-${DEV_PORT}.log" C-m + ;; +esac + +# Additional services (if needed) +if [ "$NEEDS_SUPABASE" = true ]; then + PANE_COUNT=$((PANE_COUNT + 1)) + tmux split-window -v -t "$SESSION:servers" + tmux send-keys -t "$SESSION:servers.${PANE_COUNT}" "supabase start" C-m +fi + +if [ "$NEEDS_POSTGRES" = true ] && [ "$NEEDS_SUPABASE" = false ]; then + PANE_COUNT=$((PANE_COUNT + 1)) + tmux split-window -v -t "$SESSION:servers" + tmux send-keys -t "$SESSION:servers.${PANE_COUNT}" "docker-compose up postgres" C-m +fi + +if [ "$NEEDS_REDIS" = true ]; then + PANE_COUNT=$((PANE_COUNT + 1)) + tmux split-window -v -t "$SESSION:servers" + tmux send-keys -t "$SESSION:servers.${PANE_COUNT}" "redis-server" C-m +fi + +tmux select-layout -t "$SESSION:servers" tiled +``` + +### Step 7: Create Additional Windows + +```bash +# Logs window +tmux new-window -t "$SESSION" -n logs +tmux send-keys -t "$SESSION:logs" "tail -f dev-server-${DEV_PORT}.log 2>/dev/null || sleep infinity" C-m + +# Work window +tmux new-window -t "$SESSION" -n work + +# Git window +tmux new-window -t "$SESSION" -n git +tmux send-keys -t "$SESSION:git" "git status" C-m +``` + +### Step 8: Save Metadata + +```bash +cat > .tmux-dev-session.json < +``` + +**Long-running detached sessions**: +``` +💡 Found dev sessions running >2 hours +Recommendation: Check if still needed: tmux attach -t +``` + +**Many sessions (>5)**: +``` +🧹 Found 5+ active sessions +Recommendation: Review and clean up unused sessions +``` + +## Use Cases + +### Before Starting New Environment + +```bash +/tmux-status +# Check for port conflicts and existing sessions before /start-local +``` + +### Monitor Agent Progress + +```bash +/tmux-status +# See status of spawned agents (running, completed, etc.) +``` + +### Session Discovery + +```bash +/tmux-status --detailed +# Find specific session by project name or port +``` + +## Notes + +- Read-only, never modifies sessions +- Uses tmux-monitor skill for discovery +- Integrates with tmuxwatch if available +- Detects metadata from `.tmux-dev-session.json` and `~/.claude/agents/*.json` diff --git a/claude-code/hooks/session_start.py b/claude-code/hooks/session_start.py index 670c439..9be155c 100755 --- a/claude-code/hooks/session_start.py +++ b/claude-code/hooks/session_start.py @@ -141,6 +141,73 @@ def load_development_context(source): return "\n".join(context_parts) +def load_tmux_sessions(): + """Load and display active tmux development sessions.""" + try: + # Find all tmux session metadata files + session_files = list(Path.cwd().glob('.tmux-*-session.json')) + + if not session_files: + return "📋 No active development sessions found" + + sessions = [] + for file in session_files: + try: + with open(file, 'r') as f: + data = json.load(f) + + # Verify session still exists + session_name = data.get('session') + if session_name: + check_result = subprocess.run( + ['tmux', 'has-session', '-t', session_name], + capture_output=True, + timeout=2 + ) + + if check_result.returncode == 0: + sessions.append({ + 'type': file.stem.replace('.tmux-', '').replace('-session', ''), + 'data': data + }) + except Exception: + continue + + if not sessions: + return "📋 No active development sessions found" + + # Format as table + lines = [ + "═══════════════════════════════════════════════════════════════", + " Active Development Sessions", + "═══════════════════════════════════════════════════════════════", + ] + + for sess in sessions: + data = sess['data'] + lines.append(f"\n [{sess['type'].upper()}] {data.get('session')}") + lines.append(f" Project: {data.get('project_name', 'N/A')}") + + branch = data.get('branch') + if branch and branch != 'main': + lines.append(f" Branch: {branch}") + + if 'dev_port' in data and data['dev_port']: + lines.append(f" Port: http://localhost:{data['dev_port']}") + + if 'environment' in data: + lines.append(f" Environment: {data['environment']}") + + lines.append(f" Attach: tmux attach -t {data.get('session')}") + + lines.append("\n═══════════════════════════════════════════════════════════════") + + return "\n".join(lines) + + except Exception as e: + return f"Failed to load tmux sessions: {e}" + + def main(): try: # Parse command line arguments @@ -196,17 +263,31 @@ def main(): except Exception as e: git_status_info.append(f"Failed to run git status: {e}") - # If we have git status info, output it as additional context - if git_status_info: - output = { - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": "\n\n".join(git_status_info) - } + # Store git status for potential combination with tmux sessions + # (Don't exit yet, combine with tmux sessions below) + + # Always load tmux sessions + tmux_sessions = load_tmux_sessions() + + # Combine git status (if requested) with tmux sessions + context_parts = [] + if args.git_status and git_status_info: + context_parts.extend(git_status_info) + + if tmux_sessions: + context_parts.append(tmux_sessions) + + # If we have any context to display, output it + if context_parts: + output = { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": "\n\n".join(context_parts) } - print(json.dumps(output)) - sys.exit(0) - + } + print(json.dumps(output)) + sys.exit(0) + # Load development context if requested if args.load_context: context = load_development_context(source) diff --git a/claude-code/orchestration/state/config.json b/claude-code/orchestration/state/config.json new file mode 100644 index 0000000..32484e6 --- /dev/null +++ b/claude-code/orchestration/state/config.json @@ -0,0 +1,24 @@ +{ + "orchestrator": { + "max_concurrent_agents": 4, + "idle_timeout_minutes": 15, + "checkpoint_interval_minutes": 5, + "max_retry_attempts": 3, + "polling_interval_seconds": 30 + }, + "merge": { + "default_strategy": "sequential", + "require_tests": true, + "auto_merge": false + }, + "monitoring": { + "check_interval_seconds": 30, + "log_level": "info", + "enable_cost_tracking": true + }, + "resource_limits": { + "max_budget_usd": 50, + "warn_at_percent": 80, + "hard_stop_at_percent": 100 + } +} diff --git a/claude-code/skills/agent-orchestrator/SKILL.md b/claude-code/skills/agent-orchestrator/SKILL.md new file mode 100644 index 0000000..d93b096 --- /dev/null +++ b/claude-code/skills/agent-orchestrator/SKILL.md @@ -0,0 +1,406 @@ +--- +name: agent-orchestrator +description: Spawn, monitor, and manage Claude Code agents in parallel tmux sessions. Supports simple ad-hoc agents and complex DAG-based multi-agent orchestration with wave execution. +version: 2.0.0 +--- + +# Agent Orchestrator Skill + +## Purpose + +Provide comprehensive management of Claude Code agents running in parallel tmux sessions. This skill supports two modes: + +1. **Simple Mode**: Ad-hoc agent spawning for quick parallel tasks +2. **Orchestration Mode**: DAG-based multi-agent execution with dependencies and waves + +## Directory Structure + +``` +agent-orchestrator/ +├── SKILL.md # This file +└── scripts/ + ├── core/ # Core agent management + │ ├── spawn.sh # Spawn single agent + │ ├── status.sh # Check agent status + │ └── cleanup.sh # Clean up agents + └── orchestration/ # DAG-based orchestration + ├── session-create.sh # Create orchestration session + ├── wave-spawn.sh # Spawn wave of agents + ├── wave-monitor.sh # Monitor wave progress + ├── session-status.sh # Full session status + └── merge-waves.sh # Merge completed worktrees +``` + +## When to Use This Skill + +### Simple Mode +- Quick parallel tasks without dependencies +- Background research while working on something else +- Code review while implementing +- Single isolated experiments + +### Orchestration Mode +- Complex features with multiple dependent workstreams +- Multi-team parallel development +- Large refactoring with clear phases +- Integration with `/m-plan` workflow + +--- + +# Simple Mode + +## Capabilities + +1. **Agent Spawning**: Launch Claude agents in isolated tmux sessions +2. **Worktree Isolation**: Optionally run agents in separate git worktrees +3. **Status Monitoring**: Real-time visibility into running agents +4. **Lifecycle Management**: Clean up completed agents + +## Usage + +### Spawn an Agent + +```bash +SKILL_DIR="${TOOL_DIR}/skills/agent-orchestrator/scripts" + +# Basic spawn +bash "$SKILL_DIR/core/spawn.sh" "implement user authentication" + +# With handover context (passes git status, recent commits, etc.) +bash "$SKILL_DIR/core/spawn.sh" "refactor the API layer" --with-handover + +# With git worktree isolation (agent works on separate branch) +bash "$SKILL_DIR/core/spawn.sh" "implement feature X" --with-worktree + +# With both +bash "$SKILL_DIR/core/spawn.sh" "implement caching" --with-handover --with-worktree +``` + +### Check Status + +```bash +# Quick overview +bash "$SKILL_DIR/core/status.sh" + +# Detailed status +bash "$SKILL_DIR/core/status.sh" --detailed + +# JSON output +bash "$SKILL_DIR/core/status.sh" --json + +# Specific agent +bash "$SKILL_DIR/core/status.sh" agent-1705161234 +``` + +### Clean Up + +```bash +# Clean specific agent +bash "$SKILL_DIR/core/cleanup.sh" agent-1705161234 + +# Merge worktree before cleanup +bash "$SKILL_DIR/core/cleanup.sh" agent-1705161234 --merge + +# Clean all completed agents +bash "$SKILL_DIR/core/cleanup.sh" --all-completed +``` + +## Simple Mode Example + +```bash +# Spawn reviewer while you implement +bash "$SKILL_DIR/core/spawn.sh" "review src/auth/ for security issues" --with-handover + +# Continue working in main session... + +# Check reviewer status +bash "$SKILL_DIR/core/status.sh" + +# Get reviewer output +tmux capture-pane -t agent-xxx -p > review-results.md + +# Clean up +bash "$SKILL_DIR/core/cleanup.sh" agent-xxx +``` + +--- + +# Orchestration Mode + +## Integration with /m-plan Workflow + +The orchestration mode integrates with `/m-plan` and `/m-implement` commands: + +``` +/m-plan → DAG file → session-create.sh → wave-spawn.sh → wave-monitor.sh → merge-waves.sh +``` + +## Capabilities + +1. **Session Management**: Create and track orchestration sessions +2. **Wave Execution**: Spawn agents respecting DAG dependencies +3. **Progress Monitoring**: Real-time status across all agents +4. **Cost Tracking**: Track Claude API costs per agent and total +5. **Failure Handling**: Detect failures and pause execution +6. **Merge Automation**: Merge completed worktrees systematically + +## Usage + +### 1. Create Session from DAG + +After `/m-plan` creates a DAG file: + +```bash +SKILL_DIR="${TOOL_DIR}/skills/agent-orchestrator/scripts" + +# Create orchestration session +bash "$SKILL_DIR/orchestration/session-create.sh" ~/.claude/orchestration/state/dag-plan.json + +# Output: SESSION_ID=orch-1705161234 +``` + +### 2. Execute Waves + +```bash +SESSION_ID="orch-1705161234" + +# Spawn Wave 1 (no dependencies) +bash "$SKILL_DIR/orchestration/wave-spawn.sh" $SESSION_ID 1 + +# Monitor Wave 1 until complete +bash "$SKILL_DIR/orchestration/wave-monitor.sh" $SESSION_ID 1 + +# Spawn Wave 2 (depends on Wave 1) +bash "$SKILL_DIR/orchestration/wave-spawn.sh" $SESSION_ID 2 + +# Monitor Wave 2 +bash "$SKILL_DIR/orchestration/wave-monitor.sh" $SESSION_ID 2 + +# Continue for all waves... +``` + +### 3. Monitor Session + +```bash +# List all sessions +bash "$SKILL_DIR/orchestration/session-status.sh" --list + +# Session overview +bash "$SKILL_DIR/orchestration/session-status.sh" $SESSION_ID + +# Detailed status +bash "$SKILL_DIR/orchestration/session-status.sh" $SESSION_ID --detailed + +# JSON output +bash "$SKILL_DIR/orchestration/session-status.sh" $SESSION_ID --json +``` + +### 4. Merge Results + +```bash +# Preview merges +bash "$SKILL_DIR/orchestration/merge-waves.sh" $SESSION_ID --dry-run + +# Merge all completed agents +bash "$SKILL_DIR/orchestration/merge-waves.sh" $SESSION_ID + +# Merge specific wave +bash "$SKILL_DIR/orchestration/merge-waves.sh" $SESSION_ID --wave 1 +``` + +## DAG File Format + +The orchestration expects a DAG file with this structure: + +```json +{ + "task_description": "Implement authentication system", + "nodes": { + "ws-1-auth-service": { + "task": "Create authentication service with JWT support", + "agent_type": "backend-developer", + "workstream_id": "ws-1", + "dependencies": [], + "deliverables": ["src/services/AuthService.ts", "tests"] + }, + "ws-2-oauth": { + "task": "Integrate OAuth providers (Google, GitHub)", + "agent_type": "backend-developer", + "workstream_id": "ws-2", + "dependencies": ["ws-1"], + "deliverables": ["src/services/OAuthService.ts"] + } + }, + "waves": [ + {"wave_number": 1, "nodes": ["ws-1-auth-service"]}, + {"wave_number": 2, "nodes": ["ws-2-oauth"]} + ] +} +``` + +## Orchestration Example + +```bash +# After running /m-plan for a complex feature... + +SKILL_DIR="${TOOL_DIR}/skills/agent-orchestrator/scripts" +DAG_FILE="~/.claude/orchestration/state/dag-auth-feature.json" + +# Create session +SESSION=$(bash "$SKILL_DIR/orchestration/session-create.sh" "$DAG_FILE" | grep SESSION_ID | cut -d= -f2) + +# Execute all waves +TOTAL_WAVES=$(jq '.waves | length' "$DAG_FILE") +for wave in $(seq 1 $TOTAL_WAVES); do + echo "Starting Wave $wave..." + bash "$SKILL_DIR/orchestration/wave-spawn.sh" "$SESSION" "$wave" + bash "$SKILL_DIR/orchestration/wave-monitor.sh" "$SESSION" "$wave" +done + +# Merge all results +bash "$SKILL_DIR/orchestration/merge-waves.sh" "$SESSION" + +# View final status +bash "$SKILL_DIR/orchestration/session-status.sh" "$SESSION" --detailed +``` + +--- + +# Agent Status Detection + +Both modes use the same status detection: + +| Status | Indicators | Recommended Action | +|--------|-----------|-------------------| +| `active` | Agent is processing | Wait for completion | +| `idle` | At Claude prompt | Send instructions or complete | +| `complete` | Task finished, commits created | Review and cleanup | +| `failed` | Error messages detected | Attach and debug | +| `killed` | Session no longer exists | Already cleaned up | + +--- + +# Metadata Storage + +## Simple Mode +- `~/.claude/agents/{session}.json` - Agent metadata + +## Orchestration Mode +- `~/.claude/orchestration/state/dag-{session-id}.json` - DAG definition +- `~/.claude/orchestration/state/session-{session-id}.json` - Session state + +## Unified Agent Metadata Format + +```json +{ + "session": "agent-1705161234", + "task": "implement user authentication", + "directory": "/path/to/project", + "created": "2025-01-13T14:30:00Z", + "status": "running", + "with_worktree": true, + "worktree_branch": "agent/agent-1705161234", + "orchestration": { + "session_id": "orch-1705161000", + "wave": 2, + "workstream_id": "ws-auth", + "dag_node": "ws-2-auth-service", + "agent_type": "backend-developer", + "dependencies": ["ws-1-database"] + } +} +``` + +--- + +# Best Practices + +## Task Descriptions + +Write clear, specific task descriptions: + +```bash +# Good - clear and specific +"Implement user authentication with JWT tokens and refresh token rotation" + +# Bad - vague +"Add auth" +``` + +## When to Use Worktrees + +**Use worktree (`--with-worktree`)** when: +- Agent will make many commits +- Multiple agents might edit same files +- Want to easily revert agent's work + +**Use shared directory (default)** when: +- Quick, small tasks +- Task is read-heavy (research, review) + +## Parallel Limits + +Recommended concurrent agents: +- **Standard machine**: 2-4 agents +- **High-memory machine**: 4-8 agents + +Consider I/O and API rate limits. + +--- + +# Troubleshooting + +## Agent Not Starting + +```bash +# Check spawn debug log +cat /tmp/spawn-agent-{session}-failure.log + +# Verify Claude Code installed +which claude + +# Check tmux running +tmux list-sessions +``` + +## Agent Stuck + +```bash +# Attach and check output +tmux attach -t agent-{timestamp} + +# Send wake-up prompt +tmux send-keys -t agent-{timestamp} "continue with the task" C-m +``` + +## Merge Conflicts + +```bash +# Check worktree status +git -C worktrees/agent-xxx status + +# Resolve manually then continue +git merge --continue +``` + +--- + +# Related Commands + +- `/spawn-agent` - Original spawn command +- `/m-plan` - Multi-agent planning (creates DAG) +- `/m-implement` - Multi-agent implementation +- `/m-monitor` - Multi-agent monitoring +- `/tmux-status` - General tmux status + +--- + +# Dependencies + +Required: +- `tmux` (session management) +- `jq` (JSON parsing) +- `git` (for worktree features) + +Optional: +- `tmuxwatch` (enhanced monitoring) diff --git a/claude-code/skills/agent-orchestrator/scripts/core/cleanup.sh b/claude-code/skills/agent-orchestrator/scripts/core/cleanup.sh new file mode 100755 index 0000000..d8fc721 --- /dev/null +++ b/claude-code/skills/agent-orchestrator/scripts/core/cleanup.sh @@ -0,0 +1,225 @@ +#!/bin/bash + +# ABOUTME: Agent cleanup script - removes completed agents and optionally merges worktree changes +# Part of the agent-orchestrator skill + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(dirname "$SCRIPT_DIR")" +TOOL_DIR="$(dirname "$(dirname "$SKILL_DIR")")" + +# Source utilities +source "${TOOL_DIR}/utils/git-worktree-utils.sh" + +# Parse arguments +SESSION="" +MERGE=false +FORCE=false +ALL_COMPLETED=false + +while [[ $# -gt 0 ]]; do + case $1 in + --merge|-m) + MERGE=true + shift + ;; + --force|-f) + FORCE=true + shift + ;; + --all-completed) + ALL_COMPLETED=true + shift + ;; + agent-*) + SESSION="$1" + shift + ;; + *) + shift + ;; + esac +done + +# Detect agent status from tmux output +detect_agent_status() { + local SESSION=$1 + + if ! tmux has-session -t "$SESSION" 2>/dev/null; then + echo "killed" + return 0 + fi + + local OUTPUT=$(tmux capture-pane -t "$SESSION" -p -S -100 2>/dev/null || echo "") + + # Check for completion indicators + if echo "$OUTPUT" | grep -qiE "complete|done|finished|All.*tasks.*complete"; then + echo "complete" + return 0 + fi + + # Check for failure indicators + if echo "$OUTPUT" | grep -qiE "error|failed|fatal|Error:"; then + echo "failed" + return 0 + fi + + # Check for idle + local LAST_LINE=$(echo "$OUTPUT" | tail -5) + if echo "$LAST_LINE" | grep -qE "^>|Style:|bypass permissions"; then + echo "idle" + return 0 + fi + + echo "active" +} + +# Get agent metadata +get_agent_metadata() { + local SESSION=$1 + local METADATA_FILE="$HOME/.claude/agents/${SESSION}.json" + + if [ -f "$METADATA_FILE" ]; then + cat "$METADATA_FILE" + else + echo "{}" + fi +} + +# Cleanup a single agent +cleanup_agent() { + local SESSION=$1 + local MERGE_OPT=$2 + local FORCE_OPT=$3 + + echo "Cleaning up agent: $SESSION" + + local METADATA=$(get_agent_metadata "$SESSION") + local WITH_WORKTREE=$(echo "$METADATA" | jq -r '.with_worktree // false' 2>/dev/null) + local WORKTREE_BRANCH=$(echo "$METADATA" | jq -r '.worktree_branch // ""' 2>/dev/null) + local DIRECTORY=$(echo "$METADATA" | jq -r '.directory // ""' 2>/dev/null) + + # Check if session is still active + local STATUS=$(detect_agent_status "$SESSION") + if [ "$STATUS" = "active" ] && [ "$FORCE_OPT" = false ]; then + echo "Warning: Agent is still active. Use --force to cleanup anyway." + return 1 + fi + + # Handle worktree cleanup + if [ "$WITH_WORKTREE" = "true" ] && [ -n "$WORKTREE_BRANCH" ]; then + # Extract agent ID from session name (agent-{timestamp}) + local AGENT_ID=$(echo "$SESSION" | sed 's/agent-//') + + # Check for uncommitted changes in worktree + if [ -d "$DIRECTORY" ]; then + if ! git -C "$DIRECTORY" diff --quiet 2>/dev/null; then + if [ "$FORCE_OPT" = false ]; then + echo "Warning: Worktree has uncommitted changes." + echo " Directory: $DIRECTORY" + echo " Use --force to discard changes, or commit them first." + return 1 + fi + fi + + # Merge if requested + if [ "$MERGE_OPT" = true ]; then + echo "Merging agent branch: $WORKTREE_BRANCH" + + # Check for commits to merge + local COMMITS=$(git log --oneline "HEAD..$WORKTREE_BRANCH" 2>/dev/null | wc -l | tr -d ' ') + if [ "$COMMITS" -gt 0 ]; then + echo "Found $COMMITS commit(s) to merge" + + # Try merge + if git merge "$WORKTREE_BRANCH" -m "Merge agent work: $SESSION"; then + echo "Merge successful" + else + echo "Merge failed - resolve conflicts and run cleanup again" + return 1 + fi + else + echo "No commits to merge" + fi + fi + fi + + # Remove worktree + echo "Removing worktree..." + if [ "$FORCE_OPT" = true ]; then + cleanup_agent_worktree "$AGENT_ID" true 2>/dev/null || true + else + cleanup_agent_worktree "$AGENT_ID" false 2>/dev/null || true + fi + fi + + # Kill tmux session + if tmux has-session -t "$SESSION" 2>/dev/null; then + echo "Killing tmux session..." + tmux kill-session -t "$SESSION" + fi + + # Remove metadata file + local METADATA_FILE="$HOME/.claude/agents/${SESSION}.json" + if [ -f "$METADATA_FILE" ]; then + rm "$METADATA_FILE" + echo "Removed metadata file" + fi + + echo "Cleanup complete: $SESSION" + echo "" +} + +# Get all agent sessions +get_agent_sessions() { + tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^agent-" || true +} + +# Main + +if [ "$ALL_COMPLETED" = true ]; then + echo "Cleaning up all completed agents..." + echo "" + + SESSIONS=$(get_agent_sessions) + CLEANED=0 + + while IFS= read -r SESSION; do + [ -z "$SESSION" ] && continue + + STATUS=$(detect_agent_status "$SESSION") + if [ "$STATUS" = "complete" ] || [ "$STATUS" = "killed" ]; then + cleanup_agent "$SESSION" "$MERGE" "$FORCE" || true + CLEANED=$((CLEANED + 1)) + fi + done <<< "$SESSIONS" + + if [ $CLEANED -eq 0 ]; then + echo "No completed agents to clean up" + else + echo "Cleaned up $CLEANED agent(s)" + fi + +elif [ -n "$SESSION" ]; then + # Cleanup specific session + cleanup_agent "$SESSION" "$MERGE" "$FORCE" + +else + echo "Usage: cleanup.sh [options]" + echo "" + echo "Options:" + echo " --merge, -m Merge worktree changes before cleanup" + echo " --force, -f Force cleanup even with uncommitted changes or active agents" + echo " --all-completed Clean up all completed agents" + echo "" + echo "Examples:" + echo " cleanup.sh agent-1705161234" + echo " cleanup.sh agent-1705161234 --merge" + echo " cleanup.sh agent-1705161234 --force" + echo " cleanup.sh --all-completed" + echo "" + echo "Current agents:" + bash "$SCRIPT_DIR/status.sh" 2>/dev/null || echo " (none)" + exit 1 +fi diff --git a/claude-code/skills/agent-orchestrator/scripts/core/spawn.sh b/claude-code/skills/agent-orchestrator/scripts/core/spawn.sh new file mode 100755 index 0000000..e1b2a0b --- /dev/null +++ b/claude-code/skills/agent-orchestrator/scripts/core/spawn.sh @@ -0,0 +1,479 @@ +#!/bin/bash + +# ABOUTME: Agent spawning script - launches Claude Code agents in isolated tmux sessions +# Supports both simple mode and orchestration mode (integration with /m-plan workflow) +# Part of the agent-orchestrator skill + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" +TOOL_DIR="$(dirname "$(dirname "$SKILL_DIR")")" + +# Source utilities +source "${TOOL_DIR}/utils/git-worktree-utils.sh" + +# Function: Wait for Claude Code to be ready for input +# Handles interactive prompts that may appear during startup as fallback +wait_for_claude_ready() { + local SESSION=$1 + local TIMEOUT=60 + local START=$(date +%s) + local LAST_PROMPT_HASH="" + + echo "Waiting for Claude to initialize..." + + while true; do + # Capture pane output (suppress errors if session not ready) + PANE_OUTPUT=$(tmux capture-pane -t "$SESSION" -p 2>/dev/null || echo "") + + # FIRST: Check for ACTUAL ready state - Claude Code's main interface + # Look for Claude Code specific UI elements (version banner, model info, input prompt) + if echo "$PANE_OUTPUT" | grep -qE "Claude Code v[0-9]|Opus|Sonnet|Haiku"; then + # Also verify we see the input prompt area (not just the banner) + if echo "$PANE_OUTPUT" | grep -qE "Style:|bypass permissions|> $"; then + # Double-check we're not in an error state + if ! echo "$PANE_OUTPUT" | grep -qiE "error.*claude|crash|failed to start"; then + echo "Claude ready for input" + return 0 + fi + fi + fi + + # Calculate hash of current output to detect changes (avoid repeated handling) + local CURRENT_HASH=$(echo "$PANE_OUTPUT" | md5sum | cut -d' ' -f1) + + # Only handle prompts if output has changed (prevents repeated sends) + if [ "$CURRENT_HASH" != "$LAST_PROMPT_HASH" ]; then + LAST_PROMPT_HASH="$CURRENT_HASH" + + # FALLBACK: Handle interactive prompts if they appear (edge cases) + + # Handle Style selection prompt (send Enter to accept default) + if echo "$PANE_OUTPUT" | grep -qE "Choose a style|Style:.*arrow|Use arrow keys"; then + echo "Handling style selection prompt..." + tmux send-keys -t "$SESSION" C-m # Accept default style + sleep 1 + continue + fi + + # Handle trust prompt + if echo "$PANE_OUTPUT" | grep -qE "Trust this project|trust.*\[y/N\]|Do you trust"; then + echo "Handling trust prompt..." + tmux send-keys -t "$SESSION" "y" C-m + sleep 1 + continue + fi + + # Handle permission bypass confirmation (only if prompt is fresh) + if echo "$PANE_OUTPUT" | grep -qE "bypass permission.*Continue|dangerously.*\[y/N\]|skip.*permission"; then + echo "Handling permission bypass prompt..." + tmux send-keys -t "$SESSION" "y" C-m + sleep 2 # Longer wait for this prompt + continue + fi + + # Handle any other y/N prompt as fallback (but NOT after Claude is up) + # Only if we haven't seen the Claude banner yet + if ! echo "$PANE_OUTPUT" | grep -qE "Claude Code v[0-9]"; then + if echo "$PANE_OUTPUT" | grep -qE "\[y/N\]|\[Y/n\]"; then + echo "Handling unknown y/N prompt..." + tmux send-keys -t "$SESSION" "y" C-m + sleep 1 + continue + fi + fi + fi + + # Timeout check + local ELAPSED=$(($(date +%s) - START)) + if [ $ELAPSED -gt $TIMEOUT ]; then + echo "Timeout: Claude did not initialize within ${TIMEOUT}s" + echo "Capturing debug output..." + tmux capture-pane -t "$SESSION" -p > "/tmp/spawn-agent-${SESSION}-failure.log" 2>&1 + echo "Debug output saved to /tmp/spawn-agent-${SESSION}-failure.log" + echo "Last captured output:" + echo "$PANE_OUTPUT" | tail -20 + return 1 + fi + + sleep 0.5 + done +} + +# Function: Send task to Claude with robust handling for long/multi-line content +send_task_to_claude() { + local SESSION=$1 + local TASK=$2 + local MAX_LINE_LENGTH=500 + + # Calculate task length + local TASK_LENGTH=${#TASK} + + echo "Sending task to agent (${TASK_LENGTH} chars)..." + + # For short tasks, send directly with -l flag + if [ "$TASK_LENGTH" -lt "$MAX_LINE_LENGTH" ]; then + tmux send-keys -t "$SESSION" -l "$TASK" + tmux send-keys -t "$SESSION" C-m + return 0 + fi + + # For longer tasks, send line by line with small delays + echo "Task is long, sending line by line..." + + # Write task to temp file for reliable line processing + local TASK_FILE=$(mktemp) + echo "$TASK" > "$TASK_FILE" + + # Send each line + while IFS= read -r line || [ -n "$line" ]; do + # Send the line using literal mode + tmux send-keys -t "$SESSION" -l "$line" + tmux send-keys -t "$SESSION" C-m + sleep 0.05 # Small delay between lines + done < "$TASK_FILE" + + # Final Enter to submit the complete task + tmux send-keys -t "$SESSION" C-m + + # Cleanup + rm -f "$TASK_FILE" + + return 0 +} + +# Parse arguments +TASK="${1:-}" +shift || true + +# Simple mode flags +WITH_HANDOVER=false +WITH_WORKTREE=false + +# Orchestration mode flags +ORCH_SESSION="" +ORCH_WAVE="" +ORCH_WORKSTREAM="" +ORCH_AGENT_TYPE="" +ORCH_DAG_NODE="" +ORCH_DEPENDENCIES="" + +# Parse flags +while [[ $# -gt 0 ]]; do + case $1 in + --with-handover) + WITH_HANDOVER=true + shift + ;; + --with-worktree) + WITH_WORKTREE=true + shift + ;; + # Orchestration flags + --orchestration-session) + ORCH_SESSION="$2" + shift 2 + ;; + --wave) + ORCH_WAVE="$2" + shift 2 + ;; + --workstream) + ORCH_WORKSTREAM="$2" + shift 2 + ;; + --agent-type) + ORCH_AGENT_TYPE="$2" + shift 2 + ;; + --dag-node) + ORCH_DAG_NODE="$2" + shift 2 + ;; + --dependencies) + ORCH_DEPENDENCIES="$2" + shift 2 + ;; + *) + shift + ;; + esac +done + +if [ -z "$TASK" ]; then + echo "Task description required" + echo "" + echo "Usage: spawn.sh \"task description\" [options]" + echo "" + echo "Simple Mode Options:" + echo " --with-handover Include git context in task" + echo " --with-worktree Run in isolated git worktree" + echo "" + echo "Orchestration Mode Options (for /m-implement integration):" + echo " --orchestration-session Orchestration session ID" + echo " --wave Wave number in DAG" + echo " --workstream Workstream ID" + echo " --agent-type Specialized agent type" + echo " --dag-node DAG node ID" + echo " --dependencies JSON array of dependencies" + exit 1 +fi + +# Determine if we're in orchestration mode +ORCHESTRATION_MODE=false +if [ -n "$ORCH_SESSION" ]; then + ORCHESTRATION_MODE=true + # Orchestration mode implies worktree by default + WITH_WORKTREE=true +fi + +# Generate session info +TASK_ID=$(date +%s) +if [ "$ORCHESTRATION_MODE" = true ] && [ -n "$ORCH_WORKSTREAM" ]; then + SESSION="agent-${ORCH_WORKSTREAM}-${TASK_ID}" +else + SESSION="agent-${TASK_ID}" +fi + +# Setup working directory (worktree or current) +if [ "$WITH_WORKTREE" = true ]; then + # Detect transcrypt (informational only) + if git config --get-regexp '^transcrypt\.' >/dev/null 2>&1; then + echo "Transcrypt detected - worktree will inherit encryption config" + fi + + # Get current branch as base + CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || echo "HEAD") + + # Generate task slug from task description or workstream + if [ -n "$ORCH_WORKSTREAM" ]; then + TASK_SLUG="$ORCH_WORKSTREAM" + else + TASK_SLUG=$(echo "$TASK" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | tr -s ' ' '-' | cut -c1-40 | sed 's/-$//') + fi + + # Create worktree with task slug + echo "Creating isolated git worktree..." + WORK_DIR=$(create_agent_worktree "$TASK_ID" "$CURRENT_BRANCH" "$TASK_SLUG") + AGENT_BRANCH="agent/agent-${TASK_ID}" + + echo "Worktree created:" + echo " Directory: $WORK_DIR" + echo " Branch: $AGENT_BRANCH" + echo " Base: $CURRENT_BRANCH" + echo "" +else + WORK_DIR=$(pwd) + AGENT_BRANCH="" +fi + +echo "Spawning Claude agent in tmux session..." +echo "" + +# Generate handover if requested +HANDOVER_CONTENT="" +if [ "$WITH_HANDOVER" = true ]; then + echo "Generating handover context..." + + # Get current branch and recent commits + CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown") + RECENT_COMMITS=$(git log --oneline -5 2>/dev/null || echo "No git history") + GIT_STATUS=$(git status -sb 2>/dev/null || echo "Not a git repo") + + # Create handover content + HANDOVER_CONTENT=$(cat << EOF + +# Handover Context + +## Current State +- Branch: $CURRENT_BRANCH +- Directory: $WORK_DIR +- Time: $(date) + +## Recent Commits +$RECENT_COMMITS + +## Git Status +$GIT_STATUS + +## Your Task +$TASK + +--- +Please review the above context and proceed with the task. +EOF +) + + echo "Handover generated" + echo "" +fi + +# Create tmux session +tmux new-session -d -s "$SESSION" -c "$WORK_DIR" + +# Verify session creation +if ! tmux has-session -t "$SESSION" 2>/dev/null; then + echo "Failed to create tmux session" + exit 1 +fi + +echo "Created tmux session: $SESSION" +echo "" + +# Start Claude Code in the session +tmux send-keys -t "$SESSION" "claude --dangerously-skip-permissions" C-m + +# Wait for Claude to be ready +if ! wait_for_claude_ready "$SESSION"; then + echo "Failed to start Claude agent - cleaning up..." + tmux kill-session -t "$SESSION" 2>/dev/null + exit 1 +fi + +# Additional small delay for UI stabilization +sleep 0.5 + +# Send handover context if generated (line-by-line to handle newlines) +if [ "$WITH_HANDOVER" = true ]; then + echo "Sending handover context to agent..." + + # Send line-by-line to handle multi-line content properly + echo "$HANDOVER_CONTENT" | while IFS= read -r LINE || [ -n "$LINE" ]; do + # Use -l flag to send literal text (handles special characters) + tmux send-keys -t "$SESSION" -l "$LINE" + tmux send-keys -t "$SESSION" C-m + sleep 0.05 # Small delay between lines + done + + # Final Enter to submit + tmux send-keys -t "$SESSION" C-m + sleep 0.5 +fi + +# Build the full task with orchestration context if applicable +FULL_TASK="$TASK" +if [ "$ORCHESTRATION_MODE" = true ]; then + FULL_TASK="$TASK + +ORCHESTRATION CONTEXT: +- Session: $ORCH_SESSION +- Wave: $ORCH_WAVE +- Workstream: $ORCH_WORKSTREAM +- Agent Role: Act as a ${ORCH_AGENT_TYPE:-general-purpose} agent. + +CRITICAL REQUIREMENTS: +- Work in worktree: $WORK_DIR +- Branch: $AGENT_BRANCH +- When complete: Run tests, commit with clear message, then output 'TASK COMPLETE' on a new line + +When finished, commit all changes and output 'TASK COMPLETE' to signal completion." +fi + +# Send the task using robust helper function +send_task_to_claude "$SESSION" "$FULL_TASK" + +# Small delay for Claude to start processing +sleep 1 + +# Verify task was received +CURRENT_OUTPUT=$(tmux capture-pane -t "$SESSION" -p 2>/dev/null || echo "") +if echo "$CURRENT_OUTPUT" | grep -qE "Thought for|Forming|Creating|Implement|Planning"; then + echo "Task received and processing" +elif echo "$CURRENT_OUTPUT" | grep -qE "error|failed|crash"; then + echo "Warning: Detected error in agent output" + echo "Last 10 lines of output:" + tmux capture-pane -t "$SESSION" -p | tail -10 +else + echo "Task sent (unable to confirm receipt - agent may still be starting)" +fi + +echo "" +echo "========================================" +echo "Agent spawned successfully!" +echo "========================================" +echo "" +echo "Session: $SESSION" +echo "Task: ${TASK:0:60}$([ ${#TASK} -gt 60 ] && echo '...')" +echo "Directory: $WORK_DIR" +[ -n "$AGENT_BRANCH" ] && echo "Branch: $AGENT_BRANCH" + +if [ "$ORCHESTRATION_MODE" = true ]; then + echo "" + echo "Orchestration:" + echo " Session: $ORCH_SESSION" + echo " Wave: $ORCH_WAVE" + echo " Workstream: $ORCH_WORKSTREAM" + [ -n "$ORCH_AGENT_TYPE" ] && echo " Agent Type: $ORCH_AGENT_TYPE" +fi + +echo "" +echo "Commands:" +echo " Monitor: tmux attach -t $SESSION" +echo " Output: tmux capture-pane -t $SESSION -p -S -50" +echo " Kill: tmux kill-session -t $SESSION" +echo "" + +# Save metadata +mkdir -p ~/.claude/agents + +# Build orchestration JSON block +ORCH_JSON="null" +if [ "$ORCHESTRATION_MODE" = true ]; then + ORCH_JSON=$(cat < ~/.claude/agents/${SESSION}.json < "${ORCH_STATE_FILE}.tmp" && \ + mv "${ORCH_STATE_FILE}.tmp" "$ORCH_STATE_FILE" + fi +fi + +# Output session ID for programmatic use +echo "AGENT_SESSION=$SESSION" + +exit 0 diff --git a/claude-code/skills/agent-orchestrator/scripts/core/status.sh b/claude-code/skills/agent-orchestrator/scripts/core/status.sh new file mode 100755 index 0000000..2dfddb1 --- /dev/null +++ b/claude-code/skills/agent-orchestrator/scripts/core/status.sh @@ -0,0 +1,330 @@ +#!/bin/bash + +# ABOUTME: Agent status script - shows status of all running Claude agents +# Part of the agent-orchestrator skill + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(dirname "$SCRIPT_DIR")" +TOOL_DIR="$(dirname "$(dirname "$SKILL_DIR")")" + +# Output mode: compact (default), detailed, json +OUTPUT_MODE="compact" +SPECIFIC_SESSION="" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --detailed|-d) + OUTPUT_MODE="detailed" + shift + ;; + --json|-j) + OUTPUT_MODE="json" + shift + ;; + agent-*) + SPECIFIC_SESSION="$1" + shift + ;; + *) + shift + ;; + esac +done + +# Check if tmux is available +if ! command -v tmux &> /dev/null; then + echo "tmux is not installed" + exit 1 +fi + +# Detect agent status from tmux output +detect_agent_status() { + local SESSION=$1 + + if ! tmux has-session -t "$SESSION" 2>/dev/null; then + echo "killed" + return 0 + fi + + local OUTPUT=$(tmux capture-pane -t "$SESSION" -p -S -100 2>/dev/null || echo "") + + # Check for completion indicators + if echo "$OUTPUT" | grep -qiE "complete|done|finished|All.*tasks.*complete"; then + if echo "$OUTPUT" | grep -qE "git.*commit|Commit.*created|committed"; then + echo "complete" + return 0 + fi + fi + + # Check for failure indicators + if echo "$OUTPUT" | grep -qiE "error|failed|fatal|Error:"; then + echo "failed" + return 0 + fi + + # Check for idle (at Claude prompt waiting for input) + local LAST_LINE=$(echo "$OUTPUT" | tail -5) + if echo "$LAST_LINE" | grep -qE "^>|Style:|bypass permissions|Human:"; then + echo "idle" + return 0 + fi + + # Check for thinking/processing + if echo "$OUTPUT" | grep -qE "Thought for|Planning|Implementing|Creating|Reading|Writing"; then + echo "active" + return 0 + fi + + # Default to active + echo "active" +} + +# Calculate runtime from created timestamp +calculate_runtime() { + local CREATED=$1 + local NOW=$(date +%s) + + # Parse ISO timestamp + local CREATED_TS=$(date -j -f "%Y-%m-%dT%H:%M:%S" "${CREATED:0:19}" +%s 2>/dev/null || echo "$NOW") + local DIFF=$((NOW - CREATED_TS)) + + if [ $DIFF -lt 60 ]; then + echo "${DIFF}s" + elif [ $DIFF -lt 3600 ]; then + echo "$((DIFF / 60))m" + else + echo "$((DIFF / 3600))h $((DIFF % 3600 / 60))m" + fi +} + +# Get all agent sessions +get_agent_sessions() { + tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^agent-" || true +} + +# Get agent metadata +get_agent_metadata() { + local SESSION=$1 + local METADATA_FILE="$HOME/.claude/agents/${SESSION}.json" + + if [ -f "$METADATA_FILE" ]; then + cat "$METADATA_FILE" + else + echo "{}" + fi +} + +# Output functions + +output_compact() { + local SESSIONS=$1 + + if [ -z "$SESSIONS" ]; then + echo "No agent sessions running" + return 0 + fi + + local COUNT=$(echo "$SESSIONS" | wc -l | tr -d ' ') + echo "$COUNT active agent(s):" + echo "" + + while IFS= read -r SESSION; do + [ -z "$SESSION" ] && continue + + local STATUS=$(detect_agent_status "$SESSION") + local METADATA=$(get_agent_metadata "$SESSION") + local TASK=$(echo "$METADATA" | jq -r '.task // "unknown"' 2>/dev/null) + local CREATED=$(echo "$METADATA" | jq -r '.created // ""' 2>/dev/null) + local WITH_WORKTREE=$(echo "$METADATA" | jq -r '.with_worktree // false' 2>/dev/null) + + local RUNTIME="" + if [ -n "$CREATED" ] && [ "$CREATED" != "null" ]; then + RUNTIME=" ($(calculate_runtime "$CREATED"))" + fi + + local STATUS_ICON="" + case $STATUS in + active) STATUS_ICON="running" ;; + idle) STATUS_ICON="idle" ;; + complete) STATUS_ICON="complete" ;; + failed) STATUS_ICON="FAILED" ;; + killed) STATUS_ICON="killed" ;; + esac + + local WORKTREE_FLAG="" + [ "$WITH_WORKTREE" = "true" ] && WORKTREE_FLAG=" [worktree]" + + echo "$SESSION ($STATUS_ICON$RUNTIME)$WORKTREE_FLAG" + + # Truncate task to 60 chars + local SHORT_TASK="${TASK:0:60}" + [ ${#TASK} -gt 60 ] && SHORT_TASK="${SHORT_TASK}..." + echo " Task: $SHORT_TASK" + + done <<< "$SESSIONS" + + echo "" + echo "Use --detailed for full information" +} + +output_detailed() { + local SESSIONS=$1 + + if [ -z "$SESSIONS" ]; then + echo "No agent sessions running" + return 0 + fi + + local COUNT=$(echo "$SESSIONS" | wc -l | tr -d ' ') + echo "========================================" + echo "Agent Status Report" + echo "========================================" + echo "" + echo "Total Agents: $COUNT" + echo "" + + local INDEX=1 + while IFS= read -r SESSION; do + [ -z "$SESSION" ] && continue + + local STATUS=$(detect_agent_status "$SESSION") + local METADATA=$(get_agent_metadata "$SESSION") + local TASK=$(echo "$METADATA" | jq -r '.task // "unknown"' 2>/dev/null) + local DIRECTORY=$(echo "$METADATA" | jq -r '.directory // "unknown"' 2>/dev/null) + local CREATED=$(echo "$METADATA" | jq -r '.created // ""' 2>/dev/null) + local WITH_WORKTREE=$(echo "$METADATA" | jq -r '.with_worktree // false' 2>/dev/null) + local WITH_HANDOVER=$(echo "$METADATA" | jq -r '.with_handover // false' 2>/dev/null) + local WORKTREE_BRANCH=$(echo "$METADATA" | jq -r '.worktree_branch // ""' 2>/dev/null) + + echo "----------------------------------------" + echo "## $INDEX. $SESSION" + echo "----------------------------------------" + echo "" + echo "Task: $TASK" + echo "" + + # Status with icon + case $STATUS in + active) echo "Status: Running (processing)" ;; + idle) echo "Status: Idle (waiting for input)" ;; + complete) echo "Status: Complete" ;; + failed) echo "Status: FAILED (check output)" ;; + killed) echo "Status: Killed (session ended)" ;; + esac + + echo "Directory: $DIRECTORY" + + if [ -n "$CREATED" ] && [ "$CREATED" != "null" ]; then + echo "Created: $CREATED" + echo "Runtime: $(calculate_runtime "$CREATED")" + fi + + echo "Handover: $WITH_HANDOVER" + + if [ "$WITH_WORKTREE" = "true" ]; then + echo "Worktree: Yes" + [ -n "$WORKTREE_BRANCH" ] && [ "$WORKTREE_BRANCH" != "null" ] && echo "Branch: $WORKTREE_BRANCH" + fi + + echo "" + echo "Commands:" + echo " Attach: tmux attach -t $SESSION" + echo " Output: tmux capture-pane -t $SESSION -p -S -50" + echo " Kill: tmux kill-session -t $SESSION" + + if [ "$WITH_WORKTREE" = "true" ]; then + echo " Cleanup: bash \"$SKILL_DIR/scripts/cleanup.sh\" $SESSION --merge" + else + echo " Cleanup: bash \"$SKILL_DIR/scripts/cleanup.sh\" $SESSION" + fi + + echo "" + + # Show last few lines of output + if [ "$STATUS" != "killed" ]; then + echo "Recent Output:" + echo "---" + tmux capture-pane -t "$SESSION" -p -S -10 2>/dev/null | tail -5 || echo "(unable to capture)" + echo "---" + fi + + echo "" + INDEX=$((INDEX + 1)) + + done <<< "$SESSIONS" +} + +output_json() { + local SESSIONS=$1 + + echo "{" + echo " \"agents\": [" + + local FIRST=true + while IFS= read -r SESSION; do + [ -z "$SESSION" ] && continue + + [ "$FIRST" = false ] && echo "," + FIRST=false + + local STATUS=$(detect_agent_status "$SESSION") + local METADATA=$(get_agent_metadata "$SESSION") + + local TASK=$(echo "$METADATA" | jq -r '.task // "unknown"' 2>/dev/null) + local DIRECTORY=$(echo "$METADATA" | jq -r '.directory // "unknown"' 2>/dev/null) + local CREATED=$(echo "$METADATA" | jq -r '.created // null' 2>/dev/null) + local WITH_WORKTREE=$(echo "$METADATA" | jq -r '.with_worktree // false' 2>/dev/null) + local WORKTREE_BRANCH=$(echo "$METADATA" | jq -r '.worktree_branch // null' 2>/dev/null) + + echo " {" + echo " \"session\": \"$SESSION\"," + echo " \"task\": \"$TASK\"," + echo " \"status\": \"$STATUS\"," + echo " \"directory\": \"$DIRECTORY\"," + echo " \"created\": $CREATED," + echo " \"with_worktree\": $WITH_WORKTREE," + echo " \"worktree_branch\": $WORKTREE_BRANCH" + echo -n " }" + + done <<< "$SESSIONS" + + echo "" + echo " ]," + echo " \"summary\": {" + + local TOTAL=$(echo "$SESSIONS" | grep -c "^agent-" || echo "0") + echo " \"total\": $TOTAL" + + echo " }" + echo "}" +} + +# Main + +# Get sessions to check +if [ -n "$SPECIFIC_SESSION" ]; then + if tmux has-session -t "$SPECIFIC_SESSION" 2>/dev/null; then + SESSIONS="$SPECIFIC_SESSION" + else + echo "Session not found: $SPECIFIC_SESSION" + exit 1 + fi +else + SESSIONS=$(get_agent_sessions) +fi + +# Output based on mode +case "$OUTPUT_MODE" in + compact) + output_compact "$SESSIONS" + ;; + detailed) + output_detailed "$SESSIONS" + ;; + json) + output_json "$SESSIONS" + ;; +esac diff --git a/claude-code/skills/agent-orchestrator/scripts/orchestration/merge-waves.sh b/claude-code/skills/agent-orchestrator/scripts/orchestration/merge-waves.sh new file mode 100755 index 0000000..9ac9e62 --- /dev/null +++ b/claude-code/skills/agent-orchestrator/scripts/orchestration/merge-waves.sh @@ -0,0 +1,206 @@ +#!/bin/bash + +# ABOUTME: Merges completed wave worktrees into the main branch +# Part of the agent-orchestrator skill - orchestration mode + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TOOL_DIR="$(dirname "$(dirname "$(dirname "$SCRIPT_DIR")")")" + +# Source utilities +source "${TOOL_DIR}/utils/git-worktree-utils.sh" + +# Parse arguments +SESSION_ID="${1:-}" +DRY_RUN=false +FORCE=false +WAVE_FILTER="" + +while [[ $# -gt 0 ]]; do + case $1 in + --dry-run|-n) + DRY_RUN=true + shift + ;; + --force|-f) + FORCE=true + shift + ;; + --wave) + WAVE_FILTER="$2" + shift 2 + ;; + *) + if [ -z "$SESSION_ID" ] || [[ "$1" != -* ]]; then + SESSION_ID="$1" + fi + shift + ;; + esac +done + +if [ -z "$SESSION_ID" ]; then + echo "Usage: merge-waves.sh [options]" + echo "" + echo "Merges completed agent worktrees into current branch." + echo "" + echo "Options:" + echo " --dry-run, -n Show what would be merged without merging" + echo " --force, -f Force merge even if session not complete" + echo " --wave N Only merge specific wave" + echo "" + echo "Example:" + echo " merge-waves.sh orch-1705161234" + echo " merge-waves.sh orch-1705161234 --dry-run" + echo " merge-waves.sh orch-1705161234 --wave 1" + exit 1 +fi + +# Load session +ORCH_DIR="$HOME/.claude/orchestration/state" +SESSION_FILE="$ORCH_DIR/session-${SESSION_ID}.json" + +if [ ! -f "$SESSION_FILE" ]; then + echo "Error: Session not found: $SESSION_ID" + exit 1 +fi + +STATUS=$(jq -r '.status' "$SESSION_FILE") + +if [ "$STATUS" != "complete" ] && [ "$FORCE" = false ]; then + echo "Error: Session is not complete (status: $STATUS)" + echo "Hint: Wait for all waves to complete or use --force" + exit 1 +fi + +echo "========================================" +echo "Merging Worktrees: $SESSION_ID" +echo "========================================" +echo "" + +# Get completed agents with worktrees +if [ -n "$WAVE_FILTER" ]; then + AGENTS=$(jq -r --arg w "$WAVE_FILTER" \ + '.agents | to_entries[] | select(.value.wave == ($w | tonumber) and .value.status == "complete") | .key' \ + "$SESSION_FILE") +else + AGENTS=$(jq -r '.agents | to_entries[] | select(.value.status == "complete") | .key' "$SESSION_FILE") +fi + +if [ -z "$AGENTS" ]; then + echo "No completed agents to merge" + exit 0 +fi + +# Count commits per agent +echo "Agents to merge:" +TOTAL_COMMITS=0 +for AGENT in $AGENTS; do + BRANCH=$(jq -r --arg a "$AGENT" '.agents[$a].branch' "$SESSION_FILE") + WORKSTREAM=$(jq -r --arg a "$AGENT" '.agents[$a].workstream_id' "$SESSION_FILE") + + # Count commits ahead of current branch + COMMITS=$(git rev-list --count HEAD.."$BRANCH" 2>/dev/null || echo "0") + TOTAL_COMMITS=$((TOTAL_COMMITS + COMMITS)) + + echo " $WORKSTREAM ($BRANCH): $COMMITS commit(s)" +done + +echo "" +echo "Total commits to merge: $TOTAL_COMMITS" +echo "" + +if [ "$DRY_RUN" = true ]; then + echo "[DRY RUN] Would merge the following branches:" + for AGENT in $AGENTS; do + BRANCH=$(jq -r --arg a "$AGENT" '.agents[$a].branch' "$SESSION_FILE") + echo " git merge $BRANCH" + done + echo "" + echo "Run without --dry-run to perform merge" + exit 0 +fi + +# Confirm merge +if [ "$TOTAL_COMMITS" -gt 0 ]; then + echo "Proceeding with merge..." + echo "" +fi + +# Merge each agent's branch +MERGED=0 +FAILED=0 + +for AGENT in $AGENTS; do + BRANCH=$(jq -r --arg a "$AGENT" '.agents[$a].branch' "$SESSION_FILE") + WORKSTREAM=$(jq -r --arg a "$AGENT" '.agents[$a].workstream_id' "$SESSION_FILE") + WORKTREE_DIR=$(jq -r --arg a "$AGENT" '.agents[$a].worktree_dir' "$SESSION_FILE") + + echo "Merging: $WORKSTREAM ($BRANCH)" + + # Check for uncommitted changes in worktree + if [ -d "$WORKTREE_DIR" ]; then + if ! git -C "$WORKTREE_DIR" diff --quiet 2>/dev/null; then + echo " Warning: Uncommitted changes in $WORKTREE_DIR" + if [ "$FORCE" = false ]; then + echo " Skipping (use --force to include)" + FAILED=$((FAILED + 1)) + continue + fi + fi + fi + + # Attempt merge + if git merge "$BRANCH" -m "Merge $WORKSTREAM from orchestration $SESSION_ID" 2>/dev/null; then + echo " Merged successfully" + MERGED=$((MERGED + 1)) + + # Mark agent as merged in session + jq --arg a "$AGENT" '.agents[$a].merged = true | .agents[$a].merged_at = (now | todate)' \ + "$SESSION_FILE" > "${SESSION_FILE}.tmp" && mv "${SESSION_FILE}.tmp" "$SESSION_FILE" + else + echo " Merge conflict!" + echo " Resolve conflicts and run: git merge --continue" + FAILED=$((FAILED + 1)) + + # Abort the merge to allow user to handle + git merge --abort 2>/dev/null || true + fi + + echo "" +done + +echo "========================================" +echo "Merge Summary" +echo "========================================" +echo "" +echo "Merged: $MERGED" +echo "Failed: $FAILED" +echo "" + +if [ "$FAILED" -gt 0 ]; then + echo "Some merges failed. Resolve conflicts manually and retry." + exit 1 +fi + +# Offer cleanup +echo "All branches merged successfully!" +echo "" +echo "Next steps:" +echo " 1. Run tests: npm test (or equivalent)" +echo " 2. Review changes: git log --oneline -$TOTAL_COMMITS" +echo " 3. Clean up worktrees: for each agent, run cleanup" +echo "" +echo "Cleanup commands:" +for AGENT in $AGENTS; do + WORKTREE_DIR=$(jq -r --arg a "$AGENT" '.agents[$a].worktree_dir' "$SESSION_FILE") + BRANCH=$(jq -r --arg a "$AGENT" '.agents[$a].branch' "$SESSION_FILE") + + if [ -d "$WORKTREE_DIR" ]; then + echo " git worktree remove $WORKTREE_DIR && git branch -d $BRANCH" + fi +done +echo "" +echo "Or clean all at once:" +echo " for wt in worktrees/agent-*; do git worktree remove \"\$wt\"; done" diff --git a/claude-code/skills/agent-orchestrator/scripts/orchestration/session-create.sh b/claude-code/skills/agent-orchestrator/scripts/orchestration/session-create.sh new file mode 100755 index 0000000..b9db0ac --- /dev/null +++ b/claude-code/skills/agent-orchestrator/scripts/orchestration/session-create.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +# ABOUTME: Creates an orchestration session from a DAG file +# Part of the agent-orchestrator skill - orchestration mode + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Parse arguments +DAG_FILE="${1:-}" +SESSION_ID="${2:-orch-$(date +%s)}" + +if [ -z "$DAG_FILE" ]; then + echo "Usage: session-create.sh [session-id]" + echo "" + echo "Creates an orchestration session from a DAG JSON file." + echo "" + echo "Arguments:" + echo " dag-file Path to DAG JSON file (from /m-plan)" + echo " session-id Optional session ID (default: orch-)" + echo "" + echo "Example:" + echo " session-create.sh plan.json" + echo " session-create.sh plan.json orch-my-feature" + exit 1 +fi + +# Validate DAG file exists +if [ ! -f "$DAG_FILE" ]; then + echo "Error: DAG file not found: $DAG_FILE" + exit 1 +fi + +# Validate DAG structure +if ! jq -e '.nodes' "$DAG_FILE" > /dev/null 2>&1; then + echo "Error: Invalid DAG file - missing 'nodes' field" + exit 1 +fi + +if ! jq -e '.waves' "$DAG_FILE" > /dev/null 2>&1; then + echo "Error: Invalid DAG file - missing 'waves' field" + echo "Hint: Run topological sort first or ensure /m-plan calculated waves" + exit 1 +fi + +# Create orchestration state directory +ORCH_DIR="$HOME/.claude/orchestration/state" +mkdir -p "$ORCH_DIR" + +# Copy DAG with session ID +DAG_DEST="$ORCH_DIR/dag-${SESSION_ID}.json" +cp "$DAG_FILE" "$DAG_DEST" + +# Extract metadata from DAG +TOTAL_NODES=$(jq '.nodes | length' "$DAG_FILE") +TOTAL_WAVES=$(jq '.waves | length' "$DAG_FILE") +TASK_DESC=$(jq -r '.task_description // "Multi-agent orchestration"' "$DAG_FILE") + +# Calculate max concurrent (largest wave) +MAX_CONCURRENT=$(jq '[.waves[].nodes | length] | max' "$DAG_FILE") + +# Create session state +SESSION_FILE="$ORCH_DIR/session-${SESSION_ID}.json" +cat > "$SESSION_FILE" < [options]" + echo "" + echo "Shows status of an orchestration session." + echo "" + echo "Options:" + echo " --detailed, -d Show detailed agent information" + echo " --json, -j Output as JSON" + echo " --list, -l List all sessions" + echo "" + echo "Examples:" + echo " session-status.sh --list" + echo " session-status.sh orch-1705161234" + echo " session-status.sh orch-1705161234 --detailed" + exit 1 +fi + +# Load session +SESSION_FILE="$ORCH_DIR/session-${SESSION_ID}.json" +DAG_FILE="$ORCH_DIR/dag-${SESSION_ID}.json" + +if [ ! -f "$SESSION_FILE" ]; then + echo "Error: Session not found: $SESSION_ID" + echo "Hint: Use --list to see available sessions" + exit 1 +fi + +# JSON output +if [ "${JSON_OUTPUT:-false}" = true ]; then + jq '.' "$SESSION_FILE" + exit 0 +fi + +# Load session data +STATUS=$(jq -r '.status' "$SESSION_FILE") +CREATED=$(jq -r '.created_at' "$SESSION_FILE") +TASK_DESC=$(jq -r '.task_description' "$SESSION_FILE") +TOTAL_WAVES=$(jq -r '.total_waves' "$SESSION_FILE") +CURRENT_WAVE=$(jq -r '.current_wave' "$SESSION_FILE") +TOTAL_NODES=$(jq -r '.total_nodes' "$SESSION_FILE") +TOTAL_COST=$(jq -r '[.agents[].cost_usd] | add // 0' "$SESSION_FILE") + +# Header +echo "========================================" +echo "Orchestration: $SESSION_ID" +echo "========================================" +echo "" +echo "Task: $TASK_DESC" +echo "" + +# Status with icon +case $STATUS in + pending) echo "Status: [ ] Pending" ;; + running) echo "Status: [>] Running (Wave $CURRENT_WAVE/$TOTAL_WAVES)" ;; + complete) echo "Status: [x] Complete" ;; + failed) echo "Status: [!] Failed" ;; +esac + +echo "Created: $CREATED" +echo "Cost: \$$TOTAL_COST" +echo "" + +# Waves summary +echo "Waves:" +jq -r '.waves_status[] | + if .status == "pending" then " [ ] Wave \(.wave_number)" + elif .status == "active" then " [>] Wave \(.wave_number) (running since \(.started_at))" + elif .status == "complete" then " [x] Wave \(.wave_number) (completed \(.completed_at))" + elif .status == "failed" then " [!] Wave \(.wave_number) (failed)" + else " [?] Wave \(.wave_number)" + end' "$SESSION_FILE" + +echo "" + +# Agents summary +AGENT_COUNT=$(jq '.agents | length' "$SESSION_FILE") +if [ "$AGENT_COUNT" -gt 0 ]; then + echo "Agents ($AGENT_COUNT):" + + if [ "$DETAILED" = true ]; then + # Detailed agent list + jq -r '.agents | to_entries[] | + " \(.key)" + + "\n Status: \(.value.status)" + + "\n Wave: \(.value.wave)" + + "\n Workstream: \(.value.workstream_id)" + + "\n Cost: $\(.value.cost_usd)" + + "\n Worktree: \(.value.worktree_dir)" + + "\n Last Updated: \(.value.last_updated)" + + "\n"' "$SESSION_FILE" + else + # Compact agent list + jq -r '.agents | to_entries[] | + if .value.status == "active" then " [>] \(.key) (wave \(.value.wave), $\(.value.cost_usd))" + elif .value.status == "complete" then " [x] \(.key) (wave \(.value.wave), $\(.value.cost_usd))" + elif .value.status == "failed" then " [!] \(.key) (wave \(.value.wave), $\(.value.cost_usd))" + elif .value.status == "idle" then " [~] \(.key) (wave \(.value.wave), $\(.value.cost_usd))" + else " [ ] \(.key) (wave \(.value.wave))" + end' "$SESSION_FILE" + fi +else + echo "Agents: (none spawned yet)" +fi + +echo "" + +# Next actions based on status +echo "Actions:" +case $STATUS in + pending) + echo " Start: bash \"$SCRIPT_DIR/wave-spawn.sh\" $SESSION_ID 1" + ;; + running) + echo " Monitor: bash \"$SCRIPT_DIR/wave-monitor.sh\" $SESSION_ID $CURRENT_WAVE" + if [ "$CURRENT_WAVE" -lt "$TOTAL_WAVES" ]; then + NEXT=$((CURRENT_WAVE + 1)) + echo " Next: bash \"$SCRIPT_DIR/wave-spawn.sh\" $SESSION_ID $NEXT" + fi + ;; + complete) + echo " Merge: bash \"$SCRIPT_DIR/merge-waves.sh\" $SESSION_ID" + echo " Archive: mv \"$SESSION_FILE\" \"$ORCH_DIR/archive/\"" + ;; + failed) + echo " Retry: bash \"$SCRIPT_DIR/wave-spawn.sh\" $SESSION_ID $CURRENT_WAVE" + echo " Check agents for errors and fix issues" + ;; +esac + +echo "" diff --git a/claude-code/skills/agent-orchestrator/scripts/orchestration/wave-monitor.sh b/claude-code/skills/agent-orchestrator/scripts/orchestration/wave-monitor.sh new file mode 100755 index 0000000..f598c92 --- /dev/null +++ b/claude-code/skills/agent-orchestrator/scripts/orchestration/wave-monitor.sh @@ -0,0 +1,270 @@ +#!/bin/bash + +# ABOUTME: Monitors a wave of agents until all complete or one fails +# Part of the agent-orchestrator skill - orchestration mode + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CORE_DIR="$(dirname "$SCRIPT_DIR")/core" + +# Parse arguments +SESSION_ID="${1:-}" +WAVE_NUMBER="${2:-}" +TIMEOUT_MINUTES="${3:-120}" +POLL_INTERVAL="${4:-30}" + +if [ -z "$SESSION_ID" ] || [ -z "$WAVE_NUMBER" ]; then + echo "Usage: wave-monitor.sh [timeout-minutes] [poll-interval]" + echo "" + echo "Monitors all agents in a wave until complete or failed." + echo "" + echo "Arguments:" + echo " session-id Orchestration session ID" + echo " wave-number Wave number to monitor" + echo " timeout-minutes Max time to wait (default: 120)" + echo " poll-interval Seconds between checks (default: 30)" + echo "" + echo "Exit codes:" + echo " 0 All agents completed successfully" + echo " 1 One or more agents failed" + echo " 2 Timeout reached" + exit 1 +fi + +# Load session +ORCH_DIR="$HOME/.claude/orchestration/state" +SESSION_FILE="$ORCH_DIR/session-${SESSION_ID}.json" +DAG_FILE="$ORCH_DIR/dag-${SESSION_ID}.json" + +if [ ! -f "$SESSION_FILE" ]; then + echo "Error: Session not found: $SESSION_ID" + exit 1 +fi + +# Get wave agents from session state +get_wave_agents() { + jq -r --arg w "$WAVE_NUMBER" \ + '.agents | to_entries[] | select(.value.wave == ($w | tonumber)) | .key' \ + "$SESSION_FILE" +} + +# Detect agent status from tmux +detect_agent_status() { + local AGENT_SESSION=$1 + + if ! tmux has-session -t "$AGENT_SESSION" 2>/dev/null; then + echo "killed" + return 0 + fi + + local OUTPUT=$(tmux capture-pane -t "$AGENT_SESSION" -p -S -100 2>/dev/null || echo "") + + # Check for explicit completion signal + if echo "$OUTPUT" | grep -qE "TASK COMPLETE|All tasks complete|Successfully completed"; then + echo "complete" + return 0 + fi + + # Check for commit (strong indicator of completion) + if echo "$OUTPUT" | grep -qE "committed|Commit.*created|\[.*\].*commit"; then + # Also check if at prompt (truly done) + local LAST_LINES=$(echo "$OUTPUT" | tail -5) + if echo "$LAST_LINES" | grep -qE "^>|Human:|Style:"; then + echo "complete" + return 0 + fi + fi + + # Check for failure indicators + if echo "$OUTPUT" | grep -qiE "fatal error|FAILED|Error:.*cannot|panic:"; then + echo "failed" + return 0 + fi + + # Check for idle (at prompt, waiting) + local LAST_LINES=$(echo "$OUTPUT" | tail -5) + if echo "$LAST_LINES" | grep -qE "^>|Human:|Style:|bypass permissions"; then + echo "idle" + return 0 + fi + + echo "active" +} + +# Update agent status in session state +update_agent_status() { + local AGENT_SESSION=$1 + local STATUS=$2 + + jq --arg a "$AGENT_SESSION" --arg s "$STATUS" \ + '.agents[$a].status = $s | .agents[$a].last_updated = (now | todate)' \ + "$SESSION_FILE" > "${SESSION_FILE}.tmp" && mv "${SESSION_FILE}.tmp" "$SESSION_FILE" +} + +# Extract cost from tmux (if visible) +extract_cost() { + local AGENT_SESSION=$1 + local OUTPUT=$(tmux capture-pane -t "$AGENT_SESSION" -p -S -50 2>/dev/null || echo "") + local COST=$(echo "$OUTPUT" | grep -oE 'Cost:\s*\$[0-9]+\.[0-9]{2}' | tail -1 | grep -oE '[0-9]+\.[0-9]{2}' || echo "0.00") + echo "$COST" +} + +echo "========================================" +echo "Monitoring Wave $WAVE_NUMBER" +echo "========================================" +echo "" +echo "Session: $SESSION_ID" +echo "Timeout: ${TIMEOUT_MINUTES}m" +echo "Poll: ${POLL_INTERVAL}s" +echo "" + +START_TIME=$(date +%s) +TIMEOUT_SECONDS=$((TIMEOUT_MINUTES * 60)) + +while true; do + AGENTS=$(get_wave_agents) + + if [ -z "$AGENTS" ]; then + echo "Warning: No agents found for wave $WAVE_NUMBER" + echo "Hint: Run wave-spawn.sh first" + exit 1 + fi + + # Count statuses + COMPLETE=0 + FAILED=0 + ACTIVE=0 + IDLE=0 + TOTAL=0 + + echo "----------------------------------------" + echo "$(date '+%H:%M:%S') - Status Check" + echo "----------------------------------------" + + for AGENT_SESSION in $AGENTS; do + TOTAL=$((TOTAL + 1)) + STATUS=$(detect_agent_status "$AGENT_SESSION") + COST=$(extract_cost "$AGENT_SESSION") + + # Update session state + update_agent_status "$AGENT_SESSION" "$STATUS" + + # Update cost + jq --arg a "$AGENT_SESSION" --arg c "$COST" \ + '.agents[$a].cost_usd = ($c | tonumber)' \ + "$SESSION_FILE" > "${SESSION_FILE}.tmp" && mv "${SESSION_FILE}.tmp" "$SESSION_FILE" + + # Display status + case $STATUS in + complete) + echo " [DONE] $AGENT_SESSION (\$$COST)" + COMPLETE=$((COMPLETE + 1)) + ;; + failed) + echo " [FAIL] $AGENT_SESSION (\$$COST)" + FAILED=$((FAILED + 1)) + ;; + active) + echo " [....] $AGENT_SESSION (\$$COST)" + ACTIVE=$((ACTIVE + 1)) + ;; + idle) + echo " [IDLE] $AGENT_SESSION (\$$COST)" + IDLE=$((IDLE + 1)) + ;; + killed) + echo " [KILL] $AGENT_SESSION" + FAILED=$((FAILED + 1)) + ;; + esac + done + + echo "" + echo "Summary: $COMPLETE complete, $ACTIVE active, $IDLE idle, $FAILED failed (of $TOTAL)" + + # Calculate total cost + TOTAL_COST=$(jq '[.agents[].cost_usd] | add // 0' "$SESSION_FILE") + echo "Cost: \$$TOTAL_COST" + + # Check for completion + if [ "$COMPLETE" -eq "$TOTAL" ]; then + echo "" + echo "========================================" + echo "Wave $WAVE_NUMBER COMPLETE" + echo "========================================" + + # Update wave status + jq --arg w "$WAVE_NUMBER" \ + '(.waves_status[] | select(.wave_number == ($w | tonumber))) |= . + {status: "complete", completed_at: (now | todate)}' \ + "$SESSION_FILE" > "${SESSION_FILE}.tmp" && mv "${SESSION_FILE}.tmp" "$SESSION_FILE" + + # Check if this was the last wave + TOTAL_WAVES=$(jq '.total_waves' "$SESSION_FILE") + if [ "$WAVE_NUMBER" -eq "$TOTAL_WAVES" ]; then + jq '.status = "complete"' "$SESSION_FILE" > "${SESSION_FILE}.tmp" && mv "${SESSION_FILE}.tmp" "$SESSION_FILE" + echo "" + echo "All waves complete! Orchestration finished." + echo "" + echo "Next Steps:" + echo " Review: bash \"$SCRIPT_DIR/session-status.sh\" $SESSION_ID --detailed" + echo " Merge: bash \"$SCRIPT_DIR/merge-waves.sh\" $SESSION_ID" + else + NEXT_WAVE=$((WAVE_NUMBER + 1)) + echo "" + echo "Next wave: $NEXT_WAVE" + echo " Spawn: bash \"$SCRIPT_DIR/wave-spawn.sh\" $SESSION_ID $NEXT_WAVE" + fi + + exit 0 + fi + + # Check for failures + if [ "$FAILED" -gt 0 ]; then + echo "" + echo "========================================" + echo "Wave $WAVE_NUMBER FAILED" + echo "========================================" + echo "" + echo "$FAILED agent(s) failed. Check their output:" + for AGENT_SESSION in $AGENTS; do + STATUS=$(jq -r --arg a "$AGENT_SESSION" '.agents[$a].status' "$SESSION_FILE") + if [ "$STATUS" = "failed" ] || [ "$STATUS" = "killed" ]; then + echo " tmux attach -t $AGENT_SESSION" + fi + done + + # Update wave status + jq --arg w "$WAVE_NUMBER" \ + '(.waves_status[] | select(.wave_number == ($w | tonumber))) |= . + {status: "failed", completed_at: (now | todate)}' \ + "$SESSION_FILE" > "${SESSION_FILE}.tmp" && mv "${SESSION_FILE}.tmp" "$SESSION_FILE" + + jq '.status = "failed"' "$SESSION_FILE" > "${SESSION_FILE}.tmp" && mv "${SESSION_FILE}.tmp" "$SESSION_FILE" + + exit 1 + fi + + # Check timeout + ELAPSED=$(($(date +%s) - START_TIME)) + if [ "$ELAPSED" -gt "$TIMEOUT_SECONDS" ]; then + echo "" + echo "========================================" + echo "Wave $WAVE_NUMBER TIMEOUT" + echo "========================================" + echo "" + echo "Timeout reached after ${TIMEOUT_MINUTES} minutes" + echo "Active agents may still be running in background" + echo "" + echo "Options:" + echo " Continue monitoring: bash \"$SCRIPT_DIR/wave-monitor.sh\" $SESSION_ID $WAVE_NUMBER 60" + echo " Check status: bash \"$SCRIPT_DIR/session-status.sh\" $SESSION_ID" + + exit 2 + fi + + REMAINING=$((TIMEOUT_SECONDS - ELAPSED)) + echo "Timeout in: $((REMAINING / 60))m $((REMAINING % 60))s" + echo "" + echo "Next check in ${POLL_INTERVAL}s... (Ctrl+C to stop monitoring)" + sleep "$POLL_INTERVAL" +done diff --git a/claude-code/skills/agent-orchestrator/scripts/orchestration/wave-spawn.sh b/claude-code/skills/agent-orchestrator/scripts/orchestration/wave-spawn.sh new file mode 100755 index 0000000..a28f50c --- /dev/null +++ b/claude-code/skills/agent-orchestrator/scripts/orchestration/wave-spawn.sh @@ -0,0 +1,148 @@ +#!/bin/bash + +# ABOUTME: Spawns all agents in a DAG wave in parallel +# Part of the agent-orchestrator skill - orchestration mode + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CORE_DIR="$(dirname "$SCRIPT_DIR")/core" + +# Parse arguments +SESSION_ID="${1:-}" +WAVE_NUMBER="${2:-}" + +if [ -z "$SESSION_ID" ] || [ -z "$WAVE_NUMBER" ]; then + echo "Usage: wave-spawn.sh " + echo "" + echo "Spawns all agents in the specified wave of the DAG." + echo "" + echo "Arguments:" + echo " session-id Orchestration session ID" + echo " wave-number Wave number to spawn (1-based)" + echo "" + echo "Example:" + echo " wave-spawn.sh orch-1705161234 1" + exit 1 +fi + +# Load session and DAG +ORCH_DIR="$HOME/.claude/orchestration/state" +SESSION_FILE="$ORCH_DIR/session-${SESSION_ID}.json" +DAG_FILE="$ORCH_DIR/dag-${SESSION_ID}.json" + +if [ ! -f "$SESSION_FILE" ]; then + echo "Error: Session not found: $SESSION_ID" + echo "Hint: Create session first with session-create.sh" + exit 1 +fi + +if [ ! -f "$DAG_FILE" ]; then + echo "Error: DAG file not found: $DAG_FILE" + exit 1 +fi + +# Check if previous waves are complete (if wave > 1) +if [ "$WAVE_NUMBER" -gt 1 ]; then + PREV_WAVE=$((WAVE_NUMBER - 1)) + PREV_STATUS=$(jq -r --arg w "$PREV_WAVE" \ + '.waves_status[] | select(.wave_number == ($w | tonumber)) | .status' \ + "$SESSION_FILE") + + if [ "$PREV_STATUS" != "complete" ]; then + echo "Error: Wave $PREV_WAVE is not complete (status: $PREV_STATUS)" + echo "Hint: Wait for previous wave to complete before spawning next wave" + exit 1 + fi +fi + +# Get nodes in this wave +WAVE_NODES=$(jq -r --arg w "$WAVE_NUMBER" \ + '.waves[] | select(.wave_number == ($w | tonumber)) | .nodes[]' \ + "$DAG_FILE" 2>/dev/null) + +if [ -z "$WAVE_NODES" ]; then + echo "Error: No nodes found in wave $WAVE_NUMBER" + exit 1 +fi + +NODE_COUNT=$(echo "$WAVE_NODES" | wc -l | tr -d ' ') + +echo "========================================" +echo "Spawning Wave $WAVE_NUMBER" +echo "========================================" +echo "" +echo "Session: $SESSION_ID" +echo "Agents: $NODE_COUNT" +echo "" + +# Update wave status to active +jq --arg w "$WAVE_NUMBER" \ + '(.waves_status[] | select(.wave_number == ($w | tonumber))) |= . + {status: "active", started_at: (now | todate)}' \ + "$SESSION_FILE" > "${SESSION_FILE}.tmp" && mv "${SESSION_FILE}.tmp" "$SESSION_FILE" + +# Update session current wave +jq --arg w "$WAVE_NUMBER" '.current_wave = ($w | tonumber) | .status = "running"' \ + "$SESSION_FILE" > "${SESSION_FILE}.tmp" && mv "${SESSION_FILE}.tmp" "$SESSION_FILE" + +# Spawn each agent in parallel +PIDS=() +for NODE_ID in $WAVE_NODES; do + # Extract node details from DAG + NODE=$(jq --arg n "$NODE_ID" '.nodes[$n]' "$DAG_FILE") + + TASK=$(echo "$NODE" | jq -r '.task') + AGENT_TYPE=$(echo "$NODE" | jq -r '.agent_type // "backend-developer"') + WORKSTREAM=$(echo "$NODE" | jq -r '.workstream_id // $n' --arg n "$NODE_ID") + DEPENDENCIES=$(echo "$NODE" | jq -c '.dependencies // []') + + echo "Spawning: $NODE_ID ($AGENT_TYPE)" + + # Spawn agent using core spawn.sh with orchestration flags + bash "$CORE_DIR/spawn.sh" "$TASK" \ + --with-worktree \ + --orchestration-session "$SESSION_ID" \ + --wave "$WAVE_NUMBER" \ + --workstream "$WORKSTREAM" \ + --agent-type "$AGENT_TYPE" \ + --dag-node "$NODE_ID" \ + --dependencies "$DEPENDENCIES" & + + PIDS+=($!) + + # Small delay between spawns to avoid race conditions + sleep 1 +done + +echo "" +echo "Waiting for all agents to initialize..." + +# Wait for all spawn processes +FAILED=0 +for PID in "${PIDS[@]}"; do + if ! wait "$PID"; then + FAILED=$((FAILED + 1)) + fi +done + +if [ "$FAILED" -gt 0 ]; then + echo "" + echo "Warning: $FAILED agent(s) failed to spawn" +fi + +echo "" +echo "========================================" +echo "Wave $WAVE_NUMBER Spawned" +echo "========================================" +echo "" +echo "Agents spawned: $((NODE_COUNT - FAILED))/$NODE_COUNT" +echo "" +echo "Next Steps:" +echo " Monitor wave: bash \"$SCRIPT_DIR/wave-monitor.sh\" $SESSION_ID $WAVE_NUMBER" +echo " Full status: bash \"$SCRIPT_DIR/session-status.sh\" $SESSION_ID" +echo "" +echo "To attach to agents:" +for NODE_ID in $WAVE_NODES; do + WORKSTREAM=$(jq -r --arg n "$NODE_ID" '.nodes[$n].workstream_id // $n' "$DAG_FILE" --arg n "$NODE_ID") + echo " tmux attach -t agent-${WORKSTREAM}-*" +done diff --git a/claude-code/skills/frontend-design/SKILL.md b/claude-code/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..a928b72 --- /dev/null +++ b/claude-code/skills/frontend-design/SKILL.md @@ -0,0 +1,145 @@ +--- +name: frontend-design +description: Frontend design skill for UI/UX implementation - generates distinctive, production-grade interfaces +version: 1.0.0 +authors: + - Prithvi Rajasekaran + - Alexander Bricken +--- + +# Frontend Design Skill + +This skill helps create **distinctive, production-grade frontend interfaces** that avoid generic AI aesthetics. + +## Core Principles + +When building any frontend interface, follow these principles to create visually striking, memorable designs: + +### 1. Establish Bold Aesthetic Direction + +**Before writing any code**, define a clear aesthetic vision: + +- **Understand the purpose**: What is this interface trying to achieve? +- **Choose an extreme tone**: Select a distinctive aesthetic direction + - Brutalist: Raw, bold, functional + - Maximalist: Rich, layered, decorative + - Retro-futuristic: Nostalgic tech aesthetics + - Minimalist with impact: Powerful simplicity + - Neo-brutalist: Modern take on brutalism +- **Identify the unforgettable element**: What will make this design memorable? + +### 2. Implementation Standards + +Every interface you create should be: + +- ✅ **Production-grade and functional**: Code that works flawlessly +- ✅ **Visually striking and memorable**: Designs that stand out +- ✅ **Cohesive with clear aesthetic point-of-view**: Unified vision throughout + +## Critical Design Guidelines + +### Typography + +**Choose fonts that are beautiful, unique, and interesting.** + +- ❌ **AVOID**: Generic system fonts (Arial, Helvetica, default sans-serif) +- ✅ **USE**: Distinctive choices that elevate aesthetics + - Display fonts with character + - Unexpected font pairings + - Variable fonts for dynamic expression + - Fonts that reinforce your aesthetic direction + +### Color & Theme + +**Commit to cohesive aesthetics with CSS variables.** + +- ❌ **AVOID**: Generic color palettes, predictable combinations +- ✅ **USE**: Dominant colors with sharp accents + - Define comprehensive CSS custom properties + - Create mood through color temperature + - Use unexpected color combinations + - Build depth with tints, shades, and tones + +### Motion & Animation + +**Use high-impact animations that enhance the experience.** + +- For **HTML/CSS**: CSS-only animations (transforms, transitions, keyframes) +- For **React**: Motion library (Framer Motion, React Spring) +- ❌ **AVOID**: Generic fade-ins, boring transitions +- ✅ **USE**: High-impact moments + - Purposeful movement that guides attention + - Smooth, performant animations + - Delightful micro-interactions + - Entrance/exit animations with personality + +### Composition & Layout + +**Embrace unexpected layouts.** + +- ❌ **AVOID**: Predictable grids, centered everything, safe layouts +- ✅ **USE**: Bold composition choices + - Asymmetry + - Overlap + - Diagonal flow + - Unexpected whitespace + - Breaking the grid intentionally + +### Details & Atmosphere + +**Create atmosphere through thoughtful details.** + +- ✅ Textures and grain +- ✅ Sophisticated gradients +- ✅ Patterns and backgrounds +- ✅ Custom effects (blur, glow, shadows) +- ✅ Attention to spacing and rhythm + +## What to AVOID + +**Generic AI Design Patterns:** + +- ❌ Overused fonts (Inter, Roboto, Open Sans as defaults) +- ❌ Clichéd color schemes (purple gradients, generic blues) +- ❌ Predictable layouts (everything centered, safe grids) +- ❌ Cookie-cutter design that lacks context-specific character +- ❌ Lack of personality or point-of-view +- ❌ Generic animations (basic fade-ins everywhere) + +## Execution Philosophy + +**Show restraint or elaboration as the vision demands—execution quality matters most.** + +- Every design decision should serve the aesthetic direction +- Don't add complexity for its own sake +- Don't oversimplify when richness is needed +- Commit fully to your chosen direction +- Polish details relentlessly + +## Implementation Process + +When creating a frontend interface: + +1. **Define the aesthetic direction** (brutalist, maximalist, minimalist, etc.) +2. **Choose distinctive typography** that reinforces the aesthetic +3. **Establish color system** with CSS variables +4. **Design layout** with unexpected but purposeful composition +5. **Add motion** that enhances key moments +6. **Polish details** (textures, shadows, spacing) +7. **Review against principles** - is this distinctive and production-grade? + +## Examples of Strong Aesthetic Directions + +- **Brutalist Dashboard**: Monospace fonts, high contrast, grid-based, utilitarian +- **Retro-Futuristic Landing**: Neon colors, chrome effects, 80s sci-fi inspired +- **Minimalist with Impact**: Generous whitespace, bold typography, single accent color +- **Neo-Brutalist App**: Raw aesthetics, asymmetric layouts, bold shadows +- **Maximalist Content**: Rich layers, decorative elements, abundant color + +## Resources + +For deeper guidance on prompting for high-quality frontend design, see the [Frontend Aesthetics Cookbook](https://github.com/anthropics/claude-cookbooks/blob/main/coding/prompting_for_frontend_aesthetics.ipynb). + +--- + +**Remember**: The goal is to create interfaces that are both functionally excellent and visually unforgettable. Avoid generic AI aesthetics by committing to a clear, bold direction and executing it with meticulous attention to detail. diff --git a/claude-code/skills/retro-pdf/retro-pdf.md b/claude-code/skills/retro-pdf/retro-pdf.md new file mode 100644 index 0000000..40a2388 --- /dev/null +++ b/claude-code/skills/retro-pdf/retro-pdf.md @@ -0,0 +1,382 @@ +# Retro LaTeX-Style PDF Generation + +Convert markdown documents to professional, retro LaTeX-style PDFs with academic formatting. + +## Features + +- **LaTeX/Academic styling** - Libre Baskerville font (similar to Computer Modern), tight paragraph spacing +- **Clickable Table of Contents** - Auto-generated TOC with anchor links to all sections +- **Geek Corner / Example Corner boxes** - Open-ended table style with horizontal rules (title separated from content by thin line) +- **Academic tables** - Horizontal rules only, italic headers, no vertical borders +- **Full references section** - Proper citations with clickable URLs +- **No headers/footers** - Clean pages without date/time stamps + +## Usage + +When the user asks to convert a markdown document to a retro/LaTeX-style PDF, follow this process: + +### Step 1: Create the HTML Template + +Create an HTML file with this exact structure and styling: + +```html + + + + + {{DOCUMENT_TITLE}} + + + + + + +``` + +### Step 2: Structure the Content + +#### Title and Subtitle +```html +

Document Title

+

Subtitle or tagline in italics

+
+``` + +#### Table of Contents +```html + +
+``` + +#### Section Headings (with anchor IDs) +```html +

1. Section Title

+

1.1 Subsection Title

+

Step 1: Sub-subsection (italic)

+``` + +#### Geek Corner / Example Corner Box +```html +
+
+ Geek Corner: Title Here +
+
+ "The witty or insightful quote goes here in italics." +
+
+``` + +#### Tables (Academic Style) +```html + + + + + + + + + + + +
Column 1Column 2Column 3
DataDataData
+``` + +#### Footnote References (in text) +```html +[1] +``` + +#### References Section +```html +
+

References

+
+

[1] Author, Name. "Article Title." Publication (Year). https://url.com — Brief description.

+

[2] ...

+
+``` + +#### Key Takeaway +```html +

Key Takeaway: Important summary text here.

+``` + +### Step 3: Generate the PDF + +Use Chrome headless to generate the PDF without headers/footers: + +```bash +"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + --headless \ + --disable-gpu \ + --print-to-pdf="/path/to/output.pdf" \ + --print-to-pdf-no-header \ + --no-pdf-header-footer \ + "file:///path/to/input.html" +``` + +On Linux: +```bash +google-chrome --headless --disable-gpu --print-to-pdf="/path/to/output.pdf" --print-to-pdf-no-header --no-pdf-header-footer "file:///path/to/input.html" +``` + +## Style Guidelines + +1. **Paragraphs**: Keep text dense and justified. Collapse bullet points into flowing prose where appropriate. + +2. **Geek Corner**: Use for witty asides, technical insights, or memorable quotes. Always include: + - A catchy title (e.g., "Geek Corner: Amdahl's Law, App Edition") + - Italic content in quotes + +3. **Tables**: Use sparingly for data comparisons. Always use the open style (horizontal rules only). + +4. **References**: Always include full citations with: + - Author name + - Article/document title in quotes + - Publication/source and year + - Full clickable URL + - Brief description of what the reference covers + +5. **Spacing**: Keep tight - 6px paragraph margins, 1.35 line height. + +## Example Output + +See `/Users/stevengonsalvez/d/btg/multibrand/write-up-simple.html` for a complete example of this formatting applied to a technical write-up. diff --git a/claude-code/skills/tmux-monitor/SKILL.md b/claude-code/skills/tmux-monitor/SKILL.md new file mode 100644 index 0000000..42cb23c --- /dev/null +++ b/claude-code/skills/tmux-monitor/SKILL.md @@ -0,0 +1,370 @@ +--- +name: tmux-monitor +description: Monitor and report status of all tmux sessions including dev environments, spawned agents, and running processes. Uses tmuxwatch for enhanced visibility. +version: 1.0.0 +--- + +# tmux-monitor Skill + +## Purpose + +Provide comprehensive visibility into all active tmux sessions, running processes, and spawned agents. This skill enables checking what's running where without needing to manually inspect each session. + +## Capabilities + +1. **Session Discovery**: Find and categorize all tmux sessions +2. **Process Inspection**: Identify running servers, dev environments, agents +3. **Port Mapping**: Show which ports are in use and by what +4. **Status Reporting**: Generate detailed reports with recommendations +5. **tmuxwatch Integration**: Use tmuxwatch for enhanced real-time monitoring +6. **Metadata Extraction**: Read session metadata from .tmux-dev-session.json and agent JSON files + +## When to Use + +- User asks "what's running?" +- Before starting new dev environments (check port conflicts) +- After spawning agents (verify they started correctly) +- When debugging server/process issues +- Before session cleanup +- When context switching between projects + +## Implementation + +### Step 1: Check tmux Availability + +```bash +if ! command -v tmux &> /dev/null; then + echo "❌ tmux is not installed" + exit 1 +fi + +if ! tmux list-sessions 2>/dev/null; then + echo "✅ No tmux sessions currently running" + exit 0 +fi +``` + +### Step 2: Discover All Sessions + +```bash +# Get all sessions with metadata +SESSIONS=$(tmux list-sessions -F '#{session_name}|#{session_windows}|#{session_created}|#{session_attached}') + +# Count sessions +TOTAL_SESSIONS=$(echo "$SESSIONS" | wc -l | tr -d ' ') +``` + +### Step 3: Categorize Sessions + +Group by prefix pattern: + +- `dev-*` → Development environments +- `agent-*` → Spawned agents +- `claude-*` → Claude Code sessions +- `monitor-*` → Monitoring sessions +- Others → Miscellaneous + +```bash +DEV_SESSIONS=$(echo "$SESSIONS" | grep "^dev-" || true) +AGENT_SESSIONS=$(echo "$SESSIONS" | grep "^agent-" || true) +CLAUDE_SESSIONS=$(echo "$SESSIONS" | grep "^claude-" || true) +``` + +### Step 4: Extract Details for Each Session + +For each session, gather: + +**Window Information**: +```bash +tmux list-windows -t "$SESSION" -F '#{window_index}:#{window_name}:#{window_panes}' +``` + +**Running Processes** (from first pane of each window): +```bash +tmux capture-pane -t "$SESSION:0.0" -p -S -10 -E 0 +``` + +**Port Detection** (check for listening ports): +```bash +# Extract ports from session metadata +if [ -f ".tmux-dev-session.json" ]; then + BACKEND_PORT=$(jq -r '.backend.port // empty' .tmux-dev-session.json) + FRONTEND_PORT=$(jq -r '.frontend.port // empty' .tmux-dev-session.json) +fi + +# Or detect from process list +lsof -nP -iTCP -sTCP:LISTEN | grep -E "node|python|uv|npm" +``` + +### Step 5: Load Session Metadata + +**Dev Environment Metadata** (`.tmux-dev-session.json`): +```bash +if [ -f ".tmux-dev-session.json" ]; then + PROJECT=$(jq -r '.project' .tmux-dev-session.json) + TYPE=$(jq -r '.type' .tmux-dev-session.json) + BACKEND_PORT=$(jq -r '.backend.port // "N/A"' .tmux-dev-session.json) + FRONTEND_PORT=$(jq -r '.frontend.port // "N/A"' .tmux-dev-session.json) + CREATED=$(jq -r '.created' .tmux-dev-session.json) +fi +``` + +**Agent Metadata** (`~/.claude/agents/*.json`): +```bash +if [ -f "$HOME/.claude/agents/${SESSION}.json" ]; then + AGENT_TYPE=$(jq -r '.agent_type' "$HOME/.claude/agents/${SESSION}.json") + TASK=$(jq -r '.task' "$HOME/.claude/agents/${SESSION}.json") + STATUS=$(jq -r '.status' "$HOME/.claude/agents/${SESSION}.json") + DIRECTORY=$(jq -r '.directory' "$HOME/.claude/agents/${SESSION}.json") + CREATED=$(jq -r '.created' "$HOME/.claude/agents/${SESSION}.json") +fi +``` + +### Step 6: tmuxwatch Integration + +If tmuxwatch is available, offer enhanced view: + +```bash +if command -v tmuxwatch &> /dev/null; then + echo "" + echo "📊 Enhanced Monitoring Available:" + echo " Real-time TUI: tmuxwatch" + echo " JSON export: tmuxwatch --dump | jq" + echo "" + + # Optional: Use tmuxwatch for structured data + TMUXWATCH_DATA=$(tmuxwatch --dump 2>/dev/null || echo "{}") +fi +``` + +### Step 7: Generate Comprehensive Report + +```markdown +# tmux Sessions Overview + +**Total Active Sessions**: {count} +**Total Windows**: {window_count} +**Total Panes**: {pane_count} + +--- + +## Development Environments ({dev_count}) + +### 1. dev-myapp-1705161234 +- **Type**: fullstack +- **Project**: myapp +- **Status**: ⚡ Active (attached) +- **Windows**: 4 (servers, logs, claude-work, git) +- **Panes**: 8 +- **Backend**: Port 8432 → http://localhost:8432 +- **Frontend**: Port 3891 → http://localhost:3891 +- **Created**: 2025-01-13 14:30:00 (2h ago) +- **Attach**: `tmux attach -t dev-myapp-1705161234` + +--- + +## Spawned Agents ({agent_count}) + +### 2. agent-1705160000 +- **Agent Type**: codex +- **Task**: Refactor authentication module +- **Status**: ⚙️ Running (15 minutes) +- **Working Directory**: /Users/stevie/projects/myapp +- **Git Worktree**: worktrees/agent-1705160000 +- **Windows**: 1 (work) +- **Panes**: 2 (agent | monitoring) +- **Last Output**: "Analyzing auth.py dependencies..." +- **Attach**: `tmux attach -t agent-1705160000` +- **Metadata**: `~/.claude/agents/agent-1705160000.json` + +### 3. agent-1705161000 +- **Agent Type**: aider +- **Task**: Generate API documentation +- **Status**: ✅ Completed (5 minutes ago) +- **Output**: Documentation written to docs/api/ +- **Attach**: `tmux attach -t agent-1705161000` (review) +- **Cleanup**: `tmux kill-session -t agent-1705161000` + +--- + +## Running Processes Summary + +| Port | Service | Session | Status | +|------|--------------|--------------------------|---------| +| 8432 | Backend API | dev-myapp-1705161234 | Running | +| 3891 | Frontend Dev | dev-myapp-1705161234 | Running | +| 5160 | Supabase | dev-shotclubhouse-xxx | Running | + +--- + +## Quick Actions + +**Attach to session**: +```bash +tmux attach -t +``` + +**Kill session**: +```bash +tmux kill-session -t +``` + +**List all sessions**: +```bash +tmux ls +``` + +**Kill all completed agents**: +```bash +for session in $(tmux ls | grep "^agent-" | cut -d: -f1); do + STATUS=$(jq -r '.status' "$HOME/.claude/agents/${session}.json" 2>/dev/null) + if [ "$STATUS" = "completed" ]; then + tmux kill-session -t "$session" + fi +done +``` + +--- + +## Recommendations + +{generated based on findings} +``` + +### Step 8: Provide Contextual Recommendations + +**If completed agents found**: +``` +⚠️ Found 1 completed agent session: + - agent-1705161000: Task completed 5 minutes ago + +Recommendation: Review results and clean up: + tmux attach -t agent-1705161000 # Review + tmux kill-session -t agent-1705161000 # Cleanup +``` + +**If long-running detached sessions**: +``` +💡 Found detached session running for 2h 40m: + - dev-api-service-1705159000 + +Recommendation: Check if still needed: + tmux attach -t dev-api-service-1705159000 +``` + +**If port conflicts detected**: +``` +⚠️ Port conflict detected: + - Port 3000 in use by dev-oldproject-xxx + - New session will use random port instead + +Recommendation: Clean up old session if no longer needed +``` + +## Output Formats + +### Compact (Default) + +``` +5 active sessions: +- dev-myapp-1705161234 (fullstack, 4 windows, active) +- dev-api-service-1705159000 (backend-only, 4 windows, detached) +- agent-1705160000 (codex, running 15m) +- agent-1705161000 (aider, completed ✓) +- claude-work (main session, current) + +3 running servers: +- Port 8432: Backend API (dev-myapp) +- Port 3891: Frontend Dev (dev-myapp) +- Port 5160: Supabase (dev-shotclubhouse) +``` + +### Detailed (Verbose) + +Full report with all metadata, sample output, recommendations. + +### JSON (Programmatic) + +```json +{ + "sessions": [ + { + "name": "dev-myapp-1705161234", + "type": "dev-environment", + "category": "fullstack", + "windows": 4, + "panes": 8, + "status": "attached", + "created": "2025-01-13T14:30:00Z", + "ports": { + "backend": 8432, + "frontend": 3891 + }, + "metadata_file": ".tmux-dev-session.json" + }, + { + "name": "agent-1705160000", + "type": "spawned-agent", + "agent_type": "codex", + "task": "Refactor authentication module", + "status": "running", + "runtime": "15m", + "directory": "/Users/stevie/projects/myapp", + "worktree": "worktrees/agent-1705160000", + "metadata_file": "~/.claude/agents/agent-1705160000.json" + } + ], + "summary": { + "total_sessions": 5, + "total_windows": 12, + "total_panes": 28, + "running_servers": 3, + "active_agents": 1, + "completed_agents": 1 + }, + "ports": [ + {"port": 8432, "service": "Backend API", "session": "dev-myapp-1705161234"}, + {"port": 3891, "service": "Frontend Dev", "session": "dev-myapp-1705161234"}, + {"port": 5160, "service": "Supabase", "session": "dev-shotclubhouse-xxx"} + ] +} +``` + +## Integration with Commands + +This skill is used by: +- `/tmux-status` command (user-facing command) +- Automatically before starting new dev environments (conflict detection) +- By spawned agents to check session status + +## Dependencies + +- `tmux` (required) +- `jq` (required for JSON parsing) +- `lsof` (optional, for port detection) +- `tmuxwatch` (optional, for enhanced monitoring) + +## File Structure + +``` +~/.claude/agents/ + agent-{timestamp}.json # Agent metadata + +.tmux-dev-session.json # Dev environment metadata (per project) + +/tmp/tmux-monitor-cache.json # Optional cache for performance +``` + +## Related Commands + +- `/tmux-status` - User-facing wrapper around this skill +- `/spawn-agent` - Creates sessions that this skill monitors +- `/start-local`, `/start-ios`, `/start-android` - Create dev environments + +## Notes + +- This skill is read-only, never modifies sessions +- Safe to run anytime without side effects +- Provides snapshot of current state +- Can be cached for performance (TTL: 10 seconds) +- Should be run before potentially conflicting operations diff --git a/claude-code/skills/tmux-monitor/scripts/monitor.sh b/claude-code/skills/tmux-monitor/scripts/monitor.sh new file mode 100755 index 0000000..0b1d3f5 --- /dev/null +++ b/claude-code/skills/tmux-monitor/scripts/monitor.sh @@ -0,0 +1,417 @@ +#!/bin/bash + +# ABOUTME: tmux session monitoring script - discovers, categorizes, and reports status of all active tmux sessions + +set -euo pipefail + +# Output mode: compact (default), detailed, json +OUTPUT_MODE="${1:-compact}" + +# Check if tmux is available +if ! command -v tmux &> /dev/null; then + echo "❌ tmux is not installed" + exit 1 +fi + +# Check if there are any sessions +if ! tmux list-sessions 2>/dev/null | grep -q .; then + if [ "$OUTPUT_MODE" = "json" ]; then + echo '{"sessions": [], "summary": {"total_sessions": 0, "total_windows": 0, "total_panes": 0}}' + else + echo "✅ No tmux sessions currently running" + fi + exit 0 +fi + +# Initialize counters +TOTAL_SESSIONS=0 +TOTAL_WINDOWS=0 +TOTAL_PANES=0 + +# Arrays to store sessions by category +declare -a DEV_SESSIONS +declare -a AGENT_SESSIONS +declare -a MONITOR_SESSIONS +declare -a CLAUDE_SESSIONS +declare -a OTHER_SESSIONS + +# Get all sessions +SESSIONS=$(tmux list-sessions -F '#{session_name}|#{session_windows}|#{session_created}|#{session_attached}' 2>/dev/null) + +# Parse and categorize sessions +while IFS='|' read -r SESSION_NAME WINDOW_COUNT CREATED ATTACHED; do + TOTAL_SESSIONS=$((TOTAL_SESSIONS + 1)) + TOTAL_WINDOWS=$((TOTAL_WINDOWS + WINDOW_COUNT)) + + # Get pane count for this session + PANE_COUNT=$(tmux list-panes -t "$SESSION_NAME" 2>/dev/null | wc -l | tr -d ' ') + TOTAL_PANES=$((TOTAL_PANES + PANE_COUNT)) + + # Categorize by prefix + if [[ "$SESSION_NAME" == dev-* ]]; then + DEV_SESSIONS+=("$SESSION_NAME|$WINDOW_COUNT|$PANE_COUNT|$ATTACHED") + elif [[ "$SESSION_NAME" == agent-* ]]; then + AGENT_SESSIONS+=("$SESSION_NAME|$WINDOW_COUNT|$PANE_COUNT|$ATTACHED") + elif [[ "$SESSION_NAME" == monitor-* ]]; then + MONITOR_SESSIONS+=("$SESSION_NAME|$WINDOW_COUNT|$PANE_COUNT|$ATTACHED") + elif [[ "$SESSION_NAME" == claude-* ]] || [[ "$SESSION_NAME" == *claude* ]]; then + CLAUDE_SESSIONS+=("$SESSION_NAME|$WINDOW_COUNT|$PANE_COUNT|$ATTACHED") + else + OTHER_SESSIONS+=("$SESSION_NAME|$WINDOW_COUNT|$PANE_COUNT|$ATTACHED") + fi +done <<< "$SESSIONS" + +# Helper function to get session metadata +get_dev_metadata() { + local SESSION_NAME=$1 + local METADATA_FILE=".tmux-dev-session.json" + + if [ -f "$METADATA_FILE" ]; then + local SESSION_IN_FILE=$(jq -r '.session // empty' "$METADATA_FILE" 2>/dev/null) + if [ "$SESSION_IN_FILE" = "$SESSION_NAME" ]; then + echo "$METADATA_FILE" + fi + fi + + # Try iOS-specific metadata + if [ -f ".tmux-ios-session.json" ]; then + local SESSION_IN_FILE=$(jq -r '.session // empty' ".tmux-ios-session.json" 2>/dev/null) + if [ "$SESSION_IN_FILE" = "$SESSION_NAME" ]; then + echo ".tmux-ios-session.json" + fi + fi + + # Try Android-specific metadata + if [ -f ".tmux-android-session.json" ]; then + local SESSION_IN_FILE=$(jq -r '.session // empty' ".tmux-android-session.json" 2>/dev/null) + if [ "$SESSION_IN_FILE" = "$SESSION_NAME" ]; then + echo ".tmux-android-session.json" + fi + fi +} + +get_agent_metadata() { + local SESSION_NAME=$1 + local METADATA_FILE="$HOME/.claude/agents/${SESSION_NAME}.json" + + if [ -f "$METADATA_FILE" ]; then + echo "$METADATA_FILE" + fi +} + +# Get running ports +get_running_ports() { + if command -v lsof &> /dev/null; then + lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | grep -E "node|python|uv|npm|ruby|java" | awk '{print $9}' | cut -d':' -f2 | sort -u || true + fi +} + +RUNNING_PORTS=$(get_running_ports) + +# Output functions + +output_compact() { + echo "${TOTAL_SESSIONS} active sessions:" + + # Dev environments + if [[ -v DEV_SESSIONS[@] ]] && [ ${#DEV_SESSIONS[@]} -gt 0 ]; then + for session_data in "${DEV_SESSIONS[@]}"; do + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + STATUS="detached" + [ "$ATTACHED" = "1" ] && STATUS="active" + + # Try to get metadata + METADATA_FILE=$(get_dev_metadata "$SESSION_NAME") + if [ -n "$METADATA_FILE" ]; then + PROJECT_TYPE=$(jq -r '.type // "dev"' "$METADATA_FILE" 2>/dev/null) + echo "- $SESSION_NAME ($PROJECT_TYPE, $WINDOW_COUNT windows, $STATUS)" + else + echo "- $SESSION_NAME ($WINDOW_COUNT windows, $STATUS)" + fi + done + fi + + # Agent sessions + if [[ -v AGENT_SESSIONS[@] ]] && [ ${#AGENT_SESSIONS[@]} -gt 0 ]; then + for session_data in "${AGENT_SESSIONS[@]}"; do + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + + # Try to get agent metadata + METADATA_FILE=$(get_agent_metadata "$SESSION_NAME") + if [ -n "$METADATA_FILE" ]; then + AGENT_TYPE=$(jq -r '.agent_type // "unknown"' "$METADATA_FILE" 2>/dev/null) + STATUS_=$(jq -r '.status // "running"' "$METADATA_FILE" 2>/dev/null) + echo "- $SESSION_NAME ($AGENT_TYPE, $STATUS_)" + else + echo "- $SESSION_NAME (agent)" + fi + done + fi + + # Claude sessions + if [[ -v CLAUDE_SESSIONS[@] ]] && [ ${#CLAUDE_SESSIONS[@]} -gt 0 ]; then + for session_data in "${CLAUDE_SESSIONS[@]}"; do + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + STATUS="detached" + [ "$ATTACHED" = "1" ] && STATUS="current" + echo "- $SESSION_NAME (main session, $STATUS)" + done + fi + + # Other sessions + if [[ -v OTHER_SESSIONS[@] ]] && [ ${#OTHER_SESSIONS[@]} -gt 0 ]; then + for session_data in "${OTHER_SESSIONS[@]}"; do + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + echo "- $SESSION_NAME ($WINDOW_COUNT windows)" + done + fi + + # Port summary + if [ -n "$RUNNING_PORTS" ]; then + PORT_COUNT=$(echo "$RUNNING_PORTS" | wc -l | tr -d ' ') + echo "" + echo "$PORT_COUNT running servers on ports: $(echo $RUNNING_PORTS | tr '\n' ',' | sed 's/,$//')" + fi + + echo "" + echo "Use /tmux-status --detailed for full report" +} + +output_detailed() { + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "📊 tmux Sessions Overview" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "**Total Active Sessions**: $TOTAL_SESSIONS" + echo "**Total Windows**: $TOTAL_WINDOWS" + echo "**Total Panes**: $TOTAL_PANES" + echo "" + + # Dev environments + if [[ -v DEV_SESSIONS[@] ]] && [ ${#DEV_SESSIONS[@]} -gt 0 ]; then + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "## Development Environments (${#DEV_SESSIONS[@]})" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + + local INDEX=1 + for session_data in "${DEV_SESSIONS[@]}"; do + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + STATUS="🔌 Detached" + [ "$ATTACHED" = "1" ] && STATUS="⚡ Active (attached)" + + echo "### $INDEX. $SESSION_NAME" + echo "- **Status**: $STATUS" + echo "- **Windows**: $WINDOW_COUNT" + echo "- **Panes**: $PANE_COUNT" + + # Get metadata if available + METADATA_FILE=$(get_dev_metadata "$SESSION_NAME") + if [ -n "$METADATA_FILE" ]; then + PROJECT=$(jq -r '.project // "unknown"' "$METADATA_FILE" 2>/dev/null) + PROJECT_TYPE=$(jq -r '.type // "unknown"' "$METADATA_FILE" 2>/dev/null) + CREATED=$(jq -r '.created // "unknown"' "$METADATA_FILE" 2>/dev/null) + + echo "- **Project**: $PROJECT ($PROJECT_TYPE)" + echo "- **Created**: $CREATED" + + # Check for ports + if jq -e '.dev_port' "$METADATA_FILE" &>/dev/null; then + DEV_PORT=$(jq -r '.dev_port' "$METADATA_FILE" 2>/dev/null) + echo "- **Dev Server**: http://localhost:$DEV_PORT" + fi + + if jq -e '.services' "$METADATA_FILE" &>/dev/null; then + echo "- **Services**: $(jq -r '.services | keys | join(", ")' "$METADATA_FILE" 2>/dev/null)" + fi + fi + + echo "- **Attach**: \`tmux attach -t $SESSION_NAME\`" + echo "" + + INDEX=$((INDEX + 1)) + done + fi + + # Agent sessions + if [[ -v AGENT_SESSIONS[@] ]] && [ ${#AGENT_SESSIONS[@]} -gt 0 ]; then + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "## Spawned Agents (${#AGENT_SESSIONS[@]})" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + + local INDEX=1 + for session_data in "${AGENT_SESSIONS[@]}"; do + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + + echo "### $INDEX. $SESSION_NAME" + + # Get agent metadata + METADATA_FILE=$(get_agent_metadata "$SESSION_NAME") + if [ -n "$METADATA_FILE" ]; then + AGENT_TYPE=$(jq -r '.agent_type // "unknown"' "$METADATA_FILE" 2>/dev/null) + TASK=$(jq -r '.task // "unknown"' "$METADATA_FILE" 2>/dev/null) + STATUS_=$(jq -r '.status // "running"' "$METADATA_FILE" 2>/dev/null) + DIRECTORY=$(jq -r '.directory // "unknown"' "$METADATA_FILE" 2>/dev/null) + CREATED=$(jq -r '.created // "unknown"' "$METADATA_FILE" 2>/dev/null) + + echo "- **Agent Type**: $AGENT_TYPE" + echo "- **Task**: $TASK" + echo "- **Status**: $([ "$STATUS_" = "completed" ] && echo "✅ Completed" || echo "⚙️ Running")" + echo "- **Working Directory**: $DIRECTORY" + echo "- **Created**: $CREATED" + + # Check for worktree + if jq -e '.worktree' "$METADATA_FILE" &>/dev/null; then + WORKTREE=$(jq -r '.worktree' "$METADATA_FILE" 2>/dev/null) + if [ "$WORKTREE" = "true" ]; then + AGENT_BRANCH=$(jq -r '.agent_branch // "unknown"' "$METADATA_FILE" 2>/dev/null) + echo "- **Git Worktree**: Yes (branch: $AGENT_BRANCH)" + fi + fi + fi + + echo "- **Attach**: \`tmux attach -t $SESSION_NAME\`" + echo "- **Metadata**: \`cat $METADATA_FILE\`" + echo "" + + INDEX=$((INDEX + 1)) + done + fi + + # Claude sessions + if [[ -v CLAUDE_SESSIONS[@] ]] && [ ${#CLAUDE_SESSIONS[@]} -gt 0 ]; then + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "## Other Sessions (${#CLAUDE_SESSIONS[@]})" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + + for session_data in "${CLAUDE_SESSIONS[@]}"; do + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + STATUS="Detached" + [ "$ATTACHED" = "1" ] && STATUS="⚡ Active (current session)" + echo "- $SESSION_NAME: $STATUS" + done + echo "" + fi + + # Running processes summary + if [ -n "$RUNNING_PORTS" ]; then + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "## Running Processes Summary" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "| Port | Service | Status |" + echo "|------|---------|--------|" + for PORT in $RUNNING_PORTS; do + echo "| $PORT | Running | ✅ |" + done + echo "" + fi + + # Quick actions + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "## Quick Actions" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "**List all sessions**:" + echo "\`\`\`bash" + echo "tmux ls" + echo "\`\`\`" + echo "" + echo "**Attach to session**:" + echo "\`\`\`bash" + echo "tmux attach -t " + echo "\`\`\`" + echo "" + echo "**Kill session**:" + echo "\`\`\`bash" + echo "tmux kill-session -t " + echo "\`\`\`" + echo "" +} + +output_json() { + echo "{" + echo " \"sessions\": [" + + local FIRST_SESSION=true + + # Dev sessions + for session_data in "${DEV_SESSIONS[@]}"; do + [ "$FIRST_SESSION" = false ] && echo "," + FIRST_SESSION=false + + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + + echo " {" + echo " \"name\": \"$SESSION_NAME\"," + echo " \"type\": \"dev-environment\"," + echo " \"windows\": $WINDOW_COUNT," + echo " \"panes\": $PANE_COUNT," + echo " \"attached\": $([ "$ATTACHED" = "1" ] && echo "true" || echo "false")" + + # Get metadata if available + METADATA_FILE=$(get_dev_metadata "$SESSION_NAME") + if [ -n "$METADATA_FILE" ]; then + echo " ,\"metadata_file\": \"$METADATA_FILE\"" + fi + + echo -n " }" + done + + # Agent sessions + for session_data in "${AGENT_SESSIONS[@]}"; do + [ "$FIRST_SESSION" = false ] && echo "," + FIRST_SESSION=false + + IFS='|' read -r SESSION_NAME WINDOW_COUNT PANE_COUNT ATTACHED <<< "$session_data" + + echo " {" + echo " \"name\": \"$SESSION_NAME\"," + echo " \"type\": \"spawned-agent\"," + echo " \"windows\": $WINDOW_COUNT," + echo " \"panes\": $PANE_COUNT" + + # Get agent metadata + METADATA_FILE=$(get_agent_metadata "$SESSION_NAME") + if [ -n "$METADATA_FILE" ]; then + AGENT_TYPE=$(jq -r '.agent_type // "unknown"' "$METADATA_FILE" 2>/dev/null) + STATUS_=$(jq -r '.status // "running"' "$METADATA_FILE" 2>/dev/null) + echo " ,\"agent_type\": \"$AGENT_TYPE\"," + echo " \"status\": \"$STATUS_\"," + echo " \"metadata_file\": \"$METADATA_FILE\"" + fi + + echo -n " }" + done + + echo "" + echo " ]," + echo " \"summary\": {" + echo " \"total_sessions\": $TOTAL_SESSIONS," + echo " \"total_windows\": $TOTAL_WINDOWS," + echo " \"total_panes\": $TOTAL_PANES," + echo " \"dev_sessions\": ${#DEV_SESSIONS[@]}," + echo " \"agent_sessions\": ${#AGENT_SESSIONS[@]}" + echo " }" + echo "}" +} + +# Main output +case "$OUTPUT_MODE" in + compact) + output_compact + ;; + detailed) + output_detailed + ;; + json) + output_json + ;; + *) + echo "Unknown output mode: $OUTPUT_MODE" + echo "Usage: monitor.sh [compact|detailed|json]" + exit 1 + ;; +esac diff --git a/claude-code/skills/webapp-testing/SKILL.md b/claude-code/skills/webapp-testing/SKILL.md index 4726215..bf2f534 100644 --- a/claude-code/skills/webapp-testing/SKILL.md +++ b/claude-code/skills/webapp-testing/SKILL.md @@ -13,6 +13,98 @@ To test local web applications, write native Python Playwright scripts. **Always run scripts with `--help` first** to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window. +## Browser Tools (Direct Chrome DevTools Control) + +The `bin/browser-tools` utility provides lightweight, context-rot-proof browser automation using the Chrome DevTools Protocol directly (no MCP overhead). + +**Available Commands**: + +```bash +# Launch browser and get connection details +bin/browser-tools start [--port PORT] [--headless] + +# Navigate to URL +bin/browser-tools nav [--wait-for {load|networkidle|domcontentloaded}] + +# Evaluate JavaScript +bin/browser-tools eval "" + +# Take screenshot +bin/browser-tools screenshot [--full-page] [--selector CSS_SELECTOR] + +# Interactive element picker (returns selectors) +bin/browser-tools pick + +# Get console logs +bin/browser-tools console [--level {log|warn|error|all}] + +# Search page content +bin/browser-tools search "" [--case-sensitive] + +# Extract page content (markdown, links, text) +bin/browser-tools content [--format {markdown|links|text}] + +# Get/set cookies +bin/browser-tools cookies [--set NAME=VALUE] [--domain DOMAIN] + +# Inspect element details +bin/browser-tools inspect + +# Terminate browser session +bin/browser-tools kill +``` + +**When to Use Browser-Tools vs Playwright**: + +✅ **Use browser-tools when**: +- Quick inspection/debugging of running apps +- Need to identify selectors interactively (`pick` command) +- Extracting page content for analysis +- Running simple JavaScript snippets +- Monitoring console logs in real-time +- Taking quick screenshots without writing scripts + +✅ **Use Playwright when**: +- Complex multi-step automation workflows +- Need full test assertion framework +- Handling multi-step forms +- Database integration (Supabase utilities) +- Comprehensive test coverage +- Generating test reports + +**Example Workflow**: + +```bash +# 1. Start browser pointed at your app +bin/browser-tools start --port 9222 + +# 2. Navigate to the page +bin/browser-tools nav http://localhost:3000 + +# 3. Use interactive picker to find selectors +bin/browser-tools pick +# Click on elements in the browser, get their selectors + +# 4. Inspect specific elements +bin/browser-tools inspect "button.submit" + +# 5. Take screenshot for documentation +bin/browser-tools screenshot /tmp/page.png --full-page + +# 6. Check console for errors +bin/browser-tools console --level error + +# 7. Clean up +bin/browser-tools kill +``` + +**Benefits**: +- **No MCP overhead**: Direct Chrome DevTools Protocol communication +- **Context-rot proof**: No dependency on evolving MCP specifications +- **Interactive picker**: Visual element selection for selector discovery +- **Lightweight**: Faster than full Playwright for simple tasks +- **Pre-compiled binary**: Ready to use, no compilation needed (auto-compiled via create-rule.js) + ## Decision Tree: Choosing Your Approach ``` @@ -38,14 +130,14 @@ To start a server, run `--help` first, then use the helper: **Single server:** ```bash -python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_automation.py +python scripts/with_server.py --server "npm run dev" --port 3000 -- python your_automation.py ``` **Multiple servers (e.g., backend + frontend):** ```bash python scripts/with_server.py \ - --server "cd backend && python server.py" --port 3000 \ - --server "cd frontend && npm run dev" --port 5173 \ + --server "cd backend && python server.py" --port 8000 \ + --server "cd frontend && npm run dev" --port 3000 \ -- python your_automation.py ``` @@ -53,10 +145,12 @@ To create an automation script, include only Playwright logic (servers are manag ```python from playwright.sync_api import sync_playwright +APP_PORT = 3000 # Match the port from --port argument + with sync_playwright() as p: browser = p.chromium.launch(headless=True) # Always launch chromium in headless mode page = browser.new_page() - page.goto('http://localhost:5173') # Server already running and ready + page.goto(f'http://localhost:{APP_PORT}') # Server already running and ready page.wait_for_load_state('networkidle') # CRITICAL: Wait for JS to execute # ... your automation logic browser.close() @@ -88,9 +182,352 @@ with sync_playwright() as p: - Use descriptive selectors: `text=`, `role=`, CSS selectors, or IDs - Add appropriate waits: `page.wait_for_selector()` or `page.wait_for_timeout()` +## Utility Modules + +The skill now includes comprehensive utilities for common testing patterns: + +### UI Interactions (`utils/ui_interactions.py`) + +Handle common UI patterns automatically: + +```python +from utils.ui_interactions import ( + dismiss_cookie_banner, + dismiss_modal, + click_with_header_offset, + force_click_if_needed, + wait_for_no_overlay, + wait_for_stable_dom +) + +# Dismiss cookie consent +dismiss_cookie_banner(page) + +# Close welcome modal +dismiss_modal(page, modal_identifier="Welcome") + +# Click button behind fixed header +click_with_header_offset(page, 'button#submit', header_height=100) + +# Try click with force fallback +force_click_if_needed(page, 'button#action') + +# Wait for loading overlays to disappear +wait_for_no_overlay(page) + +# Wait for DOM to stabilize +wait_for_stable_dom(page) +``` + +### Smart Form Filling (`utils/form_helpers.py`) + +Intelligently handle form variations: + +```python +from utils.form_helpers import ( + SmartFormFiller, + handle_multi_step_form, + auto_fill_form +) + +# Works with both "Full Name" and "First/Last Name" fields +filler = SmartFormFiller() +filler.fill_name_field(page, "Jane Doe") +filler.fill_email_field(page, "jane@example.com") +filler.fill_password_fields(page, "SecurePass123!") +filler.fill_phone_field(page, "+447700900123") +filler.fill_date_field(page, "1990-01-15", field_hint="birth") + +# Auto-fill entire form +results = auto_fill_form(page, { + 'email': 'test@example.com', + 'password': 'Pass123!', + 'full_name': 'Test User', + 'phone': '+447700900123', + 'date_of_birth': '1990-01-15' +}) + +# Handle multi-step forms +steps = [ + {'fields': {'email': 'test@example.com', 'password': 'Pass123!'}, 'checkbox': True}, + {'fields': {'full_name': 'Test User', 'date_of_birth': '1990-01-15'}}, + {'complete': True} +] +handle_multi_step_form(page, steps) +``` + +### Supabase Testing (`utils/supabase.py`) + +Database operations for Supabase-based apps: + +```python +from utils.supabase import SupabaseTestClient, quick_cleanup + +# Initialize client +client = SupabaseTestClient( + url="https://project.supabase.co", + service_key="your-service-role-key", + db_password="your-db-password" +) + +# Create test user +user_id = client.create_user("test@example.com", "password123") + +# Create invite code +client.create_invite_code("TEST2024", code_type="general") + +# Bypass email verification +client.confirm_email(user_id) + +# Cleanup after test +client.cleanup_related_records(user_id) +client.delete_user(user_id) + +# Quick cleanup helper +quick_cleanup("test@example.com", "db_password", "https://project.supabase.co") +``` + +### Advanced Wait Strategies (`utils/wait_strategies.py`) + +Better alternatives to simple sleep(): + +```python +from utils.wait_strategies import ( + wait_for_api_call, + wait_for_element_stable, + smart_navigation_wait, + combined_wait +) + +# Wait for specific API response +response = wait_for_api_call(page, '**/api/profile**') + +# Wait for element to stop moving +wait_for_element_stable(page, '.dropdown-menu', stability_ms=1000) + +# Smart navigation with URL check +page.click('button#login') +smart_navigation_wait(page, expected_url_pattern='**/dashboard**') + +# Comprehensive wait (network + DOM + overlays) +combined_wait(page) +``` + +### Smart Selectors (`utils/smart_selectors.py`) ⭐ NEW + +Automatically try multiple selector strategies to find elements, reducing test brittleness: + +```python +from utils.smart_selectors import SelectorStrategies + +# Find and fill email field (tries 7 different selector strategies) +success = SelectorStrategies.smart_fill(page, 'email', 'test@example.com') +# Output: ✓ Found field via placeholder: input[placeholder*="email" i] +# ✓ Filled 'email' with value + +# Find and click button (tries 8 different strategies) +success = SelectorStrategies.smart_click(page, 'Sign In') +# Output: ✓ Found button via case-insensitive text: button:text-matches("Sign In", "i") +# ✓ Clicked 'Sign In' button + +# Manual control - find input field selector +selector = SelectorStrategies.find_input_field(page, 'password') +if selector: + page.fill(selector, 'my-password') + +# Manual control - find button selector +selector = SelectorStrategies.find_button(page, 'Submit') +if selector: + page.click(selector) + +# Try custom selectors with fallback +selectors = ['button#submit', 'button.submit-btn', 'input[type="submit"]'] +selector = SelectorStrategies.find_any_element(page, selectors) +``` + +**Selector Strategies Tried (in order):** + +For input fields: +1. Test IDs: `[data-testid*="email"]` +2. ARIA labels: `input[aria-label*="email"]` +3. Placeholder: `input[placeholder*="email"]` +4. Name attribute: `input[name*="email"]` +5. Type attribute: `input[type="email"]` +6. ID exact: `#email` +7. ID partial: `input[id*="email"]` + +For buttons: +1. Test IDs: `[data-testid*="sign-in"]` +2. Name attribute: `button[name*="Sign In"]` +3. Exact text: `button:has-text("Sign In")` +4. Case-insensitive: `button:text-matches("Sign In", "i")` +5. Link as button: `a:has-text("Sign In")` +6. Submit input: `input[type="submit"][value*="Sign In"]` +7. Role button: `[role="button"]:has-text("Sign In")` + +**When to use:** +- ✅ Forms where HTML structure changes frequently +- ✅ Third-party components with unpredictable selectors +- ✅ Multi-application test suites +- ❌ Performance-critical tests (adds 5s timeout per strategy) + +**Performance:** +- Timeout per strategy: 5 seconds (configurable) +- Max timeout: 10 seconds across all strategies +- Typical: First strategy works (1-2 seconds) + +### Browser Configuration (`utils/browser_config.py`) ⭐ NEW + +Auto-configure browser context for testing environments: + +```python +from utils.browser_config import BrowserConfig + +# Auto-detect CSP bypass for localhost +context = BrowserConfig.create_test_context( + browser, + 'http://localhost:3000' +) +# Output: +# ============================================================ +# Browser Context Configuration +# ============================================================ +# Base URL: http://localhost:3000 +# 🔓 CSP bypass: ENABLED (testing on localhost) +# ⚠️ HTTPS errors: IGNORED (self-signed certs OK) +# 📐 Viewport: 1280x720 +# ============================================================ + +# Production testing (no CSP bypass) +context = BrowserConfig.create_test_context( + browser, + 'https://production.example.com' +) +# Output: 🔒 CSP bypass: DISABLED (production mode) + +# Mobile device emulation +context = BrowserConfig.create_mobile_context( + browser, + device='iPhone 12', + base_url='http://localhost:3000' +) +# Output: 📱 Mobile context: iPhone 12 +# Viewport: {'width': 390, 'height': 844} +# 🔓 CSP bypass: ENABLED + +# Manual override +context = BrowserConfig.create_test_context( + browser, + 'http://localhost:3000', + bypass_csp=False, # Force disable even for localhost + record_video=True, # Record test session + extra_http_headers={'Authorization': 'Bearer token'} +) +``` + +**Features:** +- **Auto CSP detection**: Enables bypass for localhost, disables for production +- **Self-signed certs**: Ignores HTTPS errors by default +- **Consistent viewport**: 1280x720 default for reproducible tests +- **Mobile emulation**: Built-in device profiles +- **Video recording**: Optional session recording +- **Verbose logging**: See exactly what's configured + +**When to use:** +- ✅ Every test (replaces manual `browser.new_context()`) +- ✅ Testing on localhost with CSP restrictions +- ✅ Mobile-responsive testing +- ✅ Need consistent browser configuration across tests + +### CSP Monitoring (`utils/ui_interactions.py`) ⭐ NEW + +Auto-detect and suggest fixes for Content Security Policy violations: + +```python +from utils.ui_interactions import setup_page_with_csp_handling +from utils.browser_config import BrowserConfig + +context = BrowserConfig.create_test_context(browser, 'http://localhost:7160') +page = context.new_page() + +# Enable CSP violation monitoring +setup_page_with_csp_handling(page) + +page.goto('http://localhost:7160') + +# If CSP violation occurs, you'll see: +# ====================================================================== +# ⚠️ CSP VIOLATION DETECTED +# ====================================================================== +# Message: Refused to execute inline script because it violates... +# +# 💡 SUGGESTION: +# For localhost testing, use: +# +# from utils.browser_config import BrowserConfig +# context = BrowserConfig.create_test_context( +# browser, 'http://localhost:3000' +# ) +# # Auto-enables CSP bypass for localhost +# +# Or manually: +# context = browser.new_context(bypass_csp=True) +# ====================================================================== +``` + +**When to use:** +- ✅ Debugging why tests fail on localhost +- ✅ Identifying CSP configuration issues +- ✅ Verifying CSP bypass is working +- ❌ Production testing (CSP should be enforced) + +## Complete Examples + +### Multi-Step Registration + +See `examples/multi_step_registration.py` for a complete example showing: +- Database setup (invite codes) +- Cookie banner dismissal +- Multi-step form automation +- Email verification bypass +- Login flow +- Dashboard verification +- Cleanup + +Run it: +```bash +python examples/multi_step_registration.py +``` + +## Using the Webapp-Testing Subagent + +A specialized subagent is available for testing automation. Use it to keep your main conversation focused on development: + +``` +You: "Use webapp-testing agent to register test@example.com and verify the parent role switch works" + +Main Agent: [Launches webapp-testing subagent] + +Webapp-Testing Agent: [Runs complete automation, returns results] +``` + +**Benefits:** +- Keeps main context clean +- Specialized for Playwright automation +- Access to all skill utilities +- Automatic screenshot capture +- Clear result reporting + ## Reference Files - **examples/** - Examples showing common patterns: - `element_discovery.py` - Discovering buttons, links, and inputs on a page - `static_html_automation.py` - Using file:// URLs for local HTML - - `console_logging.py` - Capturing console logs during automation \ No newline at end of file + - `console_logging.py` - Capturing console logs during automation + - `multi_step_registration.py` - Complete registration flow example (NEW) + +- **utils/** - Reusable utility modules (NEW): + - `ui_interactions.py` - Cookie banners, modals, overlays, stable waits + - `form_helpers.py` - Smart form filling, multi-step automation + - `supabase.py` - Database operations for Supabase apps + - `wait_strategies.py` - Advanced waiting patterns \ No newline at end of file diff --git a/claude-code/skills/webapp-testing/bin/browser-tools b/claude-code/skills/webapp-testing/bin/browser-tools new file mode 120000 index 0000000..6cacf83 --- /dev/null +++ b/claude-code/skills/webapp-testing/bin/browser-tools @@ -0,0 +1 @@ +../../../../claude-code-4.5/skills/webapp-testing/bin/browser-tools \ No newline at end of file diff --git a/claude-code/skills/webapp-testing/bin/browser-tools.ts b/claude-code/skills/webapp-testing/bin/browser-tools.ts new file mode 100644 index 0000000..0b2855a --- /dev/null +++ b/claude-code/skills/webapp-testing/bin/browser-tools.ts @@ -0,0 +1,984 @@ +#!/usr/bin/env ts-node + +/** + * Minimal Chrome DevTools helpers inspired by Mario Zechner's + * "What if you don't need MCP?" article. + * + * Keeps everything in one TypeScript CLI so agents (or humans) can drive Chrome + * directly via the DevTools protocol without pulling in a large MCP server. + */ +import { Command } from 'commander'; +import { execSync, spawn } from 'node:child_process'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; +import { inspect } from 'node:util'; +import puppeteer from 'puppeteer-core'; + +/** Utility type so TypeScript knows the async function constructor */ +type AsyncFunctionCtor = new (...args: string[]) => (...fnArgs: unknown[]) => Promise; + +const DEFAULT_PORT = 9222; +const DEFAULT_PROFILE_DIR = path.join(os.homedir(), '.cache', 'scraping'); +const DEFAULT_CHROME_BIN = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + +function browserURL(port: number): string { + return `http://localhost:${port}`; +} + +async function connectBrowser(port: number) { + return puppeteer.connect({ browserURL: browserURL(port), defaultViewport: null }); +} + +async function getActivePage(port: number) { + const browser = await connectBrowser(port); + const pages = await browser.pages(); + const page = pages.at(-1); + if (!page) { + await browser.disconnect(); + throw new Error('No active tab found'); + } + return { browser, page }; +} + +const program = new Command(); +program + .name('browser-tools') + .description('Lightweight Chrome DevTools helpers (no MCP required).') + .configureHelp({ sortSubcommands: true }) + .showSuggestionAfterError(); + +program + .command('start') + .description('Launch Chrome with remote debugging enabled.') + .option('-p, --port ', 'Remote debugging port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .option('--profile', 'Copy your default Chrome profile before launch.', false) + .option('--profile-dir ', 'Directory for the temporary Chrome profile.', DEFAULT_PROFILE_DIR) + .option('--chrome-path ', 'Path to the Chrome binary.', DEFAULT_CHROME_BIN) + .option('--kill-existing', 'Stop any running Google Chrome before launch (default: false).', false) + .action(async (options) => { + const { port, profile, profileDir, chromePath, killExisting } = options as { + port: number; + profile: boolean; + profileDir: string; + chromePath: string; + killExisting: boolean; + }; + + if (killExisting) { + try { + execSync("killall 'Google Chrome'", { stdio: 'ignore' }); + } catch { + // ignore missing processes + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + execSync(`mkdir -p "${profileDir}"`); + if (profile) { + const source = `${path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')}/`; + execSync(`rsync -a --delete "${source}" "${profileDir}/"`, { stdio: 'ignore' }); + } + + spawn(chromePath, [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-first-run', '--disable-popup-blocking'], { + detached: true, + stdio: 'ignore', + }).unref(); + + let connected = false; + for (let attempt = 0; attempt < 30; attempt++) { + try { + const browser = await connectBrowser(port); + await browser.disconnect(); + connected = true; + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + + if (!connected) { + console.error(`✗ Failed to start Chrome on port ${port}`); + process.exit(1); + } + console.log(`✓ Chrome listening on http://localhost:${port}${profile ? ' (profile copied)' : ''}`); + }); + +program + .command('nav ') + .description('Navigate the current tab or open a new tab.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .option('--new', 'Open in a new tab.', false) + .action(async (url: string, options) => { + const port = options.port as number; + const browser = await connectBrowser(port); + try { + if (options.new) { + const page = await browser.newPage(); + await page.goto(url, { waitUntil: 'domcontentloaded' }); + console.log('✓ Opened in new tab:', url); + } else { + const pages = await browser.pages(); + const page = pages.at(-1); + if (!page) { + throw new Error('No active tab found'); + } + await page.goto(url, { waitUntil: 'domcontentloaded' }); + console.log('✓ Navigated current tab to:', url); + } + } finally { + await browser.disconnect(); + } + }); + +program + .command('eval ') + .description('Evaluate JavaScript in the active page context.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .option('--pretty-print', 'Format array/object results with indentation.', false) + .action(async (code: string[], options) => { + const snippet = code.join(' '); + const port = options.port as number; + const pretty = Boolean(options.prettyPrint); + const useColor = process.stdout.isTTY; + + const printPretty = (value: unknown) => { + console.log( + inspect(value, { + depth: 6, + colors: useColor, + maxArrayLength: 50, + breakLength: 80, + compact: false, + }), + ); + }; + + const { browser, page } = await getActivePage(port); + try { + const result = await page.evaluate((body) => { + const ASYNC_FN = Object.getPrototypeOf(async () => {}).constructor as AsyncFunctionCtor; + return new ASYNC_FN(`return (${body})`)(); + }, snippet); + + if (pretty) { + printPretty(result); + } else if (Array.isArray(result)) { + result.forEach((entry, index) => { + if (index > 0) { + console.log(''); + } + Object.entries(entry).forEach(([key, value]) => { + console.log(`${key}: ${value}`); + }); + }); + } else if (typeof result === 'object' && result !== null) { + Object.entries(result).forEach(([key, value]) => { + console.log(`${key}: ${value}`); + }); + } else { + console.log(result); + } + } finally { + await browser.disconnect(); + } + }); + +program + .command('screenshot') + .description('Capture the current viewport and print the temp PNG path.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .action(async (options) => { + const port = options.port as number; + const { browser, page } = await getActivePage(port); + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filePath = path.join(os.tmpdir(), `screenshot-${timestamp}.png`); + await page.screenshot({ path: filePath }); + console.log(filePath); + } finally { + await browser.disconnect(); + } + }); + +program + .command('pick ') + .description('Interactive DOM picker that prints metadata for clicked elements.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .action(async (messageParts: string[], options) => { + const message = messageParts.join(' '); + const port = options.port as number; + const { browser, page } = await getActivePage(port); + try { + await page.evaluate(() => { + const scope = globalThis as typeof globalThis & { + pickOverlayInjected?: boolean; + pick?: (prompt: string) => Promise; + }; + if (scope.pickOverlayInjected) { + return; + } + scope.pickOverlayInjected = true; + scope.pick = async (prompt: string) => + new Promise((resolve) => { + const selections: unknown[] = []; + const selectedElements = new Set(); + + const overlay = document.createElement('div'); + overlay.style.cssText = + 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647;pointer-events:none'; + + const highlight = document.createElement('div'); + highlight.style.cssText = + 'position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);transition:all 0.05s ease'; + overlay.appendChild(highlight); + + const banner = document.createElement('div'); + banner.style.cssText = + 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1f2937;color:#fff;padding:12px 24px;border-radius:8px;font:14px system-ui;box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;z-index:2147483647'; + + const updateBanner = () => { + banner.textContent = `${prompt} (${selections.length} selected, Cmd/Ctrl+click to add, Enter to finish, ESC to cancel)`; + }; + + const cleanup = () => { + document.removeEventListener('mousemove', onMove, true); + document.removeEventListener('click', onClick, true); + document.removeEventListener('keydown', onKey, true); + overlay.remove(); + banner.remove(); + selectedElements.forEach((el) => { + el.style.outline = ''; + }); + }; + + const serialize = (el: HTMLElement) => { + const parents: string[] = []; + let current = el.parentElement; + while (current && current !== document.body) { + const id = current.id ? `#${current.id}` : ''; + const cls = current.className ? `.${current.className.trim().split(/\s+/).join('.')}` : ''; + parents.push(`${current.tagName.toLowerCase()}${id}${cls}`); + current = current.parentElement; + } + return { + tag: el.tagName.toLowerCase(), + id: el.id || null, + class: el.className || null, + text: el.textContent?.trim()?.slice(0, 200) || null, + html: el.outerHTML.slice(0, 500), + parents: parents.join(' > '), + }; + }; + + const onMove = (event: MouseEvent) => { + const node = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null; + if (!node || overlay.contains(node) || banner.contains(node)) return; + const rect = node.getBoundingClientRect(); + highlight.style.cssText = `position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);top:${rect.top}px;left:${rect.left}px;width:${rect.width}px;height:${rect.height}px`; + }; + const onClick = (event: MouseEvent) => { + if (banner.contains(event.target as Node)) return; + event.preventDefault(); + event.stopPropagation(); + const node = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null; + if (!node || overlay.contains(node) || banner.contains(node)) return; + + if (event.metaKey || event.ctrlKey) { + if (!selectedElements.has(node)) { + selectedElements.add(node); + node.style.outline = '3px solid #10b981'; + selections.push(serialize(node)); + updateBanner(); + } + } else { + cleanup(); + const info = serialize(node); + resolve(selections.length > 0 ? selections : info); + } + }; + + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + cleanup(); + resolve(null); + } else if (event.key === 'Enter' && selections.length > 0) { + cleanup(); + resolve(selections); + } + }; + + document.addEventListener('mousemove', onMove, true); + document.addEventListener('click', onClick, true); + document.addEventListener('keydown', onKey, true); + + document.body.append(overlay, banner); + updateBanner(); + }); + }); + + const result = await page.evaluate((msg) => { + const pickFn = (window as Window & { pick?: (message: string) => Promise }).pick; + if (!pickFn) { + return null; + } + return pickFn(msg); + }, message); + + if (Array.isArray(result)) { + result.forEach((entry, index) => { + if (index > 0) { + console.log(''); + } + Object.entries(entry).forEach(([key, value]) => { + console.log(`${key}: ${value}`); + }); + }); + } else if (result && typeof result === 'object') { + Object.entries(result).forEach(([key, value]) => { + console.log(`${key}: ${value}`); + }); + } else { + console.log(result); + } + } finally { + await browser.disconnect(); + } + }); + +program + .command('console') + .description('Capture and display console logs from the active tab.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .option('--types ', 'Comma-separated log types to show (e.g., log,error,warn). Default: all types') + .option('--follow', 'Continuous monitoring mode (like tail -f)', false) + .option('--timeout ', 'Capture duration in seconds (default: 5 for one-shot, infinite for --follow)', (value) => Number.parseInt(value, 10)) + .option('--color', 'Force color output') + .option('--no-color', 'Disable color output') + .option('--no-serialize', 'Disable object serialization (show raw text only)', false) + .action(async (options) => { + const port = options.port as number; + const follow = options.follow as boolean; + const timeout = options.timeout as number | undefined; + const typesFilter = options.types as string | undefined; + const noSerialize = options.noSerialize as boolean; + const serialize = !noSerialize; + + // Track explicit color flags by looking at argv to avoid Commander defaults overriding TTY detection. + const argv = process.argv.slice(2); + const colorFlag = argv.includes('--color') ? true : argv.includes('--no-color') ? false : undefined; + + // Determine if we should use colors: explicit flag or TTY auto-detection + const useColor = colorFlag ?? process.stdout.isTTY; + + // Parse types filter + const normalizeType = (value: string) => { + const lower = value.toLowerCase(); + if (lower === 'warning') return 'warn'; + return lower; + }; + + const allowedTypes = typesFilter + ? new Set(typesFilter.split(',').map((t) => normalizeType(t.trim()))) + : null; // null means show all types + + // Color functions (no-op if colors disabled) + const colorize = (text: string, colorCode: string) => (useColor ? `\x1b[${colorCode}m${text}\x1b[0m` : text); + const red = (text: string) => colorize(text, '31'); + const yellow = (text: string) => colorize(text, '33'); + const cyan = (text: string) => colorize(text, '36'); + const gray = (text: string) => colorize(text, '90'); + const white = (text: string) => text; + + const typeColors: Record string> = { + error: red, + warn: yellow, + warning: yellow, + info: cyan, + debug: gray, + log: white, + pageerror: red, + }; + + // Helper function definitions (outside try/catch as they don't need error handling) + const formatTimestamp = () => { + const now = new Date(); + return now.toTimeString().split(' ')[0] + '.' + now.getMilliseconds().toString().padStart(3, '0'); + }; + + // Serialize value in Node.js util.inspect style with depth limit + const formatValue = (value: any, depth = 0, maxDepth = 10): string => { + if (depth > maxDepth) { + return '[Object]'; + } + + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string') return `'${value}'`; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (typeof value === 'function') return '[Function]'; + + if (Array.isArray(value)) { + const items = value.map((v) => formatValue(v, depth + 1, maxDepth)); + return `[ ${items.join(', ')} ]`; + } + + if (typeof value === 'object') { + const entries = Object.entries(value).map(([k, v]) => `${k}: ${formatValue(v, depth + 1, maxDepth)}`); + return entries.length > 0 ? `{ ${entries.join(', ')} }` : '{}'; + } + + return String(value); + }; + + // Serialize console message arguments + const serializeArgs = async (msg: any): Promise => { + try { + const args = msg.args(); + const values = await Promise.all( + args.map(async (arg: any) => { + try { + const value = await arg.jsonValue(); + return formatValue(value); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (errorMsg.includes('circular')) return '[Circular]'; + if (errorMsg.includes('reference chain')) return '[DeepObject]'; + return '[Unserializable]'; + } + }) + ); + return values.join(' '); + } catch { + // Fallback to text representation + return msg.text(); + } + }; + + const formatMessage = (type: string, text: string, location?: { url?: string; lineNumber?: number }) => { + const color = typeColors[type] || white; + const timestamp = formatTimestamp(); + const loc = location?.url && location?.lineNumber ? ` ${location.url}:${location.lineNumber}` : ''; + return color(`[${type.toUpperCase()}] ${timestamp} ${text}${loc}`); + }; + + // Execution code (needs try/catch for error handling) + const { browser, page } = await getActivePage(port); + + try { + // Set up console listener + page.on('console', async (msg) => { + const type = normalizeType(msg.type()); + if (allowedTypes && !allowedTypes.has(type)) { + return; // Filter out unwanted types + } + + const text = serialize ? await serializeArgs(msg) : msg.text(); + console.log(formatMessage(type, text, msg.location())); + }); + + // Set up page error listener + page.on('pageerror', (error) => { + if (allowedTypes && !allowedTypes.has('pageerror') && !allowedTypes.has('error')) { + return; + } + console.log(formatMessage('pageerror', error.message)); + }); + + if (follow) { + // Continuous monitoring mode + console.log(gray('Monitoring console logs (Ctrl+C to stop)...')); + const waitForExit = () => + new Promise((resolve) => { + const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP']; + const onSignal = () => { + cleanup(); + resolve(); + }; + const onBeforeExit = () => { + cleanup(); + resolve(); + }; + const cleanup = () => { + signals.forEach((signal) => process.off(signal, onSignal)); + process.off('beforeExit', onBeforeExit); + }; + signals.forEach((signal) => process.on(signal, onSignal)); + process.on('beforeExit', onBeforeExit); + }); + + await waitForExit(); + } else { + // One-shot mode with timeout + const duration = timeout ?? 5; + console.log(gray(`Capturing console logs for ${duration} seconds...`)); + await new Promise((resolve) => setTimeout(resolve, duration * 1000)); + } + } finally { + await browser.disconnect(); + } + }); + +program + .command('search ') + .description('Google search with optional readable content extraction.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .option('-n, --count ', 'Number of results to return (default: 5, max: 50)', (value) => Number.parseInt(value, 10), 5) + .option('--content', 'Fetch readable content for each result (slower).', false) + .option('--timeout ', 'Per-navigation timeout in seconds (default: 10).', (value) => Number.parseInt(value, 10), 10) + .action(async (queryWords: string[], options) => { + const port = options.port as number; + const count = Math.max(1, Math.min(options.count as number, 50)); + const fetchContent = Boolean(options.content); + const timeoutMs = Math.max(3, (options.timeout as number) ?? 10) * 1000; + const query = queryWords.join(' '); + + const { browser, page } = await getActivePage(port); + try { + const results: { title: string; link: string; snippet: string; content?: string }[] = []; + let start = 0; + while (results.length < count) { + const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}&start=${start}`; + await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: timeoutMs }).catch(() => {}); + await page.waitForSelector('div.MjjYud', { timeout: 3000 }).catch(() => {}); + + const pageResults = await page.evaluate(() => { + const items: { title: string; link: string; snippet: string }[] = []; + document.querySelectorAll('div.MjjYud').forEach((result) => { + const titleEl = result.querySelector('h3'); + const linkEl = result.querySelector('a'); + const snippetEl = result.querySelector('div.VwiC3b, div[data-sncf]'); + const link = linkEl?.getAttribute('href') ?? ''; + if (titleEl && linkEl && link && !link.startsWith('https://www.google.com')) { + items.push({ + title: titleEl.textContent?.trim() ?? '', + link, + snippet: snippetEl?.textContent?.trim() ?? '', + }); + } + }); + return items; + }); + + for (const r of pageResults) { + if (results.length >= count) break; + if (!results.some((existing) => existing.link === r.link)) { + results.push(r); + } + } + + if (pageResults.length === 0 || start >= 90) { + break; + } + start += 10; + } + + if (fetchContent) { + for (const result of results) { + try { + await page.goto(result.link, { waitUntil: 'networkidle2', timeout: timeoutMs }).catch(() => {}); + const article = await extractReadableContent(page); + result.content = article.content ?? '(No readable content)'; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + result.content = `(Error fetching content: ${message})`; + } + } + } + + results.forEach((r, index) => { + console.log(`--- Result ${index + 1} ---`); + console.log(`Title: ${r.title}`); + console.log(`Link: ${r.link}`); + if (r.snippet) { + console.log(`Snippet: ${r.snippet}`); + } + if (r.content) { + console.log(`Content:\n${r.content}`); + } + console.log(''); + }); + + if (results.length === 0) { + console.log('No results found.'); + } + } finally { + await browser.disconnect(); + } + }); + +program + .command('content ') + .description('Extract readable content from a URL as markdown-like text.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .option('--timeout ', 'Navigation timeout in seconds (default: 10).', (value) => Number.parseInt(value, 10), 10) + .action(async (url: string, options) => { + const port = options.port as number; + const timeoutMs = Math.max(3, (options.timeout as number) ?? 10) * 1000; + const { browser, page } = await getActivePage(port); + try { + await page.goto(url, { waitUntil: 'networkidle2', timeout: timeoutMs }).catch(() => {}); + const article = await extractReadableContent(page); + console.log(`URL: ${article.url}`); + if (article.title) { + console.log(`Title: ${article.title}`); + } + console.log(''); + console.log(article.content ?? '(No readable content)'); + } finally { + await browser.disconnect(); + } + }); + +program + .command('cookies') + .description('Dump cookies from the active tab as JSON.') + .option('--port ', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT) + .action(async (options) => { + const port = options.port as number; + const { browser, page } = await getActivePage(port); + try { + const cookies = await page.cookies(); + console.log(JSON.stringify(cookies, null, 2)); + } finally { + await browser.disconnect(); + } + }); + +program + .command('inspect') + .description('List Chrome processes launched with --remote-debugging-port and show their open tabs.') + .option('--ports ', 'Comma-separated list of ports to include.', parseNumberListArg) + .option('--pids ', 'Comma-separated list of PIDs to include.', parseNumberListArg) + .option('--json', 'Emit machine-readable JSON output.', false) + .action(async (options) => { + const ports = (options.ports as number[] | undefined)?.filter((entry) => Number.isFinite(entry) && entry > 0); + const pids = (options.pids as number[] | undefined)?.filter((entry) => Number.isFinite(entry) && entry > 0); + const sessions = await describeChromeSessions({ + ports, + pids, + includeAll: !ports?.length && !pids?.length, + }); + if (options.json) { + console.log(JSON.stringify(sessions, null, 2)); + return; + } + if (sessions.length === 0) { + console.log('No Chrome instances with DevTools ports found.'); + return; + } + sessions.forEach((session, index) => { + if (index > 0) { + console.log(''); + } + const transport = session.port !== undefined ? `port ${session.port}` : session.usesPipe ? 'debugging pipe' : 'unknown transport'; + const header = [`Chrome PID ${session.pid}`, `(${transport})`]; + if (session.version?.Browser) { + header.push(`- ${session.version.Browser}`); + } + console.log(header.join(' ')); + if (session.tabs.length === 0) { + console.log(' (no tabs reported)'); + return; + } + session.tabs.forEach((tab, idx) => { + const title = tab.title || '(untitled)'; + const url = tab.url || '(no url)'; + console.log(` Tab ${idx + 1}: ${title}`); + console.log(` ${url}`); + }); + }); + }); + +program + .command('kill') + .description('Terminate Chrome instances that have DevTools ports open.') + .option('--ports ', 'Comma-separated list of ports to target.', parseNumberListArg) + .option('--pids ', 'Comma-separated list of PIDs to target.', parseNumberListArg) + .option('--all', 'Kill every matching Chrome instance.', false) + .option('--force', 'Skip the confirmation prompt.', false) + .action(async (options) => { + const ports = (options.ports as number[] | undefined)?.filter((entry) => Number.isFinite(entry) && entry > 0); + const pids = (options.pids as number[] | undefined)?.filter((entry) => Number.isFinite(entry) && entry > 0); + const killAll = Boolean(options.all); + if (!killAll && (!ports?.length && !pids?.length)) { + console.error('Specify --all, --ports , or --pids to select targets.'); + process.exit(1); + } + const sessions = await describeChromeSessions({ ports, pids, includeAll: killAll }); + if (sessions.length === 0) { + console.log('No matching Chrome instances found.'); + return; + } + if (!options.force) { + console.log('About to terminate the following Chrome sessions:'); + sessions.forEach((session) => { + const transport = session.port !== undefined ? `port ${session.port}` : session.usesPipe ? 'debugging pipe' : 'unknown transport'; + console.log(` PID ${session.pid} (${transport})`); + }); + const rl = readline.createInterface({ input, output }); + const answer = (await rl.question('Proceed? [y/N] ')).trim().toLowerCase(); + rl.close(); + if (answer !== 'y' && answer !== 'yes') { + console.log('Aborted.'); + return; + } + } + const failures: { pid: number; error: string }[] = []; + sessions.forEach((session) => { + try { + process.kill(session.pid); + const transport = session.port !== undefined ? `port ${session.port}` : session.usesPipe ? 'debugging pipe' : 'unknown transport'; + console.log(`✓ Killed Chrome PID ${session.pid} (${transport})`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`✗ Failed to kill PID ${session.pid}: ${message}`); + failures.push({ pid: session.pid, error: message }); + } + }); + if (failures.length > 0) { + process.exitCode = 1; + } + }); + +interface ChromeProcessInfo { + pid: number; + port?: number; + usesPipe: boolean; + command: string; +} + +interface ChromeTabInfo { + id?: string; + title?: string; + url?: string; + type?: string; +} + +interface ChromeSessionDescription extends ChromeProcessInfo { + version?: Record; + tabs: ChromeTabInfo[]; +} + +async function ensureReadability(page: any) { + try { + await page.setBypassCSP?.(true); + } catch { + // ignore + } + const scripts = [ + 'https://unpkg.com/@mozilla/readability@0.4.4/Readability.js', + 'https://unpkg.com/turndown@7.1.2/dist/turndown.js', + 'https://unpkg.com/turndown-plugin-gfm@1.0.2/dist/turndown-plugin-gfm.js', + ]; + for (const src of scripts) { + try { + const alreadyLoaded = await page.evaluate((url) => { + return Boolean(document.querySelector(`script[src="${url}"]`)); + }, src); + if (!alreadyLoaded) { + await page.addScriptTag({ url: src }); + } + } catch { + // best-effort; continue + } + } +} + +async function extractReadableContent(page: any): Promise<{ title?: string; content?: string; url: string }> { + await ensureReadability(page); + const result = await page.evaluate(() => { + const asMarkdown = (html: string | null | undefined) => { + if (!html) return ''; + const TurndownService = (window as any).TurndownService; + const turndownPluginGfm = (window as any).turndownPluginGfm; + if (!TurndownService) { + return ''; + } + const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' }); + if (turndownPluginGfm?.gfm) { + turndown.use(turndownPluginGfm.gfm); + } + return turndown + .turndown(html) + .replace(/\n{3,}/g, '\n\n') + .trim(); + }; + + const fallbackText = () => { + const main = + document.querySelector('main, article, [role="main"], .content, #content') || document.body || document.documentElement; + return main?.textContent?.trim() ?? ''; + }; + + let title = document.title; + let content = ''; + + try { + const Readability = (window as any).Readability; + if (Readability) { + const clone = document.cloneNode(true) as Document; + const article = new Readability(clone).parse(); + title = article?.title || title; + content = asMarkdown(article?.content) || article?.textContent || ''; + } + } catch { + // ignore readability failures + } + + if (!content) { + content = fallbackText(); + } + + content = content?.trim().slice(0, 8000); + + return { title, content, url: location.href }; + }); + return result; +} + +function parseNumberListArg(value: string): number[] { + return parseNumberList(value) ?? []; +} + +function parseNumberList(inputValue: string | undefined): number[] | undefined { + if (!inputValue) { + return undefined; + } + const parsed = inputValue + .split(',') + .map((entry) => Number.parseInt(entry.trim(), 10)) + .filter((value) => Number.isFinite(value)); + return parsed.length > 0 ? parsed : undefined; +} + +async function describeChromeSessions(options: { + ports?: number[]; + pids?: number[]; + includeAll?: boolean; +}): Promise { + const { ports, pids, includeAll } = options; + const processes = await listDevtoolsChromes(); + const portSet = new Set(ports ?? []); + const pidSet = new Set(pids ?? []); + const candidates = processes.filter((proc) => { + if (includeAll) { + return true; + } + if (portSet.size > 0 && proc.port !== undefined && portSet.has(proc.port)) { + return true; + } + if (pidSet.size > 0 && pidSet.has(proc.pid)) { + return true; + } + return false; + }); + const results: ChromeSessionDescription[] = []; + for (const proc of candidates) { + let version: Record | undefined; + let filteredTabs: ChromeTabInfo[] = []; + if (proc.port !== undefined) { + const [versionResp, tabs] = await Promise.all([ + fetchJson(`http://localhost:${proc.port}/json/version`).catch(() => undefined), + fetchJson(`http://localhost:${proc.port}/json/list`).catch(() => []), + ]); + version = versionResp as Record | undefined; + filteredTabs = Array.isArray(tabs) + ? (tabs as ChromeTabInfo[]).filter((tab) => { + const type = tab.type?.toLowerCase() ?? ''; + if (type && type !== 'page' && type !== 'app') { + if (!tab.url || tab.url.startsWith('devtools://') || tab.url.startsWith('chrome-extension://')) { + return false; + } + } + if (!tab.url || tab.url.trim().length === 0) { + return false; + } + return true; + }) + : []; + } + results.push({ + ...proc, + version, + tabs: filteredTabs, + }); + } + return results; +} + +async function listDevtoolsChromes(): Promise { + if (process.platform !== 'darwin' && process.platform !== 'linux') { + console.warn('Chrome inspection is only supported on macOS and Linux for now.'); + return []; + } + let output = ''; + try { + output = execSync('ps -ax -o pid=,command=', { encoding: 'utf8' }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to enumerate processes: ${message}`); + } + const processes: ChromeProcessInfo[] = []; + output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .forEach((line) => { + const match = line.match(/^(\d+)\s+(.+)$/); + if (!match) { + return; + } + const pid = Number.parseInt(match[1], 10); + const command = match[2]; + if (!Number.isFinite(pid) || pid <= 0) { + return; + } + if (!/chrome/i.test(command) || (!/--remote-debugging-port/.test(command) && !/--remote-debugging-pipe/.test(command))) { + return; + } + const portMatch = command.match(/--remote-debugging-port(?:=|\s+)(\d+)/); + if (portMatch) { + const port = Number.parseInt(portMatch[1], 10); + if (!Number.isFinite(port)) { + return; + } + processes.push({ pid, port, usesPipe: false, command }); + return; + } + if (/--remote-debugging-pipe/.test(command)) { + processes.push({ pid, usesPipe: true, command }); + } + }); + return processes; +} + +function fetchJson(url: string, timeoutMs = 2000): Promise { + return new Promise((resolve, reject) => { + const request = http.get(url, { timeout: timeoutMs }, (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + if ((response.statusCode ?? 500) >= 400) { + reject(new Error(`HTTP ${response.statusCode} for ${url}`)); + return; + } + try { + resolve(JSON.parse(body)); + } catch { + resolve(undefined); + } + }); + }); + request.on('timeout', () => { + request.destroy(new Error(`Request to ${url} timed out`)); + }); + request.on('error', (error) => { + reject(error); + }); + }); +} + +program.parseAsync(process.argv); diff --git a/claude-code/skills/webapp-testing/bin/package.json b/claude-code/skills/webapp-testing/bin/package.json new file mode 100644 index 0000000..f59d07d --- /dev/null +++ b/claude-code/skills/webapp-testing/bin/package.json @@ -0,0 +1,7 @@ +{ + "type": "module", + "dependencies": { + "commander": "^12.1.0", + "puppeteer-core": "^23.6.0" + } +} diff --git a/claude-code/skills/webapp-testing/examples/element_discovery.py b/claude-code/skills/webapp-testing/examples/element_discovery.py index 917ba72..8ddc5af 100755 --- a/claude-code/skills/webapp-testing/examples/element_discovery.py +++ b/claude-code/skills/webapp-testing/examples/element_discovery.py @@ -7,7 +7,7 @@ page = browser.new_page() # Navigate to page and wait for it to fully load - page.goto('http://localhost:5173') + page.goto('http://localhost:3000') # Replace with your app URL page.wait_for_load_state('networkidle') # Discover all buttons on the page diff --git a/claude-code/skills/webapp-testing/examples/multi_step_registration.py b/claude-code/skills/webapp-testing/examples/multi_step_registration.py new file mode 100644 index 0000000..e960ff6 --- /dev/null +++ b/claude-code/skills/webapp-testing/examples/multi_step_registration.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +Multi-Step Registration Example + +Demonstrates complete registration flow using all webapp-testing utilities: +- UI interactions (cookie banners, modals) +- Smart form filling (handles field variations) +- Database operations (invite codes, email verification) +- Advanced wait strategies + +This example is based on a real-world React/Supabase app with 3-step registration. +""" + +import sys +import os +from playwright.sync_api import sync_playwright +import time + +# Add utils to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from utils.ui_interactions import dismiss_cookie_banner, dismiss_modal +from utils.form_helpers import SmartFormFiller, handle_multi_step_form +from utils.supabase import SupabaseTestClient +from utils.wait_strategies import combined_wait, smart_navigation_wait + + +def register_user_complete_flow(): + """ + Complete multi-step registration with database setup and verification. + + Flow: + 1. Create invite code in database + 2. Navigate to registration page + 3. Fill multi-step form (Code → Credentials → Personal Info → Avatar) + 4. Verify email via database + 5. Login + 6. Verify dashboard access + 7. Cleanup (optional) + """ + + # Configuration - adjust for your app + APP_URL = "http://localhost:3000" + REGISTER_URL = f"{APP_URL}/register" + + # Database config (adjust for your project) + DB_PASSWORD = "your-db-password" + SUPABASE_URL = "https://project.supabase.co" + SERVICE_KEY = "your-service-role-key" + + # Test user data + TEST_EMAIL = "test.user@example.com" + TEST_PASSWORD = "TestPass123!" + FULL_NAME = "Test User" + PHONE = "+447700900123" + DATE_OF_BIRTH = "1990-01-15" + INVITE_CODE = "TEST2024" + + print("\n" + "="*60) + print("MULTI-STEP REGISTRATION AUTOMATION") + print("="*60) + + # Step 1: Setup database + print("\n[1/8] Setting up database...") + db_client = SupabaseTestClient( + url=SUPABASE_URL, + service_key=SERVICE_KEY, + db_password=DB_PASSWORD + ) + + # Create invite code + if db_client.create_invite_code(INVITE_CODE, code_type="general"): + print(f" ✓ Created invite code: {INVITE_CODE}") + else: + print(f" ⚠️ Invite code may already exist") + + # Clean up any existing test user + existing_user = db_client.find_user_by_email(TEST_EMAIL) + if existing_user: + print(f" Cleaning up existing user...") + db_client.cleanup_related_records(existing_user) + db_client.delete_user(existing_user) + + # Step 2: Start browser automation + print("\n[2/8] Starting browser automation...") + + with sync_playwright() as p: + browser = p.chromium.launch(headless=False) + page = browser.new_page(viewport={'width': 1400, 'height': 1000}) + + try: + # Step 3: Navigate to registration + print("\n[3/8] Navigating to registration page...") + page.goto(REGISTER_URL, wait_until='networkidle') + time.sleep(2) + + # Handle cookie banner + if dismiss_cookie_banner(page): + print(" ✓ Dismissed cookie banner") + + page.screenshot(path='/tmp/reg_step1_start.png', full_page=True) + print(" ✓ Screenshot: /tmp/reg_step1_start.png") + + # Step 4: Fill multi-step form + print("\n[4/8] Filling multi-step registration form...") + + # Define form steps + steps = [ + { + 'name': 'Invite Code', + 'fields': {'invite_code': INVITE_CODE}, + 'custom_fill': lambda: page.locator('input').first.fill(INVITE_CODE), + 'custom_submit': lambda: page.locator('input').first.press('Enter'), + }, + { + 'name': 'Credentials', + 'fields': { + 'email': TEST_EMAIL, + 'password': TEST_PASSWORD, + }, + 'checkbox': True, # Terms of service + }, + { + 'name': 'Personal Info', + 'fields': { + 'full_name': FULL_NAME, + 'date_of_birth': DATE_OF_BIRTH, + 'phone': PHONE, + }, + }, + { + 'name': 'Avatar Selection', + 'complete': True, # Final step with COMPLETE button + } + ] + + # Process each step + filler = SmartFormFiller() + + for i, step in enumerate(steps): + print(f"\n Step {i+1}/4: {step['name']}") + + # Custom filling logic for first step (invite code) + if 'custom_fill' in step: + step['custom_fill']() + time.sleep(1) + + if 'custom_submit' in step: + step['custom_submit']() + else: + page.locator('button:has-text("CONTINUE")').first.click() + + time.sleep(4) + page.wait_for_load_state('networkidle') + time.sleep(2) + + # Standard form filling for other steps + elif 'fields' in step: + if 'email' in step['fields']: + filler.fill_email_field(page, step['fields']['email']) + print(" ✓ Email") + + if 'password' in step['fields']: + filler.fill_password_fields(page, step['fields']['password']) + print(" ✓ Password") + + if 'full_name' in step['fields']: + filler.fill_name_field(page, step['fields']['full_name']) + print(" ✓ Full Name") + + if 'date_of_birth' in step['fields']: + filler.fill_date_field(page, step['fields']['date_of_birth'], field_hint='birth') + print(" ✓ Date of Birth") + + if 'phone' in step['fields']: + filler.fill_phone_field(page, step['fields']['phone']) + print(" ✓ Phone") + + # Check terms checkbox if needed + if step.get('checkbox'): + page.locator('input[type="checkbox"]').first.check() + print(" ✓ Terms accepted") + + time.sleep(1) + + # Click continue + page.locator('button:has-text("CONTINUE")').first.click() + time.sleep(4) + page.wait_for_load_state('networkidle') + time.sleep(2) + + # Final step - click COMPLETE + elif step.get('complete'): + complete_btn = page.locator('button:has-text("COMPLETE")').first + complete_btn.click() + print(" ✓ Clicked COMPLETE") + + time.sleep(8) + page.wait_for_load_state('networkidle') + time.sleep(3) + + # Screenshot after each step + page.screenshot(path=f'/tmp/reg_step{i+1}_complete.png', full_page=True) + print(f" ✓ Screenshot: /tmp/reg_step{i+1}_complete.png") + + print("\n ✓ Multi-step form completed!") + + # Step 5: Handle post-registration + print("\n[5/8] Handling post-registration...") + + # Dismiss welcome modal if present + if dismiss_modal(page, modal_identifier="Welcome"): + print(" ✓ Dismissed welcome modal") + + current_url = page.url + print(f" Current URL: {current_url}") + + # Step 6: Verify email via database + print("\n[6/8] Verifying email via database...") + time.sleep(2) # Brief wait for user to be created in DB + + user_id = db_client.find_user_by_email(TEST_EMAIL) + if user_id: + print(f" ✓ Found user: {user_id}") + + if db_client.confirm_email(user_id): + print(" ✓ Email verified in database") + else: + print(" ⚠️ Could not verify email") + else: + print(" ⚠️ User not found in database") + + # Step 7: Login (if not already logged in) + print("\n[7/8] Logging in...") + + if 'login' in current_url.lower(): + print(" Needs login...") + + filler.fill_email_field(page, TEST_EMAIL) + filler.fill_password_fields(page, TEST_PASSWORD, confirm=False) + time.sleep(1) + + page.locator('button[type="submit"]').first.click() + time.sleep(6) + page.wait_for_load_state('networkidle') + time.sleep(3) + + print(" ✓ Logged in") + else: + print(" ✓ Already logged in") + + # Step 8: Verify dashboard access + print("\n[8/8] Verifying dashboard access...") + + # Navigate to dashboard/perform if not already there + if 'perform' not in page.url.lower() and 'dashboard' not in page.url.lower(): + page.goto(f"{APP_URL}/perform", wait_until='networkidle') + time.sleep(3) + + page.screenshot(path='/tmp/reg_final_dashboard.png', full_page=True) + print(" ✓ Screenshot: /tmp/reg_final_dashboard.png") + + # Check if we're on the dashboard + if 'perform' in page.url.lower() or 'dashboard' in page.url.lower(): + print(" ✓ Successfully reached dashboard!") + else: + print(f" ⚠️ Unexpected URL: {page.url}") + + print("\n" + "="*60) + print("REGISTRATION COMPLETE!") + print("="*60) + print(f"\nUser: {TEST_EMAIL}") + print(f"Password: {TEST_PASSWORD}") + print(f"User ID: {user_id}") + print(f"\nScreenshots saved to /tmp/reg_step*.png") + print("="*60) + + # Keep browser open for inspection + print("\nKeeping browser open for 30 seconds...") + time.sleep(30) + + except Exception as e: + print(f"\n❌ Error: {e}") + import traceback + traceback.print_exc() + page.screenshot(path='/tmp/reg_error.png', full_page=True) + print(" Error screenshot: /tmp/reg_error.png") + + finally: + browser.close() + + # Optional cleanup + print("\n" + "="*60) + print("Cleanup") + print("="*60) + + cleanup = input("\nDelete test user? (y/N): ").strip().lower() + if cleanup == 'y' and user_id: + print("Cleaning up...") + db_client.cleanup_related_records(user_id) + db_client.delete_user(user_id) + print("✓ Test user deleted") + else: + print("Test user kept for manual testing") + + +if __name__ == '__main__': + print("\nMulti-Step Registration Automation Example") + print("=" * 60) + print("\nBefore running:") + print("1. Update configuration variables at the top of the script") + print("2. Ensure your app is running (e.g., npm run dev)") + print("3. Have database credentials ready") + print("\n" + "=" * 60) + + proceed = input("\nProceed with registration? (y/N): ").strip().lower() + + if proceed == 'y': + register_user_complete_flow() + else: + print("\nCancelled.") diff --git a/claude-code/skills/webapp-testing/utils/__pycache__/form_helpers.cpython-312.pyc b/claude-code/skills/webapp-testing/utils/__pycache__/form_helpers.cpython-312.pyc new file mode 100644 index 0000000..93f6999 Binary files /dev/null and b/claude-code/skills/webapp-testing/utils/__pycache__/form_helpers.cpython-312.pyc differ diff --git a/claude-code/skills/webapp-testing/utils/__pycache__/supabase.cpython-312.pyc b/claude-code/skills/webapp-testing/utils/__pycache__/supabase.cpython-312.pyc new file mode 100644 index 0000000..989c929 Binary files /dev/null and b/claude-code/skills/webapp-testing/utils/__pycache__/supabase.cpython-312.pyc differ diff --git a/claude-code/skills/webapp-testing/utils/__pycache__/ui_interactions.cpython-312.pyc b/claude-code/skills/webapp-testing/utils/__pycache__/ui_interactions.cpython-312.pyc new file mode 100644 index 0000000..0b547b4 Binary files /dev/null and b/claude-code/skills/webapp-testing/utils/__pycache__/ui_interactions.cpython-312.pyc differ diff --git a/claude-code/skills/webapp-testing/utils/__pycache__/wait_strategies.cpython-312.pyc b/claude-code/skills/webapp-testing/utils/__pycache__/wait_strategies.cpython-312.pyc new file mode 100644 index 0000000..2de673e Binary files /dev/null and b/claude-code/skills/webapp-testing/utils/__pycache__/wait_strategies.cpython-312.pyc differ diff --git a/claude-code/skills/webapp-testing/utils/browser_config.py b/claude-code/skills/webapp-testing/utils/browser_config.py new file mode 100644 index 0000000..0555ffa --- /dev/null +++ b/claude-code/skills/webapp-testing/utils/browser_config.py @@ -0,0 +1,217 @@ +""" +Smart Browser Configuration for Testing + +Automatically configures browser context with appropriate settings +based on the testing environment (localhost vs production). + +Usage: + from utils.browser_config import BrowserConfig + + context = BrowserConfig.create_test_context( + browser, + 'http://localhost:3000' + ) +""" + +from typing import Optional, Dict +from playwright.sync_api import Browser, BrowserContext +from urllib.parse import urlparse + + +class BrowserConfig: + """Smart browser configuration for testing environments""" + + @staticmethod + def is_localhost_url(url: str) -> bool: + """ + Check if URL is localhost or local development environment. + + Args: + url: URL to check + + Returns: + True if localhost/127.0.0.1, False otherwise + + Examples: + >>> BrowserConfig.is_localhost_url('http://localhost:3000') + True + >>> BrowserConfig.is_localhost_url('http://127.0.0.1:8080') + True + >>> BrowserConfig.is_localhost_url('https://production.com') + False + """ + try: + parsed = urlparse(url) + hostname = parsed.hostname or parsed.netloc + + localhost_patterns = [ + 'localhost', + '127.0.0.1', + '0.0.0.0', + '::1', # IPv6 localhost + ] + + return any(pattern in hostname.lower() for pattern in localhost_patterns) + except Exception: + return False + + @staticmethod + def create_test_context( + browser: Browser, + base_url: str = 'http://localhost', + bypass_csp: Optional[bool] = None, + ignore_https_errors: bool = True, + extra_http_headers: Optional[Dict[str, str]] = None, + viewport: Optional[Dict[str, int]] = None, + record_video: bool = False, + verbose: bool = True + ) -> BrowserContext: + """ + Create browser context optimized for testing. + + Auto-detects CSP bypass need: + - If base_url contains 'localhost' or '127.0.0.1' → bypass_csp=True + - Otherwise → bypass_csp=False + + Args: + browser: Playwright browser instance + base_url: Base URL of application under test + bypass_csp: Override auto-detection (None = auto-detect) + ignore_https_errors: Ignore HTTPS errors (self-signed certs) + extra_http_headers: Additional HTTP headers to send + viewport: Custom viewport size (default: 1280x720) + record_video: Record video of test session + verbose: Print configuration choices + + Returns: + Configured browser context + + Example: + # Auto-detect CSP bypass for localhost + context = BrowserConfig.create_test_context( + browser, + 'http://localhost:7160' + ) + # Output: 🔓 CSP bypass enabled (testing on localhost) + + # Manually override for production testing + context = BrowserConfig.create_test_context( + browser, + 'https://production.com', + bypass_csp=False + ) + """ + # Auto-detect CSP bypass if not specified + if bypass_csp is None: + bypass_csp = BrowserConfig.is_localhost_url(base_url) + + # Default viewport for consistent testing + if viewport is None: + viewport = {'width': 1280, 'height': 720} + + # Build context options + context_options = { + 'bypass_csp': bypass_csp, + 'ignore_https_errors': ignore_https_errors, + 'viewport': viewport, + } + + # Add extra headers if provided + if extra_http_headers: + context_options['extra_http_headers'] = extra_http_headers + + # Add video recording if requested + if record_video: + context_options['record_video_dir'] = '/tmp/playwright-videos' + + # Create context + context = browser.new_context(**context_options) + + # Print configuration for visibility + if verbose: + print("\n" + "=" * 60) + print(" Browser Context Configuration") + print("=" * 60) + print(f" Base URL: {base_url}") + + if bypass_csp: + print(" 🔓 CSP bypass: ENABLED (testing on localhost)") + else: + print(" 🔒 CSP bypass: DISABLED (production mode)") + + if ignore_https_errors: + print(" ⚠️ HTTPS errors: IGNORED (self-signed certs OK)") + + print(f" 📐 Viewport: {viewport['width']}x{viewport['height']}") + + if extra_http_headers: + print(f" 📨 Extra headers: {len(extra_http_headers)} header(s)") + + if record_video: + print(" 🎥 Video recording: ENABLED") + + print("=" * 60 + "\n") + + return context + + @staticmethod + def create_mobile_context( + browser: Browser, + device: str = 'iPhone 12', + base_url: str = 'http://localhost', + bypass_csp: Optional[bool] = None, + verbose: bool = True + ) -> BrowserContext: + """ + Create mobile browser context with device emulation. + + Args: + browser: Playwright browser instance + device: Device to emulate (e.g., 'iPhone 12', 'Pixel 5') + base_url: Base URL of application under test + bypass_csp: Override auto-detection + verbose: Print configuration + + Returns: + Mobile browser context + + Example: + context = BrowserConfig.create_mobile_context( + browser, + device='iPhone 12', + base_url='http://localhost:3000' + ) + """ + from playwright.sync_api import devices + + # Get device descriptor + if device not in devices: + available = ', '.join(list(devices.keys())[:5]) + raise ValueError( + f"Unknown device: {device}. " + f"Available: {available}, ..." + ) + + device_descriptor = devices[device] + + # Auto-detect CSP bypass + if bypass_csp is None: + bypass_csp = BrowserConfig.is_localhost_url(base_url) + + # Merge with our defaults + context_options = { + **device_descriptor, + 'bypass_csp': bypass_csp, + 'ignore_https_errors': True, + } + + context = browser.new_context(**context_options) + + if verbose: + print(f"\n📱 Mobile context: {device}") + print(f" Viewport: {device_descriptor['viewport']}") + if bypass_csp: + print(f" 🔓 CSP bypass: ENABLED") + print() + + return context diff --git a/claude-code/skills/webapp-testing/utils/form_helpers.py b/claude-code/skills/webapp-testing/utils/form_helpers.py new file mode 100644 index 0000000..e011f5f --- /dev/null +++ b/claude-code/skills/webapp-testing/utils/form_helpers.py @@ -0,0 +1,463 @@ +""" +Smart Form Filling Helpers + +Handles common form patterns across web applications: +- Multi-step forms with validation +- Dynamic field variations (full name vs first/last name) +- Retry strategies for flaky selectors +- Intelligent field detection +""" + +from playwright.sync_api import Page +from typing import Dict, List, Any, Optional +import time + + +class SmartFormFiller: + """ + Intelligent form filling that handles variations in field structures. + + Example: + ```python + filler = SmartFormFiller() + filler.fill_name_field(page, "John Doe") # Tries full name or first/last + filler.fill_email_field(page, "test@example.com") + filler.fill_password_fields(page, "SecurePass123!") + ``` + """ + + @staticmethod + def fill_name_field(page: Page, full_name: str, timeout: int = 5000) -> bool: + """ + Fill name field(s) - handles both single "Full Name" and separate "First/Last Name" fields. + + Args: + page: Playwright Page object + full_name: Full name as string (e.g., "John Doe") + timeout: Maximum time to wait for fields (milliseconds) + + Returns: + True if successful, False otherwise + + Example: + ```python + # Works with both field structures: + # - Single field: "Full Name" + # - Separate fields: "First Name" and "Last Name" + fill_name_field(page, "Jane Smith") + ``` + """ + # Strategy 1: Try single "Full Name" field + full_name_selectors = [ + 'input[name*="full" i][name*="name" i]', + 'input[placeholder*="full name" i]', + 'input[placeholder*="name" i]', + 'input[id*="fullname" i]', + 'input[id*="full-name" i]', + ] + + for selector in full_name_selectors: + try: + field = page.locator(selector).first + if field.is_visible(timeout=1000): + field.fill(full_name) + return True + except: + continue + + # Strategy 2: Try separate First/Last Name fields + parts = full_name.split(' ', 1) + first_name = parts[0] if parts else full_name + last_name = parts[1] if len(parts) > 1 else '' + + first_name_selectors = [ + 'input[name*="first" i][name*="name" i]', + 'input[placeholder*="first name" i]', + 'input[id*="firstname" i]', + 'input[id*="first-name" i]', + ] + + last_name_selectors = [ + 'input[name*="last" i][name*="name" i]', + 'input[placeholder*="last name" i]', + 'input[id*="lastname" i]', + 'input[id*="last-name" i]', + ] + + first_filled = False + last_filled = False + + # Fill first name + for selector in first_name_selectors: + try: + field = page.locator(selector).first + if field.is_visible(timeout=1000): + field.fill(first_name) + first_filled = True + break + except: + continue + + # Fill last name + for selector in last_name_selectors: + try: + field = page.locator(selector).first + if field.is_visible(timeout=1000): + field.fill(last_name) + last_filled = True + break + except: + continue + + return first_filled or last_filled + + @staticmethod + def fill_email_field(page: Page, email: str, timeout: int = 5000) -> bool: + """ + Fill email field with multiple selector strategies. + + Args: + page: Playwright Page object + email: Email address + timeout: Maximum time to wait for field (milliseconds) + + Returns: + True if successful, False otherwise + """ + email_selectors = [ + 'input[type="email"]', + 'input[name="email" i]', + 'input[placeholder*="email" i]', + 'input[id*="email" i]', + 'input[autocomplete="email"]', + ] + + for selector in email_selectors: + try: + field = page.locator(selector).first + if field.is_visible(timeout=1000): + field.fill(email) + return True + except: + continue + + return False + + @staticmethod + def fill_password_fields(page: Page, password: str, confirm: bool = True, timeout: int = 5000) -> bool: + """ + Fill password field(s) - handles both single password and password + confirm. + + Args: + page: Playwright Page object + password: Password string + confirm: Whether to also fill confirmation field (default True) + timeout: Maximum time to wait for fields (milliseconds) + + Returns: + True if successful, False otherwise + """ + password_fields = page.locator('input[type="password"]').all() + + if not password_fields: + return False + + # Fill first password field + try: + password_fields[0].fill(password) + except: + return False + + # Fill confirmation field if requested and exists + if confirm and len(password_fields) > 1: + try: + password_fields[1].fill(password) + except: + pass + + return True + + @staticmethod + def fill_phone_field(page: Page, phone: str, timeout: int = 5000) -> bool: + """ + Fill phone number field with multiple selector strategies. + + Args: + page: Playwright Page object + phone: Phone number string + timeout: Maximum time to wait for field (milliseconds) + + Returns: + True if successful, False otherwise + """ + phone_selectors = [ + 'input[type="tel"]', + 'input[name*="phone" i]', + 'input[placeholder*="phone" i]', + 'input[id*="phone" i]', + 'input[autocomplete="tel"]', + ] + + for selector in phone_selectors: + try: + field = page.locator(selector).first + if field.is_visible(timeout=1000): + field.fill(phone) + return True + except: + continue + + return False + + @staticmethod + def fill_date_field(page: Page, date_value: str, field_hint: str = None, timeout: int = 5000) -> bool: + """ + Fill date field (handles both date input and text input). + + Args: + page: Playwright Page object + date_value: Date as string (format: YYYY-MM-DD for date inputs) + field_hint: Optional hint about field (e.g., "birth", "start", "end") + timeout: Maximum time to wait for field (milliseconds) + + Returns: + True if successful, False otherwise + + Example: + ```python + fill_date_field(page, "1990-01-15", field_hint="birth") + ``` + """ + # Build selectors based on hint + date_selectors = ['input[type="date"]'] + + if field_hint: + date_selectors.extend([ + f'input[name*="{field_hint}" i]', + f'input[placeholder*="{field_hint}" i]', + f'input[id*="{field_hint}" i]', + ]) + + for selector in date_selectors: + try: + field = page.locator(selector).first + if field.is_visible(timeout=1000): + field.fill(date_value) + return True + except: + continue + + return False + + +def fill_with_retry(page: Page, selectors: List[str], value: str, max_attempts: int = 3) -> bool: + """ + Try multiple selectors with retry logic. + + Args: + page: Playwright Page object + selectors: List of CSS selectors to try + value: Value to fill + max_attempts: Maximum retry attempts per selector + + Returns: + True if any selector succeeded, False otherwise + + Example: + ```python + selectors = ['input#email', 'input[name="email"]', 'input[type="email"]'] + fill_with_retry(page, selectors, 'test@example.com') + ``` + """ + for selector in selectors: + for attempt in range(max_attempts): + try: + field = page.locator(selector).first + if field.is_visible(timeout=1000): + field.fill(value) + time.sleep(0.3) + # Verify value was set + if field.input_value() == value: + return True + except: + if attempt < max_attempts - 1: + time.sleep(0.5) + continue + + return False + + +def handle_multi_step_form(page: Page, steps: List[Dict[str, Any]], continue_button_text: str = "CONTINUE") -> bool: + """ + Automate multi-step form completion. + + Args: + page: Playwright Page object + steps: List of step configurations, each with fields and actions + continue_button_text: Text of button to advance steps + + Returns: + True if all steps completed successfully, False otherwise + + Example: + ```python + steps = [ + { + 'fields': {'email': 'test@example.com', 'password': 'Pass123!'}, + 'checkbox': 'terms', # Optional checkbox to check + 'wait_after': 2, # Optional wait time after step + }, + { + 'fields': {'full_name': 'John Doe', 'date_of_birth': '1990-01-15'}, + }, + { + 'complete': True, # Final step, click complete/finish button + } + ] + handle_multi_step_form(page, steps) + ``` + """ + filler = SmartFormFiller() + + for i, step in enumerate(steps): + print(f" Processing step {i+1}/{len(steps)}...") + + # Fill fields in this step + if 'fields' in step: + for field_type, value in step['fields'].items(): + if field_type == 'email': + filler.fill_email_field(page, value) + elif field_type == 'password': + filler.fill_password_fields(page, value) + elif field_type == 'full_name': + filler.fill_name_field(page, value) + elif field_type == 'phone': + filler.fill_phone_field(page, value) + elif field_type.startswith('date'): + hint = field_type.replace('date_', '').replace('_', ' ') + filler.fill_date_field(page, value, field_hint=hint) + else: + # Generic field - try to find and fill + print(f" Warning: Unknown field type '{field_type}'") + + # Check checkbox if specified + if 'checkbox' in step: + try: + checkbox = page.locator('input[type="checkbox"]').first + checkbox.check() + except: + print(f" Warning: Could not check checkbox") + + # Wait if specified + if 'wait_after' in step: + time.sleep(step['wait_after']) + else: + time.sleep(1) + + # Click continue/submit button + if i < len(steps) - 1: # Not the last step + button_selectors = [ + f'button:has-text("{continue_button_text}")', + 'button[type="submit"]', + 'button:has-text("Next")', + 'button:has-text("Continue")', + ] + + clicked = False + for selector in button_selectors: + try: + button = page.locator(selector).first + if button.is_visible(timeout=2000): + button.click() + clicked = True + break + except: + continue + + if not clicked: + print(f" Warning: Could not find continue button for step {i+1}") + return False + + # Wait for next step to load + page.wait_for_load_state('networkidle') + time.sleep(2) + + else: # Last step + if step.get('complete', False): + complete_selectors = [ + 'button:has-text("COMPLETE")', + 'button:has-text("Complete")', + 'button:has-text("FINISH")', + 'button:has-text("Finish")', + 'button:has-text("SUBMIT")', + 'button:has-text("Submit")', + 'button[type="submit"]', + ] + + for selector in complete_selectors: + try: + button = page.locator(selector).first + if button.is_visible(timeout=2000): + button.click() + page.wait_for_load_state('networkidle') + time.sleep(3) + return True + except: + continue + + print(" Warning: Could not find completion button") + return False + + return True + + +def auto_fill_form(page: Page, field_mapping: Dict[str, str]) -> Dict[str, bool]: + """ + Automatically fill a form based on field mapping. + + Intelligently detects field types and uses appropriate filling strategies. + + Args: + page: Playwright Page object + field_mapping: Dictionary mapping field types to values + + Returns: + Dictionary with results for each field (True = filled, False = failed) + + Example: + ```python + results = auto_fill_form(page, { + 'email': 'test@example.com', + 'password': 'SecurePass123!', + 'full_name': 'Jane Doe', + 'phone': '+447700900123', + 'date_of_birth': '1990-01-15', + }) + print(f"Email filled: {results['email']}") + ``` + """ + filler = SmartFormFiller() + results = {} + + for field_type, value in field_mapping.items(): + if field_type == 'email': + results[field_type] = filler.fill_email_field(page, value) + elif field_type == 'password': + results[field_type] = filler.fill_password_fields(page, value) + elif 'name' in field_type.lower(): + results[field_type] = filler.fill_name_field(page, value) + elif 'phone' in field_type.lower(): + results[field_type] = filler.fill_phone_field(page, value) + elif 'date' in field_type.lower(): + hint = field_type.replace('date_of_', '').replace('_', ' ') + results[field_type] = filler.fill_date_field(page, value, field_hint=hint) + else: + # Try generic fill + try: + field = page.locator(f'input[name="{field_type}"]').first + field.fill(value) + results[field_type] = True + except: + results[field_type] = False + + return results diff --git a/claude-code/skills/webapp-testing/utils/smart_selectors.py b/claude-code/skills/webapp-testing/utils/smart_selectors.py new file mode 100644 index 0000000..eaca263 --- /dev/null +++ b/claude-code/skills/webapp-testing/utils/smart_selectors.py @@ -0,0 +1,306 @@ +""" +Smart Selector Strategies for Robust Web Testing + +Automatically tries multiple selector strategies to find elements, +reducing test brittleness when HTML structure changes. + +Usage: + from utils.smart_selectors import SelectorStrategies + + # Find and fill email field + SelectorStrategies.smart_fill(page, 'email', 'test@example.com') + + # Find and click button + SelectorStrategies.smart_click(page, 'Sign In') +""" + +from typing import Optional, List +from playwright.sync_api import Page, TimeoutError as PlaywrightTimeoutError + + +class SelectorStrategies: + """Multiple strategies for finding common elements""" + + # Reduced timeouts for faster failure (5s per strategy instead of default 30s) + DEFAULT_TIMEOUT = 5000 # 5 seconds per strategy attempt + MAX_TOTAL_TIMEOUT = 10000 # 10 seconds max across all strategies + + @staticmethod + def find_input_field( + page: Page, + field_type: str, + timeout: int = DEFAULT_TIMEOUT, + verbose: bool = True + ) -> Optional[str]: + """ + Find input field using multiple strategies in order of reliability. + + Strategies (in order): + 1. Test IDs: [data-testid*="field_type"] + 2. ARIA labels: input[aria-label*="field_type" i] + 3. Placeholder: input[placeholder*="field_type" i] + 4. Name attribute: input[name*="field_type" i] + 5. Type attribute: input[type="field_type"] + 6. ID attribute: #field_type, input[id*="field_type" i] + + Args: + page: Playwright Page object + field_type: Type of field to find (e.g., 'email', 'password') + timeout: Timeout per strategy in milliseconds (default: 5000) + verbose: Print which strategy succeeded (default: True) + + Returns: + Selector string that worked, or None if not found + + Example: + selector = SelectorStrategies.find_input_field(page, 'email') + if selector: + page.fill(selector, 'test@example.com') + """ + strategies = [ + # Strategy 1: Test IDs (most reliable) + (f'[data-testid*="{field_type}" i]', 'data-testid'), + + # Strategy 2: ARIA labels (accessibility best practice) + (f'input[aria-label*="{field_type}" i]', 'aria-label'), + + # Strategy 3: Placeholder text + (f'input[placeholder*="{field_type}" i]', 'placeholder'), + + # Strategy 4: Name attribute + (f'input[name*="{field_type}" i]', 'name attribute'), + + # Strategy 5: Type attribute (works for email, password, text) + (f'input[type="{field_type}"]', 'type attribute'), + + # Strategy 6: ID attribute (exact match) + (f'#{field_type}', 'id (exact)'), + + # Strategy 7: ID attribute (partial match) + (f'input[id*="{field_type}" i]', 'id (partial)'), + ] + + for selector, strategy_name in strategies: + try: + locator = page.locator(selector).first + if locator.is_visible(timeout=timeout): + if verbose: + print(f"✓ Found field via {strategy_name}: {selector}") + return selector + except PlaywrightTimeoutError: + continue + except Exception: + # Catch other errors (element not found, etc.) + continue + + if verbose: + print(f"✗ Could not find field for '{field_type}' using any strategy") + return None + + @staticmethod + def find_button( + page: Page, + button_text: str, + timeout: int = DEFAULT_TIMEOUT, + verbose: bool = True + ) -> Optional[str]: + """ + Find button by text using multiple strategies. + + Strategies: + 1. Test ID: [data-testid*="button-text"] + 2. Role with name: button[name="button_text"] + 3. Exact text: button:has-text("Button Text") + 4. Partial text (case-insensitive): button:text-matches("button text", "i") + 5. Link as button: a:has-text("Button Text") + 6. Input submit: input[type="submit"][value*="button text" i] + + Args: + page: Playwright Page object + button_text: Text on the button + timeout: Timeout per strategy in milliseconds + verbose: Print which strategy succeeded + + Returns: + Selector string that worked, or None if not found + + Example: + selector = SelectorStrategies.find_button(page, 'Sign In') + if selector: + page.click(selector) + """ + # Normalize button text for test-id matching + test_id = button_text.lower().replace(' ', '-') + + strategies = [ + # Strategy 1: Test IDs + (f'[data-testid*="{test_id}" i]', 'data-testid'), + + # Strategy 2: Button with name attribute + (f'button[name*="{button_text}" i]', 'button name'), + + # Strategy 3: Exact text match + (f'button:has-text("{button_text}")', 'exact text'), + + # Strategy 4: Case-insensitive text match + (f'button:text-matches("{button_text}", "i")', 'case-insensitive text'), + + # Strategy 5: Link styled as button + (f'a:has-text("{button_text}")', 'link (exact text)'), + + # Strategy 6: Link case-insensitive + (f'a:text-matches("{button_text}", "i")', 'link (case-insensitive)'), + + # Strategy 7: Input submit button + (f'input[type="submit"][value*="{button_text}" i]', 'submit input'), + + # Strategy 8: Any clickable element with text + (f'[role="button"]:has-text("{button_text}")', 'role=button'), + ] + + for selector, strategy_name in strategies: + try: + locator = page.locator(selector).first + if locator.is_visible(timeout=timeout): + if verbose: + print(f"✓ Found button via {strategy_name}: {selector}") + return selector + except PlaywrightTimeoutError: + continue + except Exception: + continue + + if verbose: + print(f"✗ Could not find button '{button_text}' using any strategy") + return None + + @staticmethod + def smart_fill( + page: Page, + field_type: str, + value: str, + timeout: int = MAX_TOTAL_TIMEOUT, + verbose: bool = True + ) -> bool: + """ + Find and fill a field automatically using smart selector strategies. + + Args: + page: Playwright Page object + field_type: Type of field (e.g., 'email', 'password', 'username') + value: Value to fill + timeout: Max timeout across all strategies + verbose: Print progress messages + + Returns: + True if successful, False otherwise + + Example: + success = SelectorStrategies.smart_fill(page, 'email', 'test@example.com') + if not success: + print("Failed to fill email field") + """ + selector = SelectorStrategies.find_input_field( + page, field_type, timeout=timeout // 2, verbose=verbose + ) + + if selector: + try: + page.fill(selector, value) + if verbose: + print(f"✓ Filled '{field_type}' with value") + return True + except Exception as e: + if verbose: + print(f"✗ Found field but failed to fill: {e}") + return False + + return False + + @staticmethod + def smart_click( + page: Page, + button_text: str, + timeout: int = MAX_TOTAL_TIMEOUT, + verbose: bool = True + ) -> bool: + """ + Find and click a button automatically using smart selector strategies. + + Args: + page: Playwright Page object + button_text: Text on the button to click + timeout: Max timeout across all strategies + verbose: Print progress messages + + Returns: + True if successful, False otherwise + + Example: + success = SelectorStrategies.smart_click(page, 'Sign In') + if not success: + print("Failed to click Sign In button") + """ + selector = SelectorStrategies.find_button( + page, button_text, timeout=timeout // 2, verbose=verbose + ) + + if selector: + try: + page.click(selector) + if verbose: + print(f"✓ Clicked '{button_text}' button") + return True + except Exception as e: + if verbose: + print(f"✗ Found button but failed to click: {e}") + return False + + return False + + @staticmethod + def find_any_element( + page: Page, + selectors: List[str], + timeout: int = DEFAULT_TIMEOUT, + verbose: bool = True + ) -> Optional[str]: + """ + Try multiple custom selectors and return the first one that works. + + Useful when you have specific selectors to try but want fallback logic. + + Args: + page: Playwright Page object + selectors: List of CSS selectors to try + timeout: Timeout per selector + verbose: Print which selector worked + + Returns: + First selector that found a visible element, or None + + Example: + selectors = [ + 'button#submit', + 'button.submit-btn', + 'input[type="submit"]' + ] + selector = SelectorStrategies.find_any_element(page, selectors) + if selector: + page.click(selector) + """ + for selector in selectors: + try: + locator = page.locator(selector).first + if locator.is_visible(timeout=timeout): + if verbose: + print(f"✓ Found element: {selector}") + return selector + except PlaywrightTimeoutError: + continue + except Exception: + continue + + if verbose: + print(f"✗ Could not find element using any of {len(selectors)} selectors") + return None diff --git a/claude-code/skills/webapp-testing/utils/supabase.py b/claude-code/skills/webapp-testing/utils/supabase.py new file mode 100644 index 0000000..ecceac2 --- /dev/null +++ b/claude-code/skills/webapp-testing/utils/supabase.py @@ -0,0 +1,353 @@ +""" +Supabase Test Utilities + +Generic database helpers for testing with Supabase. +Supports user management, email verification, and test data cleanup. +""" + +import subprocess +import json +from typing import Dict, List, Optional, Any + + +class SupabaseTestClient: + """ + Generic Supabase test client for database operations during testing. + + Example: + ```python + client = SupabaseTestClient( + url="https://project.supabase.co", + service_key="your-service-role-key", + db_password="your-db-password" + ) + + # Create test user + user_id = client.create_user("test@example.com", "password123") + + # Verify email (bypass email sending) + client.confirm_email(user_id) + + # Cleanup after test + client.delete_user(user_id) + ``` + """ + + def __init__(self, url: str, service_key: str, db_password: str = None, db_host: str = None): + """ + Initialize Supabase test client. + + Args: + url: Supabase project URL (e.g., "https://project.supabase.co") + service_key: Service role key for admin operations + db_password: Database password for direct SQL operations + db_host: Database host (if different from default) + """ + self.url = url.rstrip('/') + self.service_key = service_key + self.db_password = db_password + + # Extract DB host from URL if not provided + if not db_host: + # Convert https://abc123.supabase.co to db.abc123.supabase.co + project_ref = url.split('//')[1].split('.')[0] + self.db_host = f"db.{project_ref}.supabase.co" + else: + self.db_host = db_host + + def _run_sql(self, sql: str) -> Dict[str, Any]: + """ + Execute SQL directly against the database. + + Args: + sql: SQL query to execute + + Returns: + Dictionary with 'success', 'output', 'error' keys + """ + if not self.db_password: + return {'success': False, 'error': 'Database password not provided'} + + try: + result = subprocess.run( + [ + 'psql', + '-h', self.db_host, + '-p', '5432', + '-U', 'postgres', + '-c', sql, + '-t', # Tuples only + '-A', # Unaligned output + ], + env={'PGPASSWORD': self.db_password}, + capture_output=True, + text=True, + timeout=10 + ) + + return { + 'success': result.returncode == 0, + 'output': result.stdout.strip(), + 'error': result.stderr.strip() if result.returncode != 0 else None + } + except Exception as e: + return {'success': False, 'error': str(e)} + + def create_user(self, email: str, password: str, metadata: Dict = None) -> Optional[str]: + """ + Create a test user via Auth Admin API. + + Args: + email: User email + password: User password + metadata: Optional user metadata + + Returns: + User ID if successful, None otherwise + + Example: + ```python + user_id = client.create_user( + "test@example.com", + "SecurePass123!", + metadata={"full_name": "Test User"} + ) + ``` + """ + import requests + + payload = { + 'email': email, + 'password': password, + 'email_confirm': True + } + + if metadata: + payload['user_metadata'] = metadata + + try: + response = requests.post( + f"{self.url}/auth/v1/admin/users", + headers={ + 'Authorization': f'Bearer {self.service_key}', + 'apikey': self.service_key, + 'Content-Type': 'application/json' + }, + json=payload, + timeout=10 + ) + + if response.ok: + return response.json().get('id') + else: + print(f"Error creating user: {response.text}") + return None + except Exception as e: + print(f"Exception creating user: {e}") + return None + + def confirm_email(self, user_id: str = None, email: str = None) -> bool: + """ + Confirm user email (bypass email verification for testing). + + Args: + user_id: User ID (if known) + email: User email (alternative to user_id) + + Returns: + True if successful, False otherwise + + Example: + ```python + # By user ID + client.confirm_email(user_id="abc-123") + + # Or by email + client.confirm_email(email="test@example.com") + ``` + """ + if user_id: + sql = f"UPDATE auth.users SET email_confirmed_at = NOW() WHERE id = '{user_id}';" + elif email: + sql = f"UPDATE auth.users SET email_confirmed_at = NOW() WHERE email = '{email}';" + else: + return False + + result = self._run_sql(sql) + return result['success'] + + def delete_user(self, user_id: str = None, email: str = None) -> bool: + """ + Delete a test user and related data. + + Args: + user_id: User ID + email: User email (alternative to user_id) + + Returns: + True if successful, False otherwise + + Example: + ```python + client.delete_user(email="test@example.com") + ``` + """ + # Get user ID if email provided + if email and not user_id: + result = self._run_sql(f"SELECT id FROM auth.users WHERE email = '{email}';") + if result['success'] and result['output']: + user_id = result['output'].strip() + else: + return False + + if not user_id: + return False + + # Delete from profiles first (foreign key) + self._run_sql(f"DELETE FROM public.profiles WHERE id = '{user_id}';") + + # Delete from auth.users + result = self._run_sql(f"DELETE FROM auth.users WHERE id = '{user_id}';") + + return result['success'] + + def cleanup_related_records(self, user_id: str, tables: List[str] = None) -> Dict[str, bool]: + """ + Clean up user-related records from multiple tables. + + Args: + user_id: User ID + tables: List of tables to clean (defaults to common tables) + + Returns: + Dictionary mapping table names to cleanup success status + + Example: + ```python + results = client.cleanup_related_records( + user_id="abc-123", + tables=["profiles", "team_members", "coach_verification_requests"] + ) + ``` + """ + if not tables: + tables = [ + 'pending_profiles', + 'coach_verification_requests', + 'team_members', + 'team_join_requests', + 'profiles' + ] + + results = {} + + for table in tables: + # Try both user_id and id columns + sql = f"DELETE FROM public.{table} WHERE user_id = '{user_id}' OR id = '{user_id}';" + result = self._run_sql(sql) + results[table] = result['success'] + + return results + + def create_invite_code(self, code: str, code_type: str = 'general', max_uses: int = 999) -> bool: + """ + Create an invite code for testing. + + Args: + code: Invite code string + code_type: Type of code (e.g., 'general', 'team_join') + max_uses: Maximum number of uses + + Returns: + True if successful, False otherwise + + Example: + ```python + client.create_invite_code("TEST2024", code_type="general") + ``` + """ + sql = f""" + INSERT INTO public.invite_codes (code, code_type, is_valid, max_uses, expires_at) + VALUES ('{code}', '{code_type}', true, {max_uses}, NOW() + INTERVAL '30 days') + ON CONFLICT (code) DO UPDATE SET is_valid=true, max_uses={max_uses}, use_count=0; + """ + + result = self._run_sql(sql) + return result['success'] + + def find_user_by_email(self, email: str) -> Optional[str]: + """ + Find user ID by email address. + + Args: + email: User email + + Returns: + User ID if found, None otherwise + """ + sql = f"SELECT id FROM auth.users WHERE email = '{email}';" + result = self._run_sql(sql) + + if result['success'] and result['output']: + return result['output'].strip() + return None + + def get_user_privileges(self, user_id: str) -> Optional[List[str]]: + """ + Get user's privilege array. + + Args: + user_id: User ID + + Returns: + List of privileges if found, None otherwise + """ + sql = f"SELECT privileges FROM public.profiles WHERE id = '{user_id}';" + result = self._run_sql(sql) + + if result['success'] and result['output']: + # Parse PostgreSQL array format + privileges_str = result['output'].strip('{}') + return [p.strip() for p in privileges_str.split(',')] + return None + + +def quick_cleanup(email: str, db_password: str, project_url: str) -> bool: + """ + Quick cleanup helper - delete user and all related data. + + Args: + email: User email to delete + db_password: Database password + project_url: Supabase project URL + + Returns: + True if successful, False otherwise + + Example: + ```python + from utils.supabase import quick_cleanup + + # Clean up test user + quick_cleanup( + "test@example.com", + "db_password", + "https://project.supabase.co" + ) + ``` + """ + client = SupabaseTestClient( + url=project_url, + service_key="", # Not needed for SQL operations + db_password=db_password + ) + + user_id = client.find_user_by_email(email) + if not user_id: + return True # Already deleted + + # Clean up all related tables + client.cleanup_related_records(user_id) + + # Delete user + return client.delete_user(user_id) diff --git a/claude-code/skills/webapp-testing/utils/ui_interactions.py b/claude-code/skills/webapp-testing/utils/ui_interactions.py new file mode 100644 index 0000000..e385be7 --- /dev/null +++ b/claude-code/skills/webapp-testing/utils/ui_interactions.py @@ -0,0 +1,454 @@ +""" +UI Interaction Helpers for Web Automation + +Common UI patterns that appear across many web applications: +- Cookie consent banners +- Modal dialogs +- Loading overlays +- Welcome tours/onboarding +- Fixed headers blocking clicks +""" + +from playwright.sync_api import Page +import time + + +def dismiss_cookie_banner(page: Page, timeout: int = 3000) -> bool: + """ + Detect and dismiss cookie consent banners. + + Tries common patterns: + - "Accept" / "Accept All" / "OK" buttons + - "I Agree" / "Got it" buttons + - Cookie banner containers + + Args: + page: Playwright Page object + timeout: Maximum time to wait for banner (milliseconds) + + Returns: + True if banner was found and dismissed, False otherwise + + Example: + ```python + page.goto('https://example.com') + if dismiss_cookie_banner(page): + print("Cookie banner dismissed") + ``` + """ + cookie_button_selectors = [ + 'button:has-text("Accept")', + 'button:has-text("Accept All")', + 'button:has-text("Accept all")', + 'button:has-text("I Agree")', + 'button:has-text("I agree")', + 'button:has-text("OK")', + 'button:has-text("Got it")', + 'button:has-text("Allow")', + 'button:has-text("Allow all")', + '[data-testid="cookie-accept"]', + '[data-testid="accept-cookies"]', + '[id*="cookie-accept" i]', + '[id*="accept-cookie" i]', + '[class*="cookie-accept" i]', + ] + + for selector in cookie_button_selectors: + try: + button = page.locator(selector).first + if button.is_visible(timeout=timeout): + button.click() + time.sleep(0.5) # Brief wait for banner to disappear + return True + except: + continue + + return False + + +def dismiss_modal(page: Page, modal_identifier: str = None, timeout: int = 2000) -> bool: + """ + Close modal dialogs with multiple fallback strategies. + + Strategies: + 1. If identifier provided, close that specific modal + 2. Click close button (X, Close, Cancel, etc.) + 3. Press Escape key + 4. Click backdrop/overlay + + Args: + page: Playwright Page object + modal_identifier: Optional - specific text in modal to identify it + timeout: Maximum time to wait for modal (milliseconds) + + Returns: + True if modal was found and closed, False otherwise + + Example: + ```python + # Close any modal + dismiss_modal(page) + + # Close specific "Welcome" modal + dismiss_modal(page, modal_identifier="Welcome") + ``` + """ + # If specific modal identifier provided, wait for it first + if modal_identifier: + try: + modal = page.locator(f'[role="dialog"]:has-text("{modal_identifier}"), dialog:has-text("{modal_identifier}")').first + if not modal.is_visible(timeout=timeout): + return False + except: + return False + + # Strategy 1: Click close button + close_button_selectors = [ + 'button:has-text("Close")', + 'button:has-text("×")', + 'button:has-text("X")', + 'button:has-text("Cancel")', + 'button:has-text("GOT IT")', + 'button:has-text("Got it")', + 'button:has-text("OK")', + 'button:has-text("Dismiss")', + '[aria-label="Close"]', + '[aria-label="close"]', + '[data-testid="close-modal"]', + '[class*="close" i]', + '[class*="dismiss" i]', + ] + + for selector in close_button_selectors: + try: + button = page.locator(selector).first + if button.is_visible(timeout=500): + button.click() + time.sleep(0.5) + return True + except: + continue + + # Strategy 2: Press Escape key + try: + page.keyboard.press('Escape') + time.sleep(0.5) + # Check if modal is gone + modals = page.locator('[role="dialog"], dialog').all() + if all(not m.is_visible() for m in modals): + return True + except: + pass + + # Strategy 3: Click backdrop (if exists and clickable) + try: + backdrop = page.locator('[class*="backdrop" i], [class*="overlay" i]').first + if backdrop.is_visible(timeout=500): + backdrop.click(position={'x': 10, 'y': 10}) # Click corner, not center + time.sleep(0.5) + return True + except: + pass + + return False + + +def click_with_header_offset(page: Page, selector: str, header_height: int = 80, force: bool = False): + """ + Click an element while accounting for fixed headers that might block it. + + Scrolls the element into view with an offset to avoid fixed headers, + then clicks it. + + Args: + page: Playwright Page object + selector: CSS selector for the element to click + header_height: Height of fixed header in pixels (default 80) + force: Whether to use force click if normal click fails + + Example: + ```python + # Click button that might be behind a fixed header + click_with_header_offset(page, 'button#submit', header_height=100) + ``` + """ + element = page.locator(selector).first + + # Scroll element into view with offset + element.evaluate(f'el => el.scrollIntoView({{ block: "center", inline: "nearest" }})') + page.evaluate(f'window.scrollBy(0, -{header_height})') + time.sleep(0.3) # Brief wait for scroll to complete + + try: + element.click() + except Exception as e: + if force: + element.click(force=True) + else: + raise e + + +def force_click_if_needed(page: Page, selector: str, timeout: int = 5000) -> bool: + """ + Try normal click first, use force click if it fails (e.g., due to overlays). + + Args: + page: Playwright Page object + selector: CSS selector for the element to click + timeout: Maximum time to wait for element (milliseconds) + + Returns: + True if click succeeded (normal or forced), False otherwise + + Example: + ```python + # Try to click, handling potential overlays + if force_click_if_needed(page, 'button#submit'): + print("Button clicked successfully") + ``` + """ + try: + element = page.locator(selector).first + if not element.is_visible(timeout=timeout): + return False + + # Try normal click first + try: + element.click(timeout=timeout) + return True + except: + # Fall back to force click + element.click(force=True) + return True + except: + return False + + +def wait_for_no_overlay(page: Page, max_wait_seconds: int = 10) -> bool: + """ + Wait for loading overlays/spinners to disappear. + + Looks for common loading overlay patterns and waits until they're gone. + + Args: + page: Playwright Page object + max_wait_seconds: Maximum time to wait (seconds) + + Returns: + True if overlays disappeared, False if timeout + + Example: + ```python + page.click('button#submit') + wait_for_no_overlay(page) # Wait for loading to complete + ``` + """ + overlay_selectors = [ + '[class*="loading" i]', + '[class*="spinner" i]', + '[class*="overlay" i]', + '[class*="backdrop" i]', + '[data-loading="true"]', + '[aria-busy="true"]', + '.loader', + '.loading', + '#loading', + ] + + start_time = time.time() + + while time.time() - start_time < max_wait_seconds: + all_hidden = True + + for selector in overlay_selectors: + try: + overlays = page.locator(selector).all() + for overlay in overlays: + if overlay.is_visible(): + all_hidden = False + break + except: + continue + + if not all_hidden: + break + + if all_hidden: + return True + + time.sleep(0.5) + + return False + + +def handle_welcome_tour(page: Page, skip_button_text: str = "Skip") -> bool: + """ + Automatically skip onboarding tours or welcome wizards. + + Looks for and clicks "Skip", "Skip Tour", "Close", "Maybe Later" buttons. + + Args: + page: Playwright Page object + skip_button_text: Text to look for in skip buttons (default "Skip") + + Returns: + True if tour was skipped, False if no tour found + + Example: + ```python + page.goto('https://app.example.com') + handle_welcome_tour(page) # Skip any onboarding tour + ``` + """ + skip_selectors = [ + f'button:has-text("{skip_button_text}")', + 'button:has-text("Skip Tour")', + 'button:has-text("Maybe Later")', + 'button:has-text("No Thanks")', + 'button:has-text("Close Tour")', + '[data-testid="skip-tour"]', + '[data-testid="close-tour"]', + '[aria-label="Skip tour"]', + '[aria-label="Close tour"]', + ] + + for selector in skip_selectors: + try: + button = page.locator(selector).first + if button.is_visible(timeout=2000): + button.click() + time.sleep(0.5) + return True + except: + continue + + return False + + +def wait_for_stable_dom(page: Page, stability_duration_ms: int = 1000, max_wait_seconds: int = 10) -> bool: + """ + Wait for the DOM to stop changing (useful for dynamic content loading). + + Monitors for DOM mutations and waits until no changes occur for the specified duration. + + Args: + page: Playwright Page object + stability_duration_ms: Duration of no changes to consider stable (milliseconds) + max_wait_seconds: Maximum time to wait (seconds) + + Returns: + True if DOM stabilized, False if timeout + + Example: + ```python + page.goto('https://app.example.com') + wait_for_stable_dom(page) # Wait for all dynamic content to load + ``` + """ + # Inject mutation observer script + script = f""" + new Promise((resolve) => {{ + let lastMutation = Date.now(); + const observer = new MutationObserver(() => {{ + lastMutation = Date.now(); + }}); + + observer.observe(document.body, {{ + childList: true, + subtree: true, + attributes: true + }}); + + const checkStability = () => {{ + if (Date.now() - lastMutation >= {stability_duration_ms}) {{ + observer.disconnect(); + resolve(true); + }} else if (Date.now() - lastMutation > {max_wait_seconds * 1000}) {{ + observer.disconnect(); + resolve(false); + }} else {{ + setTimeout(checkStability, 100); + }} + }}; + + setTimeout(checkStability, {stability_duration_ms}); + }}) + """ + + try: + result = page.evaluate(script) + return result + except: + return False + + +def setup_page_with_csp_handling(page): + """ + Set up page with automatic CSP error detection and helpful suggestions. + + Monitors console for CSP violations and suggests fixes. + Call this once per page before navigation. + + Args: + page: Playwright Page object + + Example: + setup_page_with_csp_handling(page) + page.goto('http://localhost:7160') + # If CSP violation occurs: + # Output: ⚠️ CSP Violation detected: [error message] + # 💡 Suggestion: Use BrowserConfig.create_test_context() with auto CSP bypass + + Usage with BrowserConfig: + from utils.browser_config import BrowserConfig + + context = BrowserConfig.create_test_context(browser, 'http://localhost:3000') + page = context.new_page() + setup_page_with_csp_handling(page) + """ + csp_violations = [] + + def handle_console(msg): + """Handler for console messages that detects CSP violations""" + text = msg.text.lower() + + # Detect various CSP-related errors + csp_keywords = [ + 'content security policy', + 'csp', + 'refused to execute inline script', + 'refused to load', + 'blocked by content security policy', + ] + + if any(keyword in text for keyword in csp_keywords): + # Avoid duplicate messages + if msg.text not in csp_violations: + csp_violations.append(msg.text) + print("\n" + "=" * 70) + print("⚠️ CSP VIOLATION DETECTED") + print("=" * 70) + print(f"Message: {msg.text[:200]}") + print("\n💡 SUGGESTION:") + print(" For localhost testing, use:") + print(" ") + print(" from utils.browser_config import BrowserConfig") + print(" context = BrowserConfig.create_test_context(") + print(" browser, 'http://localhost:3000'") + print(" )") + print(" # Auto-enables CSP bypass for localhost") + print("\n Or manually:") + print(" context = browser.new_context(bypass_csp=True)") + print("=" * 70 + "\n") + + # Attach console listener + page.on('console', handle_console) + + # Also monitor for page errors related to CSP + def handle_page_error(error): + """Handler for page errors""" + error_text = str(error).lower() + if 'content security policy' in error_text or 'csp' in error_text: + print(f"\n⚠️ Page Error (CSP-related): {error}\n") + + page.on('pageerror', handle_page_error) diff --git a/claude-code/skills/webapp-testing/utils/wait_strategies.py b/claude-code/skills/webapp-testing/utils/wait_strategies.py new file mode 100644 index 0000000..f92d236 --- /dev/null +++ b/claude-code/skills/webapp-testing/utils/wait_strategies.py @@ -0,0 +1,312 @@ +""" +Advanced Wait Strategies for Reliable Web Automation + +Better alternatives to simple sleep() or networkidle for dynamic web applications. +""" + +from playwright.sync_api import Page +import time +from typing import Callable, Optional, Any + + +def wait_for_api_call(page: Page, url_pattern: str, timeout_seconds: int = 10) -> Optional[Any]: + """ + Wait for a specific API call to complete and return its response. + + Args: + page: Playwright Page object + url_pattern: URL pattern to match (can include wildcards) + timeout_seconds: Maximum time to wait + + Returns: + Response data if call completed, None if timeout + + Example: + ```python + # Wait for user profile API call + response = wait_for_api_call(page, '**/api/profile**') + if response: + print(f"Profile loaded: {response}") + ``` + """ + response_data = {'data': None, 'completed': False} + + def handle_response(response): + if url_pattern.replace('**', '') in response.url: + try: + response_data['data'] = response.json() + response_data['completed'] = True + except: + response_data['completed'] = True + + page.on('response', handle_response) + + start_time = time.time() + while not response_data['completed'] and (time.time() - start_time) < timeout_seconds: + time.sleep(0.1) + + page.remove_listener('response', handle_response) + + return response_data['data'] + + +def wait_for_element_stable(page: Page, selector: str, stability_ms: int = 1000, timeout_seconds: int = 10) -> bool: + """ + Wait for an element's position to stabilize (stop moving/changing). + + Useful for elements that animate or shift due to dynamic content loading. + + Args: + page: Playwright Page object + selector: CSS selector for the element + stability_ms: Duration element must remain stable (milliseconds) + timeout_seconds: Maximum time to wait + + Returns: + True if element stabilized, False if timeout + + Example: + ```python + # Wait for dropdown menu to finish animating + wait_for_element_stable(page, '.dropdown-menu', stability_ms=500) + ``` + """ + try: + element = page.locator(selector).first + + script = f""" + (element, stabilityMs) => {{ + return new Promise((resolve) => {{ + let lastRect = element.getBoundingClientRect(); + let lastChange = Date.now(); + + const checkStability = () => {{ + const currentRect = element.getBoundingClientRect(); + + if (currentRect.top !== lastRect.top || + currentRect.left !== lastRect.left || + currentRect.width !== lastRect.width || + currentRect.height !== lastRect.height) {{ + lastChange = Date.now(); + lastRect = currentRect; + }} + + if (Date.now() - lastChange >= stabilityMs) {{ + resolve(true); + }} else if (Date.now() - lastChange < {timeout_seconds * 1000}) {{ + setTimeout(checkStability, 50); + }} else {{ + resolve(false); + }} + }}; + + setTimeout(checkStability, stabilityMs); + }}); + }} + """ + + result = element.evaluate(script, stability_ms) + return result + except: + return False + + +def wait_with_retry(page: Page, condition_fn: Callable[[], bool], max_retries: int = 5, backoff_seconds: float = 0.5) -> bool: + """ + Wait for a condition with exponential backoff retry. + + Args: + page: Playwright Page object + condition_fn: Function that returns True when condition is met + max_retries: Maximum number of retry attempts + backoff_seconds: Initial backoff duration (doubles each retry) + + Returns: + True if condition met, False if all retries exhausted + + Example: + ```python + # Wait for specific element to appear with retry + def check_dashboard(): + return page.locator('#dashboard').is_visible() + + if wait_with_retry(page, check_dashboard): + print("Dashboard loaded!") + ``` + """ + wait_time = backoff_seconds + + for attempt in range(max_retries): + try: + if condition_fn(): + return True + except: + pass + + if attempt < max_retries - 1: + time.sleep(wait_time) + wait_time *= 2 # Exponential backoff + + return False + + +def smart_navigation_wait(page: Page, expected_url_pattern: str = None, timeout_seconds: int = 10) -> bool: + """ + Comprehensive wait strategy after navigation/interaction. + + Combines multiple strategies: + 1. Network idle + 2. DOM stability + 3. URL pattern match (if provided) + + Args: + page: Playwright Page object + expected_url_pattern: Optional URL pattern to wait for + timeout_seconds: Maximum time to wait + + Returns: + True if all conditions met, False if timeout + + Example: + ```python + page.click('button#login') + smart_navigation_wait(page, expected_url_pattern='**/dashboard**') + ``` + """ + start_time = time.time() + + # Step 1: Wait for network idle + try: + page.wait_for_load_state('networkidle', timeout=timeout_seconds * 1000) + except: + pass + + # Step 2: Check URL if pattern provided + if expected_url_pattern: + while (time.time() - start_time) < timeout_seconds: + current_url = page.url + pattern = expected_url_pattern.replace('**', '') + if pattern in current_url: + break + time.sleep(0.5) + else: + return False + + # Step 3: Brief wait for DOM stability + time.sleep(1) + + return True + + +def wait_for_data_load(page: Page, data_attribute: str = 'data-loaded', timeout_seconds: int = 10) -> bool: + """ + Wait for data-loading attribute to indicate completion. + + Args: + page: Playwright Page object + data_attribute: Data attribute to check (e.g., 'data-loaded') + timeout_seconds: Maximum time to wait + + Returns: + True if data loaded, False if timeout + + Example: + ```python + # Wait for element with data-loaded="true" + wait_for_data_load(page, data_attribute='data-loaded') + ``` + """ + start_time = time.time() + + while (time.time() - start_time) < timeout_seconds: + try: + elements = page.locator(f'[{data_attribute}="true"]').all() + if elements: + return True + except: + pass + + time.sleep(0.3) + + return False + + +def wait_until_no_element(page: Page, selector: str, timeout_seconds: int = 10) -> bool: + """ + Wait until an element is no longer visible (e.g., loading spinner disappears). + + Args: + page: Playwright Page object + selector: CSS selector for the element + timeout_seconds: Maximum time to wait + + Returns: + True if element disappeared, False if still visible after timeout + + Example: + ```python + # Wait for loading spinner to disappear + wait_until_no_element(page, '.loading-spinner') + ``` + """ + start_time = time.time() + + while (time.time() - start_time) < timeout_seconds: + try: + element = page.locator(selector).first + if not element.is_visible(timeout=500): + return True + except: + return True # Element not found = disappeared + + time.sleep(0.3) + + return False + + +def combined_wait(page: Page, timeout_seconds: int = 10) -> bool: + """ + Comprehensive wait combining multiple strategies for maximum reliability. + + Uses: + 1. Network idle + 2. No visible loading indicators + 3. DOM stability + 4. Brief settling time + + Args: + page: Playwright Page object + timeout_seconds: Maximum time to wait + + Returns: + True if all conditions met, False if timeout + + Example: + ```python + page.click('button#submit') + combined_wait(page) # Wait for everything to settle + ``` + """ + start_time = time.time() + + # Network idle + try: + page.wait_for_load_state('networkidle', timeout=timeout_seconds * 1000) + except: + pass + + # Wait for common loading indicators to disappear + loading_selectors = [ + '.loading', + '.spinner', + '[data-loading="true"]', + '[aria-busy="true"]', + ] + + for selector in loading_selectors: + wait_until_no_element(page, selector, timeout_seconds=3) + + # Final settling time + time.sleep(1) + + return (time.time() - start_time) < timeout_seconds diff --git a/claude-code/utils/git-worktree-utils.sh b/claude-code/utils/git-worktree-utils.sh new file mode 100755 index 0000000..3e9b9f8 --- /dev/null +++ b/claude-code/utils/git-worktree-utils.sh @@ -0,0 +1,111 @@ +#!/bin/bash + +# ABOUTME: Git worktree utilities for agent workspace isolation + +set -euo pipefail + +# Create agent worktree with isolated branch +create_agent_worktree() { + local AGENT_ID=$1 + local BASE_BRANCH=${2:-$(git branch --show-current)} + local TASK_SLUG=${3:-""} + + # Build directory name with optional task slug + if [ -n "$TASK_SLUG" ]; then + local WORKTREE_DIR="worktrees/agent-${AGENT_ID}-${TASK_SLUG}" + else + local WORKTREE_DIR="worktrees/agent-${AGENT_ID}" + fi + + local BRANCH_NAME="agent/agent-${AGENT_ID}" + + # Create worktrees directory if needed + mkdir -p worktrees + + # Create worktree with new branch (redirect git output to stderr) + git worktree add -b "$BRANCH_NAME" "$WORKTREE_DIR" "$BASE_BRANCH" >&2 + + # Echo only the directory path to stdout + echo "$WORKTREE_DIR" +} + +# Remove agent worktree +cleanup_agent_worktree() { + local AGENT_ID=$1 + local FORCE=${2:-false} + + # Find worktree directory (may have task slug suffix) + local WORKTREE_DIR=$(find worktrees -type d -name "agent-${AGENT_ID}*" 2>/dev/null | head -1) + local BRANCH_NAME="agent/agent-${AGENT_ID}" + + if [ -z "$WORKTREE_DIR" ] || [ ! -d "$WORKTREE_DIR" ]; then + echo "❌ Worktree not found for agent: $AGENT_ID" + return 1 + fi + + # Check for uncommitted changes + if ! git -C "$WORKTREE_DIR" diff --quiet 2>/dev/null; then + if [ "$FORCE" = false ]; then + echo "⚠️ Worktree has uncommitted changes. Use --force to remove anyway." + return 1 + fi + fi + + # Remove worktree + git worktree remove "$WORKTREE_DIR" $( [ "$FORCE" = true ] && echo "--force" ) + + # Delete branch (only if merged or forced) + git branch -d "$BRANCH_NAME" 2>/dev/null || \ + ( [ "$FORCE" = true ] && git branch -D "$BRANCH_NAME" ) +} + +# List all agent worktrees +list_agent_worktrees() { + git worktree list | grep "worktrees/agent-" || echo "No agent worktrees found" +} + +# Merge agent work into current branch +merge_agent_work() { + local AGENT_ID=$1 + local BRANCH_NAME="agent/agent-${AGENT_ID}" + + if ! git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then + echo "❌ Branch not found: $BRANCH_NAME" + return 1 + fi + + git merge "$BRANCH_NAME" +} + +# Check if worktree exists +worktree_exists() { + local AGENT_ID=$1 + local WORKTREE_DIR=$(find worktrees -type d -name "agent-${AGENT_ID}*" 2>/dev/null | head -1) + + [ -n "$WORKTREE_DIR" ] && [ -d "$WORKTREE_DIR" ] +} + +# Main CLI (only run if executed directly, not sourced) +if [ "${BASH_SOURCE[0]:-}" = "${0:-}" ]; then + case "${1:-help}" in + create) + create_agent_worktree "$2" "${3:-}" "${4:-}" + ;; + cleanup) + cleanup_agent_worktree "$2" "${3:-false}" + ;; + list) + list_agent_worktrees + ;; + merge) + merge_agent_work "$2" + ;; + exists) + worktree_exists "$2" + ;; + *) + echo "Usage: git-worktree-utils.sh {create|cleanup|list|merge|exists} [args]" + exit 1 + ;; + esac +fi diff --git a/claude-code/utils/orchestrator-agent.sh b/claude-code/utils/orchestrator-agent.sh new file mode 100755 index 0000000..4724dac --- /dev/null +++ b/claude-code/utils/orchestrator-agent.sh @@ -0,0 +1,120 @@ +#!/bin/bash + +# Agent Lifecycle Management Utility +# Handles agent spawning, status detection, and termination + +set -euo pipefail + +# Source the spawn-agent logic +SPAWN_AGENT_CMD="${HOME}/.claude/commands/spawn-agent.md" + +# detect_agent_status +# Detects agent status from tmux output +detect_agent_status() { + local tmux_session="$1" + + if ! tmux has-session -t "$tmux_session" 2>/dev/null; then + echo "killed" + return 0 + fi + + local output=$(tmux capture-pane -t "$tmux_session" -p -S -100 2>/dev/null || echo "") + + # Check for completion indicators + if echo "$output" | grep -qiE "complete|done|finished|✅.*complete"; then + if echo "$output" | grep -qE "git.*commit|Commit.*created"; then + echo "complete" + return 0 + fi + fi + + # Check for failure indicators + if echo "$output" | grep -qiE "error|failed|❌|fatal"; then + echo "failed" + return 0 + fi + + # Check for idle (no recent activity) + local last_line=$(echo "$output" | tail -1) + if echo "$last_line" | grep -qE "^>|^│|^─|Style:|bypass permissions"; then + echo "idle" + return 0 + fi + + # Active by default + echo "active" +} + +# check_idle_timeout +# Checks if agent has been idle too long +check_idle_timeout() { + local session_id="$1" + local agent_id="$2" + local timeout_minutes="$3" + + # Get agent's last_updated timestamp + local last_updated=$(~/.claude/utils/orchestrator-state.sh get-agent "$session_id" "$agent_id" | jq -r '.last_updated // empty') + + if [ -z "$last_updated" ]; then + echo "false" + return 0 + fi + + local now=$(date +%s) + local last=$(date -j -f "%Y-%m-%dT%H:%M:%S" "${last_updated:0:19}" +%s 2>/dev/null || echo "$now") + local diff=$(( (now - last) / 60 )) + + if [ "$diff" -gt "$timeout_minutes" ]; then + echo "true" + else + echo "false" + fi +} + +# kill_agent +# Kills an agent tmux session +kill_agent() { + local tmux_session="$1" + + if tmux has-session -t "$tmux_session" 2>/dev/null; then + tmux kill-session -t "$tmux_session" + echo "Killed agent session: $tmux_session" + fi +} + +# extract_cost_from_tmux +# Extracts cost from Claude status bar in tmux +extract_cost_from_tmux() { + local tmux_session="$1" + + local output=$(tmux capture-pane -t "$tmux_session" -p -S -50 2>/dev/null || echo "") + + # Look for "Cost: $X.XX" pattern + local cost=$(echo "$output" | grep -oE 'Cost:\s*\$[0-9]+\.[0-9]{2}' | tail -1 | grep -oE '[0-9]+\.[0-9]{2}') + + echo "${cost:-0.00}" +} + +case "${1:-}" in + detect-status) + detect_agent_status "$2" + ;; + check-idle) + check_idle_timeout "$2" "$3" "$4" + ;; + kill) + kill_agent "$2" + ;; + extract-cost) + extract_cost_from_tmux "$2" + ;; + *) + echo "Usage: orchestrator-agent.sh [args...]" + echo "Commands:" + echo " detect-status " + echo " check-idle " + echo " kill " + echo " extract-cost " + exit 1 + ;; +esac diff --git a/claude-code/utils/orchestrator-dag.sh b/claude-code/utils/orchestrator-dag.sh new file mode 100755 index 0000000..b0b9c03 --- /dev/null +++ b/claude-code/utils/orchestrator-dag.sh @@ -0,0 +1,125 @@ +#!/bin/bash + +# DAG (Directed Acyclic Graph) Utility +# Handles dependency resolution and wave calculation + +set -euo pipefail + +STATE_DIR="${HOME}/.claude/orchestration/state" + +# topological_sort +# Returns nodes in topological order (waves) +topological_sort() { + local dag_file="$1" + + # Extract nodes and edges + local nodes=$(jq -r '.nodes | keys[]' "$dag_file") + local edges=$(jq -r '.edges' "$dag_file") + + # Calculate in-degree for each node + declare -A indegree + for node in $nodes; do + local deps=$(jq -r --arg n "$node" '.edges[] | select(.to == $n) | .from' "$dag_file" | wc -l) + indegree[$node]=$deps + done + + # Topological sort using Kahn's algorithm + local wave=1 + local result="" + + while [ ${#indegree[@]} -gt 0 ]; do + local wave_nodes="" + + # Find all nodes with indegree 0 + for node in "${!indegree[@]}"; do + if [ "${indegree[$node]}" -eq 0 ]; then + wave_nodes="$wave_nodes $node" + fi + done + + if [ -z "$wave_nodes" ]; then + echo "Error: Cycle detected in DAG" >&2 + return 1 + fi + + # Output wave + echo "$wave:$wave_nodes" + + # Remove processed nodes and update indegrees + for node in $wave_nodes; do + unset indegree[$node] + + # Decrease indegree for dependent nodes + local dependents=$(jq -r --arg n "$node" '.edges[] | select(.from == $n) | .to' "$dag_file") + for dep in $dependents; do + if [ -n "${indegree[$dep]:-}" ]; then + indegree[$dep]=$((indegree[$dep] - 1)) + fi + done + done + + ((wave++)) + done +} + +# check_dependencies +# Checks if all dependencies for a node are satisfied +check_dependencies() { + local dag_file="$1" + local node_id="$2" + + local deps=$(jq -r --arg n "$node_id" '.edges[] | select(.to == $n) | .from' "$dag_file") + + if [ -z "$deps" ]; then + echo "true" + return 0 + fi + + # Check if all dependencies are complete + for dep in $deps; do + local status=$(jq -r --arg n "$dep" '.nodes[$n].status' "$dag_file") + if [ "$status" != "complete" ]; then + echo "false" + return 1 + fi + done + + echo "true" +} + +# get_next_wave +# Gets the next wave of nodes ready to execute +get_next_wave() { + local dag_file="$1" + + local nodes=$(jq -r '.nodes | to_entries[] | select(.value.status == "pending") | .key' "$dag_file") + + local wave_nodes="" + for node in $nodes; do + if [ "$(check_dependencies "$dag_file" "$node")" = "true" ]; then + wave_nodes="$wave_nodes $node" + fi + done + + echo "$wave_nodes" | tr -s ' ' +} + +case "${1:-}" in + topo-sort) + topological_sort "$2" + ;; + check-deps) + check_dependencies "$2" "$3" + ;; + next-wave) + get_next_wave "$2" + ;; + *) + echo "Usage: orchestrator-dag.sh [args...]" + echo "Commands:" + echo " topo-sort " + echo " check-deps " + echo " next-wave " + exit 1 + ;; +esac diff --git a/claude-code/utils/orchestrator-state.sh b/claude-code/utils/orchestrator-state.sh new file mode 100755 index 0000000..40b9a57 --- /dev/null +++ b/claude-code/utils/orchestrator-state.sh @@ -0,0 +1,431 @@ +#!/bin/bash + +# Orchestrator State Management Utility +# Manages sessions.json, completed.json, and DAG state files + +set -euo pipefail + +# Paths +STATE_DIR="${HOME}/.claude/orchestration/state" +SESSIONS_FILE="${STATE_DIR}/sessions.json" +COMPLETED_FILE="${STATE_DIR}/completed.json" +CONFIG_FILE="${STATE_DIR}/config.json" + +# Ensure jq is available +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed. Install with: brew install jq" + exit 1 +fi + +# ============================================================================ +# Session Management Functions +# ============================================================================ + +# create_session [config_json] +# Creates a new orchestration session +create_session() { + local session_id="$1" + local tmux_session="$2" + local custom_config="${3:-{}}" + + # Load default config + local default_config=$(jq -r '.orchestrator' "$CONFIG_FILE") + + # Merge custom config with defaults + local merged_config=$(echo "$default_config" | jq ". + $custom_config") + + # Create session object + local session=$(cat < "$SESSIONS_FILE" + + echo "$session_id" +} + +# get_session +# Retrieves a session by ID +get_session() { + local session_id="$1" + jq -r ".active_sessions[] | select(.session_id == \"$session_id\")" "$SESSIONS_FILE" +} + +# update_session +# Updates a session with new data (merges) +update_session() { + local session_id="$1" + local update="$2" + + local updated=$(jq \ + --arg id "$session_id" \ + --argjson upd "$update" \ + '(.active_sessions[] | select(.session_id == $id)) |= (. + $upd) | .last_updated = "'$(date -Iseconds)'"' \ + "$SESSIONS_FILE") + + echo "$updated" > "$SESSIONS_FILE" +} + +# update_session_status +# Updates session status +update_session_status() { + local session_id="$1" + local status="$2" + + update_session "$session_id" "{\"status\": \"$status\"}" +} + +# archive_session +# Moves session from active to completed +archive_session() { + local session_id="$1" + + # Get session data + local session=$(get_session "$session_id") + + if [ -z "$session" ]; then + echo "Error: Session $session_id not found" + return 1 + fi + + # Mark as complete with end time + local completed_session=$(echo "$session" | jq ". + {\"completed_at\": \"$(date -Iseconds)\"}") + + # Add to completed sessions + local updated_completed=$(jq ".completed_sessions += [$completed_session] | .last_updated = \"$(date -Iseconds)\"" "$COMPLETED_FILE") + echo "$updated_completed" > "$COMPLETED_FILE" + + # Update totals + local total_cost=$(echo "$completed_session" | jq -r '.total_cost_usd') + local updated_totals=$(jq \ + --arg cost "$total_cost" \ + '.total_cost_usd += ($cost | tonumber) | .total_agents_spawned += 1' \ + "$COMPLETED_FILE") + echo "$updated_totals" > "$COMPLETED_FILE" + + # Remove from active sessions + local updated_active=$(jq \ + --arg id "$session_id" \ + '.active_sessions = [.active_sessions[] | select(.session_id != $id)] | .last_updated = "'$(date -Iseconds)'"' \ + "$SESSIONS_FILE") + echo "$updated_active" > "$SESSIONS_FILE" + + echo "Session $session_id archived" +} + +# list_active_sessions +# Lists all active sessions +list_active_sessions() { + jq -r '.active_sessions[] | .session_id' "$SESSIONS_FILE" +} + +# ============================================================================ +# Agent Management Functions +# ============================================================================ + +# add_agent +# Adds an agent to a session +add_agent() { + local session_id="$1" + local agent_id="$2" + local agent_config="$3" + + local updated=$(jq \ + --arg sid "$session_id" \ + --arg aid "$agent_id" \ + --argjson cfg "$agent_config" \ + '(.active_sessions[] | select(.session_id == $sid).agents[$aid]) = $cfg | .last_updated = "'$(date -Iseconds)'"' \ + "$SESSIONS_FILE") + + echo "$updated" > "$SESSIONS_FILE" +} + +# update_agent_status +# Updates an agent's status +update_agent_status() { + local session_id="$1" + local agent_id="$2" + local status="$3" + + local updated=$(jq \ + --arg sid "$session_id" \ + --arg aid "$agent_id" \ + --arg st "$status" \ + '(.active_sessions[] | select(.session_id == $sid).agents[$aid].status) = $st | + (.active_sessions[] | select(.session_id == $sid).agents[$aid].last_updated) = "'$(date -Iseconds)'" | + .last_updated = "'$(date -Iseconds)'"' \ + "$SESSIONS_FILE") + + echo "$updated" > "$SESSIONS_FILE" +} + +# update_agent_cost +# Updates an agent's cost +update_agent_cost() { + local session_id="$1" + local agent_id="$2" + local cost_usd="$3" + + local updated=$(jq \ + --arg sid "$session_id" \ + --arg aid "$agent_id" \ + --arg cost "$cost_usd" \ + '(.active_sessions[] | select(.session_id == $sid).agents[$aid].cost_usd) = ($cost | tonumber) | + .last_updated = "'$(date -Iseconds)'"' \ + "$SESSIONS_FILE") + + echo "$updated" > "$SESSIONS_FILE" + + # Update session total cost + update_session_total_cost "$session_id" +} + +# update_session_total_cost +# Recalculates and updates session total cost +update_session_total_cost() { + local session_id="$1" + + local total=$(jq -r \ + --arg sid "$session_id" \ + '(.active_sessions[] | select(.session_id == $sid).agents | to_entries | map(.value.cost_usd // 0) | add) // 0' \ + "$SESSIONS_FILE") + + update_session "$session_id" "{\"total_cost_usd\": $total}" +} + +# get_agent +# Gets agent data +get_agent() { + local session_id="$1" + local agent_id="$2" + + jq -r \ + --arg sid "$session_id" \ + --arg aid "$agent_id" \ + '.active_sessions[] | select(.session_id == $sid).agents[$aid]' \ + "$SESSIONS_FILE" +} + +# list_agents +# Lists all agents in a session +list_agents() { + local session_id="$1" + + jq -r \ + --arg sid "$session_id" \ + '.active_sessions[] | select(.session_id == $sid).agents | keys[]' \ + "$SESSIONS_FILE" +} + +# ============================================================================ +# Wave Management Functions +# ============================================================================ + +# add_wave +# Adds a wave to the session +add_wave() { + local session_id="$1" + local wave_number="$2" + local agent_ids="$3" # JSON array like '["agent-1", "agent-2"]' + + local wave=$(cat < "$SESSIONS_FILE" +} + +# update_wave_status +# Updates wave status +update_wave_status() { + local session_id="$1" + local wave_number="$2" + local status="$3" + + local timestamp_field="" + if [ "$status" = "active" ]; then + timestamp_field="started_at" + elif [ "$status" = "complete" ] || [ "$status" = "failed" ]; then + timestamp_field="completed_at" + fi + + local jq_filter='(.active_sessions[] | select(.session_id == $sid).waves[] | select(.wave_number == ($wn | tonumber)).status) = $st' + + if [ -n "$timestamp_field" ]; then + jq_filter="$jq_filter | (.active_sessions[] | select(.session_id == \$sid).waves[] | select(.wave_number == (\$wn | tonumber)).$timestamp_field) = \"$(date -Iseconds)\"" + fi + + jq_filter="$jq_filter | .last_updated = \"$(date -Iseconds)\"" + + local updated=$(jq \ + --arg sid "$session_id" \ + --arg wn "$wave_number" \ + --arg st "$status" \ + "$jq_filter" \ + "$SESSIONS_FILE") + + echo "$updated" > "$SESSIONS_FILE" +} + +# get_current_wave +# Gets the current active or next pending wave number +get_current_wave() { + local session_id="$1" + + # First check for active waves + local active_wave=$(jq -r \ + --arg sid "$session_id" \ + '.active_sessions[] | select(.session_id == $sid).waves[] | select(.status == "active") | .wave_number' \ + "$SESSIONS_FILE" | head -1) + + if [ -n "$active_wave" ]; then + echo "$active_wave" + return + fi + + # Otherwise get first pending wave + local pending_wave=$(jq -r \ + --arg sid "$session_id" \ + '.active_sessions[] | select(.session_id == $sid).waves[] | select(.status == "pending") | .wave_number' \ + "$SESSIONS_FILE" | head -1) + + echo "${pending_wave:-0}" +} + +# ============================================================================ +# Utility Functions +# ============================================================================ + +# check_budget_limit +# Checks if session is within budget limits +check_budget_limit() { + local session_id="$1" + + local max_budget=$(jq -r '.resource_limits.max_budget_usd' "$CONFIG_FILE") + local warn_percent=$(jq -r '.resource_limits.warn_at_percent' "$CONFIG_FILE") + local stop_percent=$(jq -r '.resource_limits.hard_stop_at_percent' "$CONFIG_FILE") + + local current_cost=$(jq -r \ + --arg sid "$session_id" \ + '.active_sessions[] | select(.session_id == $sid).total_cost_usd' \ + "$SESSIONS_FILE") + + local percent=$(echo "scale=2; ($current_cost / $max_budget) * 100" | bc) + + if (( $(echo "$percent >= $stop_percent" | bc -l) )); then + echo "STOP" + return 1 + elif (( $(echo "$percent >= $warn_percent" | bc -l) )); then + echo "WARN" + return 0 + else + echo "OK" + return 0 + fi +} + +# pretty_print_session +# Pretty prints a session +pretty_print_session() { + local session_id="$1" + get_session "$session_id" | jq '.' +} + +# ============================================================================ +# Main CLI Interface +# ============================================================================ + +case "${1:-}" in + create) + create_session "$2" "$3" "${4:-{}}" + ;; + get) + get_session "$2" + ;; + update) + update_session "$2" "$3" + ;; + archive) + archive_session "$2" + ;; + list) + list_active_sessions + ;; + add-agent) + add_agent "$2" "$3" "$4" + ;; + update-agent-status) + update_agent_status "$2" "$3" "$4" + ;; + update-agent-cost) + update_agent_cost "$2" "$3" "$4" + ;; + get-agent) + get_agent "$2" "$3" + ;; + list-agents) + list_agents "$2" + ;; + add-wave) + add_wave "$2" "$3" "$4" + ;; + update-wave-status) + update_wave_status "$2" "$3" "$4" + ;; + get-current-wave) + get_current_wave "$2" + ;; + check-budget) + check_budget_limit "$2" + ;; + print) + pretty_print_session "$2" + ;; + *) + echo "Usage: orchestrator-state.sh [args...]" + echo "" + echo "Commands:" + echo " create [config_json]" + echo " get " + echo " update " + echo " archive " + echo " list" + echo " add-agent " + echo " update-agent-status " + echo " update-agent-cost " + echo " get-agent " + echo " list-agents " + echo " add-wave " + echo " update-wave-status " + echo " get-current-wave " + echo " check-budget " + echo " print " + exit 1 + ;; +esac diff --git a/create-rule.js b/create-rule.js index 0c91a7e..9d90882 100755 --- a/create-rule.js +++ b/create-rule.js @@ -169,31 +169,46 @@ function getEffectiveExcludeFiles(tool, config) { function copyDirectoryRecursive(source, destination, excludeFiles = [], templateSubstitutions = {}) { const files = []; - + + function shouldSkipItem(item, relativePath, isDirectory) { + // Skip node_modules and logs directories + if (isDirectory && (item === 'node_modules' || item === 'logs')) { + return true; + } + + // Skip build artifacts and lock files + if (!isDirectory) { + // Skip Bun build temp files + if (item.startsWith('.') && item.includes('.bun-build')) { + return true; + } + // Skip lock files in bin directories + if (relativePath.includes('/bin/') && (item === 'bun.lockb' || item === 'bun.lock')) { + return true; + } + } + + // Check explicit excludes + return excludeFiles.some(excludeFile => + relativePath === excludeFile || item === excludeFile + ); + } + function getAllFiles(dir, basePath = '') { const items = readdirSync(dir); for (const item of items) { const fullPath = path.join(dir, item); const relativePath = path.join(basePath, item); - - if (statSync(fullPath).isDirectory()) { - // Check if this directory should be excluded - const shouldExcludeDir = excludeFiles.some(excludeFile => - relativePath === excludeFile || item === excludeFile - ); - - if (!shouldExcludeDir) { - getAllFiles(fullPath, relativePath); - } + const isDirectory = statSync(fullPath).isDirectory(); + + if (shouldSkipItem(item, relativePath, isDirectory)) { + continue; + } + + if (isDirectory) { + getAllFiles(fullPath, relativePath); } else { - // Check if this file should be excluded - const shouldExclude = excludeFiles.some(excludeFile => - relativePath === excludeFile || item === excludeFile - ); - - if (!shouldExclude) { - files.push({ source: fullPath, dest: path.join(destination, relativePath), fileName: item, relativePath: relativePath }); - } + files.push({ source: fullPath, dest: path.join(destination, relativePath), fileName: item, relativePath: relativePath }); } } } @@ -410,6 +425,81 @@ async function handleFullDirectoryCopy(tool, config, overrideHomeDir = null, tar const skillsFiles = copyDirectoryRecursive(skillsSource, skillsDest, [], config.templateSubstitutions || {}); completeProgress(`Copied ${skillsFiles} skill files`); } + + // Smart compilation for browser-tools in webapp-testing skill + const browserToolsDir = path.join(skillsDest, 'webapp-testing', 'bin'); + const browserToolsTs = path.join(browserToolsDir, 'browser-tools.ts'); + const browserToolsBinary = path.join(browserToolsDir, 'browser-tools'); + const rebuildFlag = process.argv.includes('--rebuild'); + + if (fs.existsSync(browserToolsTs)) { + // For claude-code-4.5: compile the binary + if (tool === 'claude-code-4.5') { + let shouldCompile = rebuildFlag; + + if (!fs.existsSync(browserToolsBinary)) { + shouldCompile = true; + showProgress('browser-tools binary missing, compiling'); + } else if (!rebuildFlag) { + const tsStats = statSync(browserToolsTs); + const binaryStats = statSync(browserToolsBinary); + if (tsStats.mtime > binaryStats.mtime) { + shouldCompile = true; + showProgress('browser-tools.ts is newer, recompiling'); + } + } + + if (shouldCompile) { + try { + showProgress('Compiling browser-tools binary'); + const { execSync } = await import('child_process'); + + // Try Bun first + try { + execSync('which bun', { stdio: 'pipe' }); + execSync( + `cd "${browserToolsDir}" && bun install && bun build browser-tools.ts --compile --target bun --outfile browser-tools`, + { stdio: 'pipe' } + ); + completeProgress('Compiled browser-tools with Bun'); + } catch { + // Fallback to esbuild + try { + execSync('which esbuild', { stdio: 'pipe' }); + execSync( + `cd "${browserToolsDir}" && npm install && esbuild browser-tools.ts --bundle --platform=node --outfile=browser-tools.js`, + { stdio: 'pipe' } + ); + completeProgress('Compiled browser-tools with esbuild (JavaScript output)'); + } catch { + completeProgress('Compilation failed, using existing binary if available'); + } + } + } catch (error) { + completeProgress(`Compilation error: ${error.message}, using existing binary if available`); + } + } + } + // For claude-code: copy binary from claude-code-4.5 source + else if (tool === 'claude-code') { + showProgress('Copying browser-tools binary from claude-code-4.5'); + const sourceBinary = path.join(__dirname, 'claude-code-4.5', 'skills', 'webapp-testing', 'bin', 'browser-tools'); + + // Remove symlink if it exists (from git source) + if (fs.existsSync(browserToolsBinary)) { + fs.unlinkSync(browserToolsBinary); + } + + // Copy binary from claude-code-4.5 source + if (fs.existsSync(sourceBinary)) { + fs.copyFileSync(sourceBinary, browserToolsBinary); + fs.chmodSync(browserToolsBinary, 0o755); // Make executable + completeProgress('Copied browser-tools binary from claude-code-4.5'); + } else { + completeProgress('Warning: browser-tools binary not found in claude-code-4.5'); + } + } + } } // Copy tool-specific files if they exist