diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000..1bcad33 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,125 @@ +# Testing + +This project uses [Bats](https://github.com/bats-core/bats-core) (Bash Automated Testing System) for testing the shell scripts. + +## Running Tests + +```bash +# Run all tests +bats tests/ + +# Run specific test file +bats tests/install.bats +bats tests/sync.bats +bats tests/validation.bats + +# Run with verbose output (show test names) +bats --tap tests/ +``` + +## Test Structure + +``` +tests/ +├── test_helper.bash # Shared setup/teardown and utilities +├── install.bats # Tests for install.sh +├── sync.bats # Tests for sync.sh commands +└── validation.bats # Tests for skills validation +``` + +## Writing Tests + +### Test File Format + +```bash +#!/usr/bin/env bats + +load 'test_helper' + +setup() { + setup_test_env +} + +teardown() { + teardown_test_env +} + +@test "description of what this tests" { + # Arrange + create_fake_skill "my-skill" + + # Act + run_install + + # Assert + assert_symlink "$FAKE_HOME/.claude/skills/my-skill" "$FAKE_REPO/skills/my-skill" +} +``` + +### Key Conventions + +1. **Always use the test environment** - Call `setup_test_env` in setup and `teardown_test_env` in teardown +2. **Use helper functions** - Use `run_install`, `run_sync`, `create_fake_skill`, etc. from test_helper.bash +3. **Test in isolation** - Tests use temp directories (`$FAKE_HOME`, `$FAKE_REPO`) and never touch real config + +### Available Test Helpers + +**Environment:** +- `setup_test_env` - Creates isolated temp directories +- `teardown_test_env` - Cleans up temp directories +- `$FAKE_HOME` - Temp directory simulating user's home +- `$FAKE_REPO` - Temp directory simulating the repo + +**Creating Test Data:** +- `create_fake_skill "name"` - Creates a valid skill with SKILL.md +- `create_invalid_skill "name"` - Creates skill without frontmatter +- `create_skill_no_md "name"` - Creates skill without SKILL.md +- `create_fake_agent "name"` - Creates an agent file +- `create_fake_rule "name"` - Creates a rule file +- `create_fake_settings` - Creates settings.json +- `create_fake_statusline` - Creates statusline.sh + +**Running Scripts:** +- `run_install [args]` - Runs install.sh in test environment +- `run_sync [args]` - Runs sync.sh in test environment + +**Assertions:** +- `assert_symlink "path" "expected_target"` - Verifies symlink exists and points to target +- `assert_regular_file "path"` - Verifies file exists and is not a symlink +- `assert_dir "path"` - Verifies directory exists +- `assert_backup_exists` - Verifies a backup was created +- `assert_manifest_operation "op"` - Verifies manifest contains operation + +**Backup Helpers:** +- `get_latest_backup` - Returns name of most recent backup + +### Testing Tips + +1. **Test both success and failure cases** - Verify error messages and exit codes +2. **Test dry-run mode** - Ensure `--dry-run` doesn't modify anything +3. **Test idempotency** - Running the same command twice should work +4. **Group related tests** - Use comment headers to organize test sections + +## Adding New Tests + +When adding new functionality to install.sh or sync.sh: + +1. Add tests to the appropriate .bats file +2. Add any new helper functions to test_helper.bash +3. Run `bats tests/` to verify all tests pass +4. Consider edge cases (missing files, conflicts, dry-run) + +## CI Integration + +Tests run automatically on push/PR via GitHub Actions. See `.github/workflows/test.yml`. + +The workflow: +1. Runs on `macos-latest` (matches dev environment) +2. Installs bats-core via Homebrew +3. Runs all tests with `bats tests/` +4. Validates all skills with `./sync.sh validate` + +To run locally before pushing: +```bash +bats tests/ && ./sync.sh validate +``` diff --git a/.claude/rules/workflows.md b/.claude/rules/workflows.md new file mode 100644 index 0000000..9d4cbf8 --- /dev/null +++ b/.claude/rules/workflows.md @@ -0,0 +1,120 @@ +# Workflows + +## Setting up on a new machine + +```bash +git clone git@github.com:brianlovin/agent-config.git ~/Developer/agent-config +cd ~/Developer/agent-config +./install.sh +``` + +Creates symlinks from `~/.claude/` to this repo. Local-only items are preserved. + +### Dry-run mode + +Preview what would happen without making changes: + +```bash +./install.sh --dry-run +``` + +### Handling conflicts + +If a local file differs from the repo version, you'll be prompted: +- `[r]` Use repo version (backs up local first) +- `[l]` Keep local version (skip this item) +- `[d]` Show diff between versions +- `[q]` Quit + +Use `--force` to automatically use repo versions (still creates backups). + +## Sync status legend + +```bash +./sync.sh +``` + +Shows status grouped by type (Skills, Agents, Rules): + +- `✓` synced (symlinked to this repo) +- `○` local only (not in repo) +- `⚠` conflict (exists in both - run `./install.sh` to fix) +- `→` external (symlinked elsewhere) + +## Adding items to sync across machines + +```bash +./sync.sh add skill # Add a skill directory +./sync.sh add agent # Add an agent file (without .md extension) +./sync.sh add rule # Add a rule file (without .md extension) +./sync.sh push +``` + +Copies the item to repo, replaces local with symlink, prompts for commit. + +Skills are validated before adding - must have SKILL.md with `name` and `description` in frontmatter. + +## Removing items from repo + +```bash +./sync.sh remove skill +./sync.sh remove agent +./sync.sh remove rule +./sync.sh push +``` + +Removes from repo but keeps local copy. + +## Backups and undo + +All destructive operations create timestamped backups in `.backup/`. + +```bash +./sync.sh backups # List available backups +./sync.sh undo # Restore from last backup +./sync.sh undo --dry-run # Preview what would be restored +``` + +## Validating skills + +Check that all skills have valid SKILL.md files: + +```bash +./sync.sh validate +``` + +Skills must have frontmatter with `name` and `description`: + +```yaml +--- +name: my-skill +description: What this skill does +--- +``` + +## Dry-run mode + +Preview any command without making changes: + +```bash +./sync.sh --dry-run add skill my-skill +./sync.sh -n remove agent my-agent +./install.sh --dry-run +``` + +## Keeping items local-only + +Any item in `~/.claude/` that isn't symlinked stays local. The install script only creates symlinks for what's in this repo—it never deletes local files. + +Use this for work-specific or experimental items. + +## Directory structure + +``` +~/.claude/ +├── skills/ # Skill directories (each has SKILL.md) +├── agents/ # Subagent markdown files +├── rules/ # Rule markdown files +├── settings.json +└── statusline.sh +``` diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6a46232 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install bats + run: brew install bats-core + + - name: Run tests + run: bats tests/ + + - name: Validate skills + run: ./sync.sh validate diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1d25d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.backup/ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..134c0e8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# Agent Config + +Personal agent settings, skills, agents, and rules, synced across machines via symlinks. + +## Commands + +```bash +./install.sh # Set up symlinks (run after cloning) +./install.sh --dry-run # Preview what would be done +./sync.sh # Show sync status +./sync.sh add # Add a local item to repo +./sync.sh remove # Remove item from repo (keeps local) +./sync.sh pull # Pull latest and reinstall symlinks +./sync.sh push # Commit and push changes +./sync.sh undo # Restore from last backup +./sync.sh validate # Validate all skills +./sync.sh backups # List available backups +bats tests/ # Run tests +``` + +Types: `skill`, `agent`, `rule` + +## Testing + +Tests use Bats. Run `bats tests/` to execute all tests. Tests run in isolated temp directories. + +See [.claude/rules/testing.md](.claude/rules/testing.md) for testing conventions. + +## Key Files + +- `install.sh` - Creates symlinks from ~/.claude and ~/.codex/skills to this repo +- `sync.sh` - Manages syncing items between local and repo +- `tests/` - Bats test suite + +For detailed workflows, see [.claude/rules/workflows.md](.claude/rules/workflows.md). + +## Verification + +After making changes: +- `bats tests/` - Run all tests +- `./sync.sh validate` - Validate all skills diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5062b63 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# agent-config + +My agent configuration for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and Codex. + +## Quick start + +```bash +git clone https://github.com/brianlovin/agent-config.git +cd agent-config +./install.sh +``` + +## What's included + +### Settings +- `settings.json` - Global permissions and preferences +- `statusline.sh` - Custom statusline showing token usage + +### Skills +Reusable capabilities that your coding agents can invoke. + +| Skill | Description | +|-------|-------------| +| `agent-browser` | Browser automation for web testing and interaction | +| `favicon` | Generate favicons from a source image | +| `knip` | Find and remove unused files, dependencies, and exports | +| `rams` | Run accessibility and visual design review | +| `reclaude` | Refactor CLAUDE.md files for progressive disclosure | +| `simplify` | Code simplification specialist | +| `deslop` | Remove AI-generated code slop | + +## Managing your config + +```bash +# See what's synced vs local-only +./sync.sh + +# Preview what install would do +./install.sh --dry-run + +# Add a local skill to the repo +./sync.sh add skill my-skill +./sync.sh push + +# Pull changes on another machine +./sync.sh pull + +# Remove a skill from repo (keeps local copy) +./sync.sh remove skill my-skill +./sync.sh push +``` + +### Safe operations with backups + +All destructive operations create timestamped backups: + +```bash +# List available backups +./sync.sh backups + +# Restore from last backup +./sync.sh undo +``` + +### Validate skills + +```bash +./sync.sh validate +``` + +Skills must have a `SKILL.md` with frontmatter containing `name` and `description`. + +## Testing + +Tests use [Bats](https://github.com/bats-core/bats-core) (Bash Automated Testing System). + +```bash +# Install bats (one-time) +brew install bats-core + +# Run all tests +bats tests/ + +# Run specific test file +bats tests/install.bats +bats tests/sync.bats +bats tests/validation.bats +``` + +Tests run in isolated temp directories and don't affect your actual `~/.claude` config. +Tests also cover Codex skills syncing in `~/.codex/skills`. + +## Local-only config + +Not everything needs to be synced. The install script only creates symlinks for what's in this repo - it won't delete your local-only skills. + +Machine-specific permissions accumulate in `~/.claude/settings.local.json` (auto-created by Claude, not synced). +Codex skills are also linked from this repo into `~/.codex/skills`. + +## Creating your own + +Fork this repo and customize! The structure is simple: + +``` +agent-config/ +├── settings.json # Claude Code settings +├── statusline.sh # Optional statusline script +├── skills/ # Skills (subdirectories with SKILL.md) +├── agents/ # Subagent definitions +├── rules/ # Rule files +└── tests/ # Bats tests +``` + +## See also + +- [Claude Code docs](https://docs.anthropic.com/en/docs/claude-code) +- [My dotfiles](https://github.com/brianlovin/dotfiles) - Shell, git, SSH config diff --git a/agents/security-reviewer.md b/agents/security-reviewer.md new file mode 100644 index 0000000..90cebc4 --- /dev/null +++ b/agents/security-reviewer.md @@ -0,0 +1,45 @@ +--- +name: security-reviewer +description: Reviews code for security issues including injection vulnerabilities, auth flaws, and secrets in code. +tools: Read, Grep, Glob +--- + +# Security Code Review + +Review code for common security vulnerabilities and issues. + +## Check For + +### Injection Vulnerabilities +- SQL injection (unsanitized input in queries) +- Command injection (shell commands with user input) +- XSS (unescaped output in HTML/templates) +- Path traversal (user input in file paths) + +### Authentication & Authorization +- Missing auth checks on sensitive endpoints +- Hardcoded credentials or API keys +- Weak session management +- Improper access control + +### Secrets & Sensitive Data +- API keys, tokens, passwords in source code +- Credentials in configuration files +- Secrets in error messages or logs +- Sensitive data in URLs or query strings + +### Data Handling +- Sensitive data logged or exposed in errors +- Missing input validation +- Insecure deserialization +- Improper error handling revealing internals + +## Output Format + +Report findings with: +1. **Location**: File and line number +2. **Issue**: What the vulnerability is +3. **Risk**: Severity (Critical/High/Medium/Low) +4. **Fix**: Recommended remediation + +If no issues found, report "No security issues identified" with a brief summary of what was reviewed. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..fb2e1fb --- /dev/null +++ b/install.sh @@ -0,0 +1,369 @@ +#!/bin/bash +set -e + +CONFIG_DIR="$(cd "$(dirname "$0")" && pwd)" +BACKUP_DIR="$CONFIG_DIR/.backup" +DRY_RUN=false +FORCE=false +CLAUDE_HOME="$HOME/.claude" +CODEX_HOME="$HOME/.codex" + +# Colors +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +RESET='\033[0m' + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + -n|--dry-run) + DRY_RUN=true + shift + ;; + -f|--force) + FORCE=true + shift + ;; + -h|--help) + echo "Usage: ./install.sh [options]" + echo "" + echo "Options:" + echo " -n, --dry-run Show what would be done without making changes" + echo " -f, --force Overwrite conflicts without prompting" + echo " -h, --help Show this help message" + exit 0 + ;; + *) + echo "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# Create backup with timestamp +create_backup() { + local timestamp=$(date +%Y%m%d_%H%M%S) + local backup_path="$BACKUP_DIR/$timestamp" + mkdir -p "$backup_path" + echo "$backup_path" +} + +# Backup a file or directory +backup_item() { + local src="$1" + local backup_path="$2" + local relative_path="${src#$HOME/}" + local dest="$backup_path/$relative_path" + + mkdir -p "$(dirname "$dest")" + if [ -L "$src" ]; then + # For symlinks, store the target + echo "$(readlink "$src")" > "$dest.symlink" + elif [ -d "$src" ]; then + cp -r "$src" "$dest" + else + cp "$src" "$dest" + fi +} + +# Write manifest +write_manifest() { + local backup_path="$1" + local operation="$2" + shift 2 + local items=("$@") + + cat > "$backup_path/manifest.json" << EOF +{ + "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "operation": "$operation", + "items": [$(printf '"%s",' "${items[@]}" | sed 's/,$//')], + "user": "$USER", + "hostname": "$(hostname)" +} +EOF +} + +# Check if item has conflict (exists locally and is not a symlink to our repo) +has_conflict() { + local path="$1" + if [ -e "$path" ] && [ ! -L "$path" ]; then + return 0 + fi + if [ -L "$path" ]; then + local target=$(readlink "$path") + if [[ "$target" != "$CONFIG_DIR"* ]]; then + return 0 + fi + fi + return 1 +} + +# Handle conflict interactively +handle_conflict() { + local src="$1" + local dest="$2" + local backup_path="$3" + local item_name="$4" + + if $FORCE; then + backup_item "$dest" "$backup_path" + return 0 # Proceed with overwrite + fi + + echo "" + echo -e "${YELLOW}Conflict:${RESET} $item_name exists locally and differs from repo" + echo " Local: $dest" + echo " Repo: $src" + echo "" + echo "Options:" + echo " [r] Use repo version (backup local)" + echo " [l] Keep local version (skip)" + echo " [d] Show diff" + echo " [q] Quit" + + while true; do + read -p "Choice [r/l/d/q]: " choice + case $choice in + r|R) + backup_item "$dest" "$backup_path" + return 0 # Proceed with overwrite + ;; + l|L) + return 1 # Skip this item + ;; + d|D) + echo "" + if [ -d "$src" ]; then + diff -r "$dest" "$src" 2>/dev/null || true + else + diff "$dest" "$src" 2>/dev/null || true + fi + echo "" + ;; + q|Q) + echo "Aborted." + exit 1 + ;; + *) + echo "Invalid choice. Use r, l, d, or q." + ;; + esac + done +} + +# Dry run output +dry_run_msg() { + echo -e "${BLUE}[dry-run]${RESET} $1" +} + +# Main installation +main() { + local skill_targets=("$CLAUDE_HOME/skills" "$CODEX_HOME/skills") + + if $DRY_RUN; then + echo -e "${BOLD}Dry run - showing what would be done:${RESET}" + echo "" + else + echo "Installing agent config from $CONFIG_DIR" + echo "" + fi + + local backup_path="" + local backed_up_items=() + local has_changes=false + + if ! $DRY_RUN; then + mkdir -p "$CLAUDE_HOME" "$CODEX_HOME" + fi + + # Settings + if [ -f "$CONFIG_DIR/settings.json" ]; then + if $DRY_RUN; then + if has_conflict "$CLAUDE_HOME/settings.json"; then + dry_run_msg "Would backup and replace $CLAUDE_HOME/settings.json" + else + dry_run_msg "Would link settings.json" + fi + else + if has_conflict "$CLAUDE_HOME/settings.json"; then + [ -z "$backup_path" ] && backup_path=$(create_backup) + if handle_conflict "$CONFIG_DIR/settings.json" "$CLAUDE_HOME/settings.json" "$backup_path" "settings.json"; then + backed_up_items+=("settings.json") + rm -rf "$CLAUDE_HOME/settings.json" + ln -sf "$CONFIG_DIR/settings.json" "$CLAUDE_HOME/settings.json" + echo -e "${GREEN}✓${RESET} settings.json (replaced, backup saved)" + has_changes=true + else + echo -e "${YELLOW}○${RESET} settings.json (kept local)" + fi + else + ln -sf "$CONFIG_DIR/settings.json" "$CLAUDE_HOME/settings.json" + echo -e "${GREEN}✓${RESET} settings.json" + has_changes=true + fi + fi + fi + + # Statusline + if [ -f "$CONFIG_DIR/statusline.sh" ]; then + if $DRY_RUN; then + if has_conflict "$CLAUDE_HOME/statusline.sh"; then + dry_run_msg "Would backup and replace $CLAUDE_HOME/statusline.sh" + else + dry_run_msg "Would link statusline.sh" + fi + else + if has_conflict "$CLAUDE_HOME/statusline.sh"; then + [ -z "$backup_path" ] && backup_path=$(create_backup) + if handle_conflict "$CONFIG_DIR/statusline.sh" "$CLAUDE_HOME/statusline.sh" "$backup_path" "statusline.sh"; then + backed_up_items+=("statusline.sh") + rm -rf "$CLAUDE_HOME/statusline.sh" + ln -sf "$CONFIG_DIR/statusline.sh" "$CLAUDE_HOME/statusline.sh" + echo -e "${GREEN}✓${RESET} statusline.sh (replaced, backup saved)" + has_changes=true + else + echo -e "${YELLOW}○${RESET} statusline.sh (kept local)" + fi + else + ln -sf "$CONFIG_DIR/statusline.sh" "$CLAUDE_HOME/statusline.sh" + echo -e "${GREEN}✓${RESET} statusline.sh" + has_changes=true + fi + fi + fi + + # Skills (directory symlinks per skill) + if [ -d "$CONFIG_DIR/skills" ] && [ -n "$(ls -A "$CONFIG_DIR/skills" 2>/dev/null)" ]; then + if ! $DRY_RUN; then + for target in "${skill_targets[@]}"; do + mkdir -p "$target" + done + fi + for skill in "$CONFIG_DIR/skills"/*/; do + [ -d "$skill" ] || continue + skill_name=$(basename "$skill") + for target in "${skill_targets[@]}"; do + local dest="$target/$skill_name" + + if $DRY_RUN; then + if has_conflict "$dest"; then + dry_run_msg "Would backup and replace $dest" + else + dry_run_msg "Would link $dest" + fi + else + if has_conflict "$dest"; then + [ -z "$backup_path" ] && backup_path=$(create_backup) + if handle_conflict "$skill" "$dest" "$backup_path" "$dest"; then + backed_up_items+=("${dest#$HOME/}") + rm -rf "$dest" + ln -sfn "$skill" "$dest" + echo -e "${GREEN}✓${RESET} $dest (replaced, backup saved)" + has_changes=true + else + echo -e "${YELLOW}○${RESET} $dest (kept local)" + fi + else + ln -sfn "$skill" "$dest" + echo -e "${GREEN}✓${RESET} $dest" + has_changes=true + fi + fi + done + done + fi + + # Agents (file symlinks per agent) + if [ -d "$CONFIG_DIR/agents" ] && ls "$CONFIG_DIR/agents"/*.md &>/dev/null; then + mkdir -p "$CLAUDE_HOME/agents" + for agent in "$CONFIG_DIR/agents"/*.md; do + [ -f "$agent" ] || continue + agent_name=$(basename "$agent") + local dest="$CLAUDE_HOME/agents/$agent_name" + + if $DRY_RUN; then + if has_conflict "$dest"; then + dry_run_msg "Would backup and replace agents/$agent_name" + else + dry_run_msg "Would link agents/$agent_name" + fi + else + if has_conflict "$dest"; then + [ -z "$backup_path" ] && backup_path=$(create_backup) + if handle_conflict "$agent" "$dest" "$backup_path" "agents/$agent_name"; then + backed_up_items+=("agents/$agent_name") + rm -rf "$dest" + ln -sf "$agent" "$dest" + echo -e "${GREEN}✓${RESET} agents/$agent_name (replaced, backup saved)" + has_changes=true + else + echo -e "${YELLOW}○${RESET} agents/$agent_name (kept local)" + fi + else + ln -sf "$agent" "$dest" + echo -e "${GREEN}✓${RESET} agents/$agent_name" + has_changes=true + fi + fi + done + fi + + # Rules (file symlinks per rule) + if [ -d "$CONFIG_DIR/rules" ] && ls "$CONFIG_DIR/rules"/*.md &>/dev/null; then + mkdir -p "$CLAUDE_HOME/rules" + for rule in "$CONFIG_DIR/rules"/*.md; do + [ -f "$rule" ] || continue + rule_name=$(basename "$rule") + local dest="$CLAUDE_HOME/rules/$rule_name" + + if $DRY_RUN; then + if has_conflict "$dest"; then + dry_run_msg "Would backup and replace rules/$rule_name" + else + dry_run_msg "Would link rules/$rule_name" + fi + else + if has_conflict "$dest"; then + [ -z "$backup_path" ] && backup_path=$(create_backup) + if handle_conflict "$rule" "$dest" "$backup_path" "rules/$rule_name"; then + backed_up_items+=("rules/$rule_name") + rm -rf "$dest" + ln -sf "$rule" "$dest" + echo -e "${GREEN}✓${RESET} rules/$rule_name (replaced, backup saved)" + has_changes=true + else + echo -e "${YELLOW}○${RESET} rules/$rule_name (kept local)" + fi + else + ln -sf "$rule" "$dest" + echo -e "${GREEN}✓${RESET} rules/$rule_name" + has_changes=true + fi + fi + done + fi + + echo "" + + if $DRY_RUN; then + echo "Run without --dry-run to apply changes." + else + if [ -n "$backup_path" ] && [ ${#backed_up_items[@]} -gt 0 ]; then + write_manifest "$backup_path" "install" "${backed_up_items[@]}" + echo -e "${BLUE}Backup saved:${RESET} $backup_path" + echo "Run './sync.sh undo' to restore." + echo "" + fi + + echo "Done! Agent config installed." + echo "" + echo "Local-only items in ~/.claude/ and ~/.codex/ are preserved." + echo "Use ./sync.sh to manage what gets shared." + fi +} + +main diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..a9962a6 --- /dev/null +++ b/settings.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(gh:*)", + "Bash(git:*)" + ], + "deny": [] + }, + "statusLine": { + "type": "command", + "command": "~/.claude/statusline.sh" + }, + "alwaysThinkingEnabled": true, + "skipDangerousModePermissionPrompt": true +} diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md new file mode 100644 index 0000000..a083812 --- /dev/null +++ b/skills/agent-browser/SKILL.md @@ -0,0 +1,252 @@ +--- +name: agent-browser +description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages. +allowed-tools: Bash(agent-browser:*) +--- + +# Browser Automation with agent-browser + +## Quick start + +```bash +agent-browser open # Navigate to page +agent-browser snapshot -i # Get interactive elements with refs +agent-browser click @e1 # Click element by ref +agent-browser fill @e2 "text" # Fill input by ref +agent-browser close # Close browser +``` + +## Core workflow + +1. Navigate: `agent-browser open ` +2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`) +3. Interact using refs from the snapshot +4. Re-snapshot after navigation or significant DOM changes + +## Commands + +### Navigation +```bash +agent-browser open # Navigate to URL +agent-browser back # Go back +agent-browser forward # Go forward +agent-browser reload # Reload page +agent-browser close # Close browser +``` + +### Snapshot (page analysis) +```bash +agent-browser snapshot # Full accessibility tree +agent-browser snapshot -i # Interactive elements only (recommended) +agent-browser snapshot -c # Compact output +agent-browser snapshot -d 3 # Limit depth to 3 +agent-browser snapshot -s "#main" # Scope to CSS selector +``` + +### Interactions (use @refs from snapshot) +```bash +agent-browser click @e1 # Click +agent-browser dblclick @e1 # Double-click +agent-browser focus @e1 # Focus element +agent-browser fill @e2 "text" # Clear and type +agent-browser type @e2 "text" # Type without clearing +agent-browser press Enter # Press key +agent-browser press Control+a # Key combination +agent-browser keydown Shift # Hold key down +agent-browser keyup Shift # Release key +agent-browser hover @e1 # Hover +agent-browser check @e1 # Check checkbox +agent-browser uncheck @e1 # Uncheck checkbox +agent-browser select @e1 "value" # Select dropdown +agent-browser scroll down 500 # Scroll page +agent-browser scrollintoview @e1 # Scroll element into view +agent-browser drag @e1 @e2 # Drag and drop +agent-browser upload @e1 file.pdf # Upload files +``` + +### Get information +```bash +agent-browser get text @e1 # Get element text +agent-browser get html @e1 # Get innerHTML +agent-browser get value @e1 # Get input value +agent-browser get attr @e1 href # Get attribute +agent-browser get title # Get page title +agent-browser get url # Get current URL +agent-browser get count ".item" # Count matching elements +agent-browser get box @e1 # Get bounding box +``` + +### Check state +```bash +agent-browser is visible @e1 # Check if visible +agent-browser is enabled @e1 # Check if enabled +agent-browser is checked @e1 # Check if checked +``` + +### Screenshots & PDF +```bash +agent-browser screenshot # Screenshot to stdout +agent-browser screenshot path.png # Save to file +agent-browser screenshot --full # Full page +agent-browser pdf output.pdf # Save as PDF +``` + +### Video recording +```bash +agent-browser record start ./demo.webm # Start recording (uses current URL + state) +agent-browser click @e1 # Perform actions +agent-browser record stop # Stop and save video +agent-browser record restart ./take2.webm # Stop current + start new recording +``` +Recording creates a fresh context but preserves cookies/storage from your session. If no URL is provided, it automatically returns to your current page. For smooth demos, explore first, then start recording. + +### Wait +```bash +agent-browser wait @e1 # Wait for element +agent-browser wait 2000 # Wait milliseconds +agent-browser wait --text "Success" # Wait for text +agent-browser wait --url "**/dashboard" # Wait for URL pattern +agent-browser wait --load networkidle # Wait for network idle +agent-browser wait --fn "window.ready" # Wait for JS condition +``` + +### Mouse control +```bash +agent-browser mouse move 100 200 # Move mouse +agent-browser mouse down left # Press button +agent-browser mouse up left # Release button +agent-browser mouse wheel 100 # Scroll wheel +``` + +### Semantic locators (alternative to refs) +```bash +agent-browser find role button click --name "Submit" +agent-browser find text "Sign In" click +agent-browser find label "Email" fill "user@test.com" +agent-browser find first ".item" click +agent-browser find nth 2 "a" text +``` + +### Browser settings +```bash +agent-browser set viewport 1920 1080 # Set viewport size +agent-browser set device "iPhone 14" # Emulate device +agent-browser set geo 37.7749 -122.4194 # Set geolocation +agent-browser set offline on # Toggle offline mode +agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers +agent-browser set credentials user pass # HTTP basic auth +agent-browser set media dark # Emulate color scheme +``` + +### Cookies & Storage +```bash +agent-browser cookies # Get all cookies +agent-browser cookies set name value # Set cookie +agent-browser cookies clear # Clear cookies +agent-browser storage local # Get all localStorage +agent-browser storage local key # Get specific key +agent-browser storage local set k v # Set value +agent-browser storage local clear # Clear all +``` + +### Network +```bash +agent-browser network route # Intercept requests +agent-browser network route --abort # Block requests +agent-browser network route --body '{}' # Mock response +agent-browser network unroute [url] # Remove routes +agent-browser network requests # View tracked requests +agent-browser network requests --filter api # Filter requests +``` + +### Tabs & Windows +```bash +agent-browser tab # List tabs +agent-browser tab new [url] # New tab +agent-browser tab 2 # Switch to tab +agent-browser tab close # Close tab +agent-browser window new # New window +``` + +### Frames +```bash +agent-browser frame "#iframe" # Switch to iframe +agent-browser frame main # Back to main frame +``` + +### Dialogs +```bash +agent-browser dialog accept [text] # Accept dialog +agent-browser dialog dismiss # Dismiss dialog +``` + +### JavaScript +```bash +agent-browser eval "document.title" # Run JavaScript +``` + +## Example: Form submission + +```bash +agent-browser open https://example.com/form +agent-browser snapshot -i +# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3] + +agent-browser fill @e1 "user@example.com" +agent-browser fill @e2 "password123" +agent-browser click @e3 +agent-browser wait --load networkidle +agent-browser snapshot -i # Check result +``` + +## Example: Authentication with saved state + +```bash +# Login once +agent-browser open https://app.example.com/login +agent-browser snapshot -i +agent-browser fill @e1 "username" +agent-browser fill @e2 "password" +agent-browser click @e3 +agent-browser wait --url "**/dashboard" +agent-browser state save auth.json + +# Later sessions: load saved state +agent-browser state load auth.json +agent-browser open https://app.example.com/dashboard +``` + +## Sessions (parallel browsers) + +```bash +agent-browser --session test1 open site-a.com +agent-browser --session test2 open site-b.com +agent-browser session list +``` + +## JSON output (for parsing) + +Add `--json` for machine-readable output: +```bash +agent-browser snapshot -i --json +agent-browser get text @e1 --json +``` + +## Debugging + +```bash +agent-browser open example.com --headed # Show browser window +agent-browser console # View console messages +agent-browser errors # View page errors +agent-browser record start ./debug.webm # Record from current page +agent-browser record stop # Save recording +agent-browser open example.com --headed # Show browser window +agent-browser --cdp 9222 snapshot # Connect via CDP +agent-browser console # View console messages +agent-browser console --clear # Clear console +agent-browser errors # View page errors +agent-browser errors --clear # Clear errors +agent-browser highlight @e1 # Highlight element +agent-browser trace start # Start recording trace +agent-browser trace stop trace.zip # Stop and save trace +``` \ No newline at end of file diff --git a/skills/bun/SKILL.md b/skills/bun/SKILL.md new file mode 100644 index 0000000..22d06c8 --- /dev/null +++ b/skills/bun/SKILL.md @@ -0,0 +1,87 @@ +--- +name: bun +description: Use Bun instead of Node.js, npm, pnpm, or vite. Provides command mappings, Bun-specific APIs, and development patterns. +--- + +# Bun Runtime + +Use Bun as the default JavaScript/TypeScript runtime and package manager. + +## Command Mappings + +| Instead of | Use | +|------------|-----| +| `node file.ts` | `bun file.ts` | +| `ts-node file.ts` | `bun file.ts` | +| `npm install` | `bun install` | +| `npm run script` | `bun run script` | +| `jest` / `vitest` | `bun test` | +| `webpack` / `esbuild` | `bun build` | + +Bun automatically loads `.env` files - don't use dotenv. + +## Bun-Specific APIs + +Prefer these over Node.js equivalents: + +| API | Purpose | Don't use | +|-----|---------|-----------| +| `Bun.serve()` | HTTP server with WebSocket, HTTPS, routes | express | +| `bun:sqlite` | SQLite database | better-sqlite3 | +| `Bun.redis` | Redis client | ioredis | +| `Bun.sql` | Postgres client | pg, postgres.js | +| `Bun.file()` | File operations | node:fs readFile/writeFile | +| `Bun.$\`cmd\`` | Shell commands | execa | +| `WebSocket` | WebSocket client (built-in) | ws | + +## Testing + +Use `bun:test` for tests: + +```ts +import { test, expect } from "bun:test"; + +test("description", () => { + expect(1).toBe(1); +}); +``` + +Run with `bun test`. + +## Frontend Development + +Use HTML imports with `Bun.serve()` instead of Vite. Supports React, CSS, Tailwind. + +**Server:** + +```ts +import index from "./index.html" + +Bun.serve({ + routes: { + "/": index, + "/api/users/:id": { + GET: (req) => Response.json({ id: req.params.id }), + }, + }, + development: { hmr: true, console: true } +}) +``` + +**HTML file:** + +```html + + + + + +``` + +Bun's bundler transpiles `.tsx`, `.jsx`, `.js` automatically. CSS is bundled via `` tags. + +Run with `bun --hot ./server.ts` for HMR. + +## Documentation + +For detailed API docs, see `node_modules/bun-types/docs/**.md`. diff --git a/skills/chrome-webstore-release-blueprint/SKILL.md b/skills/chrome-webstore-release-blueprint/SKILL.md new file mode 100644 index 0000000..d515ab5 --- /dev/null +++ b/skills/chrome-webstore-release-blueprint/SKILL.md @@ -0,0 +1,263 @@ +--- +name: chrome-webstore-release-blueprint +description: Guide a user end-to-end through setting up Chrome Web Store API release automation in any repository. Use when asked to walk someone through OAuth/CWS credential setup, refresh token creation, local/CI secret setup, version-based publish automation, and submission status checks. +--- + +# Chrome Web Store Release Blueprint + +Use this skill as a hands-on setup guide. The agent should lead the user step-by-step, ask for confirmations, and only automate the parts that can be done locally/in CI. + +## What This Skill Is For + +- Helping a user set up Chrome Web Store release automation from scratch. +- Giving clear manual instructions for Google/CWS dashboard steps. +- Implementing repo-side scripts/workflows after the user provides credentials. +- Verifying submission state (`PUBLISHED`, `PENDING_REVIEW`, etc.). + +## Agent Behavior Rules + +- Treat dashboard/OAuth tasks as user-driven; do not imply you performed them. +- Give one clear step at a time and wait for confirmation before moving on. +- Ask for exact values only when needed, and tell user where each value comes from. +- Mask secrets in logs and never commit secret values to git. +- If `gh` is available, offer secret upload automation; if not, provide manual fallback. + +## Step 1: Project Discovery (Before Any Credential Work) + +Collect these inputs: + +- manifest path containing extension version +- build command +- zip/package command and output file name/path +- CI platform (GitHub Actions by default) +- release branch policy (`main`, tags, or manual dispatch) +- local secret file convention (`.env`, `.env.local`, etc.) + +Ask explicitly: +- "Do you want CI to publish only when version changes?" +- "Do you want me to wire GitHub secret upload via `gh`?" + +## Step 2: Detailed Credential Walkthrough (User + Agent) + +### 2.1 Enable API in Google Cloud + +Tell user to open: +- `https://console.cloud.google.com/apis/library/chromewebstore.googleapis.com` + +User actions: +1. Select the intended Google Cloud project. +2. Click `Enable` for Chrome Web Store API. + +Agent prompt example: +- "When Chrome Web Store API shows as Enabled, tell me and I will move to OAuth setup." + +### 2.2 Configure OAuth Consent Screen + +Tell user to open one of: +- `https://console.cloud.google.com/apis/credentials/consent` +- If UI redirects, continue in Google Auth Platform consent screen pages. + +User actions: +1. Choose `External` user type (for non-Workspace internal apps). +2. Fill app name, support email, developer contact email. +3. Save and continue through scopes unless custom scopes are required. +4. Add your own Google account as a test user if app is in Testing mode. +5. Save. + +Agent guidance: +- If user wants stable long-lived refresh token behavior, recommend moving consent screen to Production when ready. + +### 2.3 Create OAuth Client + +Tell user to open: +- `https://console.cloud.google.com/apis/credentials` + +User actions: +1. Click `Create Credentials` -> `OAuth client ID`. +2. Choose application type `Web application`. +3. Add authorized redirect URI exactly: +- `https://developers.google.com/oauthplayground` +4. Create client. + +Capture values: +- `CWS_CLIENT_ID` +- `CWS_CLIENT_SECRET` + +Agent prompt example: +- "Paste `CWS_CLIENT_ID` and `CWS_CLIENT_SECRET` when ready (I will treat them as secrets)." + +### 2.4 Generate Refresh Token (OAuth Playground) + +Tell user to open: +- `https://developers.google.com/oauthplayground/` + +User actions: +1. Click the settings gear icon. +2. Enable `Use your own OAuth credentials`. +3. Paste `CWS_CLIENT_ID` and `CWS_CLIENT_SECRET`. +4. In Step 1, enter scope: +- `https://www.googleapis.com/auth/chromewebstore` +5. Click `Authorize APIs`. +6. Sign in with the same Google account that owns/publishes the extension. +7. Click `Exchange authorization code for tokens`. +8. Copy refresh token. + +Capture value: +- `CWS_REFRESH_TOKEN` + +Agent prompt example: +- "Paste `CWS_REFRESH_TOKEN` now. I will only place it in local secret storage/CI secrets." + +### 2.5 Capture Store IDs + +Capture: +- `CWS_EXTENSION_ID` (the extension item ID from store/developer listing URL) +- `CWS_PUBLISHER_ID` (developer/publisher ID from Chrome Web Store developer account context) + +Agent instruction: +- If user is unsure, ask them to open the Chrome Web Store Developer Dashboard and copy IDs from item/account URLs or account details. + +### 2.6 Credential Checklist + +Do not proceed until all five exist: +- `CWS_CLIENT_ID` +- `CWS_CLIENT_SECRET` +- `CWS_REFRESH_TOKEN` +- `CWS_PUBLISHER_ID` +- `CWS_EXTENSION_ID` + +## Step 3: Local Secret File and CI Secret Setup + +Create a local template file (no real values committed): + +```env +CWS_CLIENT_ID= +CWS_CLIENT_SECRET= +CWS_REFRESH_TOKEN= +CWS_PUBLISHER_ID= +CWS_EXTENSION_ID= +``` + +Ensure real secret file path is gitignored. + +If using GitHub Actions, ask user if `gh` automation is desired. + +If yes, verify: + +```bash +gh --version +gh auth status +``` + +If `gh` auth is missing, tell user to run: +- `gh auth login` + +Then implement a helper script that: +- reads secret values from local env file +- validates all required keys are present +- supports `--dry-run` +- masks values in dry-run output +- uploads with `gh secret set ... --repo ...` +- fails fast on missing keys/auth + +If user declines `gh`, provide manual secret entry checklist for repository settings. + +## Step 4: Release Workflow Blueprint (Version-Triggered) + +Design the CI workflow around this logic: + +1. Read local manifest version. +2. Optionally compare with a secondary version file and fail on mismatch. +3. Exchange refresh token for access token: +- `POST https://oauth2.googleapis.com/token` +4. Fetch CWS status: +- `GET https://chromewebstore.googleapis.com/v2/publishers//items/:fetchStatus` +5. Extract current published version from: +- `publishedItemRevisionStatus.distributionChannels[0].crxVersion` +6. If local version == published version, skip publish. +7. If version changed: +- build package zip +- upload zip: + `POST https://chromewebstore.googleapis.com/upload/v2/publishers//items/:upload` +- handle async upload state with polling when needed +- publish: + `POST https://chromewebstore.googleapis.com/v2/publishers//items/:publish` + +Treat these publish states as successful submission: +- `PENDING_REVIEW` +- `PUBLISHED` +- `PUBLISHED_TO_TESTERS` +- `STAGED` + +## Step 5: Submission Status Checker Blueprint + +Create a script dedicated to "what is the latest submission state?". + +Required behavior: +- accepts env values (and optional `--env-file`) +- optionally accepts `--manifest` for local version comparison +- supports `--json` +- calls token endpoint + `fetchStatus` +- outputs normalized fields: + - `itemId` + - `localVersion` + - `publishedVersion` + - `publishedState` + - `submittedVersion` + - `submittedState` + - `upToDate` + - `pendingReview` +- exits non-zero on auth/API/input errors + +Helpful checks to include: +- flag version mismatch between manifest and package metadata +- show whether uploaded version is pending review but not yet published +- print concise human summary when `--json` is not used + +## Step 6: Guided Verification Flow + +Run this with the user: + +1. Confirm status checker runs successfully before release. +2. Bump extension version (patch) in all version sources. +3. Push branch and trigger workflow. +4. Confirm workflow either: +- skips (if no version change), or +- uploads and submits publish. +5. Re-run status checker: +- expect `PENDING_REVIEW` first in many cases +- later expect published channel to match local version + +## Troubleshooting Script (What Agent Should Say) + +- `invalid_grant`: +- likely wrong/expired refresh token, wrong OAuth client, or wrong account +- `403` from CWS endpoint: +- account lacks publisher permissions for that extension +- workflow no-op: +- local version equals published version by design +- upload failure: +- inspect API response and packaged zip structure/manifest validity +- version mismatch guard failure: +- align all declared version files before publishing + +## Practical Links (Share During Guidance) + +- Chrome Web Store API overview: +`https://developer.chrome.com/docs/webstore/using-api` +- Publish endpoint: +`https://developer.chrome.com/docs/webstore/publish` +- OAuth Playground: +`https://developers.google.com/oauthplayground/` +- API enablement page: +`https://console.cloud.google.com/apis/library/chromewebstore.googleapis.com` +- Credentials page: +`https://console.cloud.google.com/apis/credentials` + +## Guardrails + +- Never commit credentials. +- Never hardcode secrets in workflow YAML. +- Never auto-publish every push without version comparison. +- Keep setup instructions explicit and user-confirmed at each manual step. +- Prefer repeatable helper scripts over ad-hoc one-off commands. diff --git a/skills/deslop/SKILL.md b/skills/deslop/SKILL.md new file mode 100644 index 0000000..37d4f63 --- /dev/null +++ b/skills/deslop/SKILL.md @@ -0,0 +1,16 @@ +--- +name: deslop +description: Remove AI-generated code slop from the current branch. Use after writing code to clean up unnecessary comments, defensive checks, and inconsistent style. +--- + +# Remove AI code slop + +Check the diff against main, and remove all AI generated slop introduced in this branch. + +This includes: +- Extra comments that a human wouldn't add or is inconsistent with the rest of the file +- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths) +- Casts to any to get around type issues +- Any other style that is inconsistent with the file + +Report at the end with only a 1-3 sentence summary of what you changed diff --git a/skills/electron-wrapper/references/build-and-distribute.md b/skills/electron-wrapper/references/build-and-distribute.md new file mode 100644 index 0000000..5caf73e --- /dev/null +++ b/skills/electron-wrapper/references/build-and-distribute.md @@ -0,0 +1,467 @@ +# Build & Distribution + +Server bundling, Bun binary packaging, CI/CD workflows, code signing, and icon generation. + +--- + +## 1. Server Bundle Script + +Bundle the Bun web server into a single file for packaging: + +```typescript +// scripts/build-server.ts +import { mkdir, rm } from "fs/promises"; +import path from "path"; + +async function main(): Promise { + const outDir = path.join(import.meta.dir, "..", "resources", "server"); + + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + + const result = await Bun.build({ + entrypoints: [path.join(import.meta.dir, "..", "src", "index.ts")], + target: "bun", + minify: true, + outdir: outDir, + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, + }); + + if (!result.success) { + console.error("Server build failed"); + process.exit(1); + } + + console.log(`Server bundle written to ${outDir}`); +} + +main().catch((error) => { + console.error("Server build error:", error); + process.exit(1); +}); +``` + +This produces `resources/server/index.js` — a single file that the bundled Bun binary runs in the packaged app. + +--- + +## 2. Bun Binary Download Script + +Downloads platform-specific Bun binaries for bundling into the Electron app: + +```typescript +// scripts/download-bun.ts +import { mkdir, unlink } from "fs/promises"; +import { existsSync } from "fs"; +import path from "path"; + +const BUN_VERSION = "1.2.5"; // pin to match your dev version + +const PLATFORMS = [ + { platform: "darwin", arch: "arm64", file: "bun-darwin-aarch64.zip" }, + { platform: "darwin", arch: "x64", file: "bun-darwin-x64.zip" }, + { platform: "win32", arch: "x64", file: "bun-windows-x64.zip" }, +] as const; + +const RESOURCES_DIR = path.join(import.meta.dir, "..", "resources", "bun"); + +async function downloadBun( + platform: string, + arch: string, + file: string, + force: boolean = false +): Promise { + const url = `https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/${file}`; + const outDir = path.join(RESOURCES_DIR, `${platform}-${arch}`); + const zipPath = path.join(outDir, file); + const bunExecutable = platform === "win32" ? "bun.exe" : "bun"; + const bunPath = path.join(outDir, bunExecutable); + + if (existsSync(bunPath) && !force) { + console.log(`Bun already exists for ${platform}-${arch}, skipping...`); + return; + } + + console.log(`Downloading Bun for ${platform}-${arch}...`); + await mkdir(outDir, { recursive: true }); + + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to download: ${response.status}`); + + await Bun.write(zipPath, response); + + // Extract and flatten + const proc = Bun.spawn(["unzip", "-o", zipPath, "-d", outDir], { + cwd: outDir, + stdout: "inherit", + stderr: "inherit", + }); + await proc.exited; + + // Move binary from extracted subdirectory to outDir root + const extractedDir = path.join(outDir, file.replace(".zip", "")); + const extractedBun = path.join(extractedDir, bunExecutable); + + if (existsSync(extractedBun)) { + const moveProc = Bun.spawn(["mv", extractedBun, bunPath]); + await moveProc.exited; + const rmProc = Bun.spawn(["rm", "-rf", extractedDir]); + await rmProc.exited; + } + + await unlink(zipPath); + + if (platform !== "win32") { + const chmodProc = Bun.spawn(["chmod", "+x", bunPath]); + await chmodProc.exited; + } + + console.log(`Bun ready at ${bunPath}`); +} + +async function main(): Promise { + const args = process.argv.slice(2); + let platforms = PLATFORMS; + const force = args.includes("--force"); + + // --current: download only for the current platform + if (args.includes("--current")) { + const currentPlatform = process.platform; + const currentArch = process.arch === "arm64" ? "arm64" : "x64"; + platforms = PLATFORMS.filter( + (p) => p.platform === currentPlatform && p.arch === currentArch + ); + } + + // --platform darwin --arch arm64: for CI cross-builds + const platformIdx = args.indexOf("--platform"); + const archIdx = args.indexOf("--arch"); + if (platformIdx !== -1 && archIdx !== -1) { + const targetPlatform = args[platformIdx + 1]; + const targetArch = args[archIdx + 1]; + platforms = PLATFORMS.filter( + (p) => p.platform === targetPlatform && p.arch === targetArch + ); + } + + for (const { platform, arch, file } of platforms) { + await downloadBun(platform, arch, file, force); + } +} + +main().catch((error) => { + console.error("Error:", error); + process.exit(1); +}); +``` + +### CLI flags: +- `--current` — download only for current platform/arch (local dev) +- `--platform darwin --arch arm64` — download specific target (CI) +- `--force` — re-download even if binary exists + +--- + +## 3. Package Scripts + +### Root package.json + +```json +{ + "scripts": { + "build": "bun run build.ts", + "build:server": "bun scripts/build-server.ts", + "download-bun": "bun scripts/download-bun.ts", + "electron:dev": "cd electron && npm run dev", + "electron:build": "bun run build:server && cd electron && npm run build", + "electron:pack": "bun run build && bun run build:server && cd electron && npm run pack", + "electron:dist": "bun run build && bun run build:server && cd electron && npm run dist" + } +} +``` + +### electron/package.json + +```json +{ + "scripts": { + "dev": "concurrently \"npm run dev:web\" \"npm run dev:electron\"", + "dev:web": "cd .. && bun run dev", + "dev:electron": "wait-on http://localhost:3005 && npm run build && ELECTRON_DEV_URL=http://localhost:3005 electron .", + "build": "tsc -p tsconfig.main.json && tsc -p tsconfig.preload.json", + "pack": "npm run build && electron-builder --dir", + "dist": "npm run build && electron-builder", + "dist:mac": "npm run build && electron-builder --mac", + "dist:win": "npm run build && electron-builder --win" + } +} +``` + +Build pipeline: `build web app` → `build server bundle` → `compile electron TS` → `electron-builder packages everything` + +--- + +## 4. GitHub Actions CI Workflow + +```yaml +name: Electron Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version to release (without v prefix)' + required: false + +jobs: + build-mac: + runs-on: macos-14 + strategy: + matrix: + arch: [arm64, x64] + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: electron/package-lock.json + + - name: Install web dependencies + run: bun install + + - name: Build web app + run: bun run build + + - name: Build server bundle + run: bun run build:server + + - name: Download Bun binary + run: bun scripts/download-bun.ts --platform darwin --arch ${{ matrix.arch }} + + - name: Install Electron dependencies + run: cd electron && npm ci + + - name: Import Apple certificates + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12 + security create-keychain -p "" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "" build.keychain + security import certificate.p12 -k build.keychain \ + -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "" build.keychain + rm certificate.p12 + + - name: Build Electron app + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + CSC_KEYCHAIN: build.keychain + run: | + cd electron + npm run dist:mac -- --${{ matrix.arch }} --publish never + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: mac-${{ matrix.arch }} + path: | + electron/release/*.dmg + electron/release/*.zip + electron/release/*.yml + electron/release/*.blockmap + if-no-files-found: error + + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: electron/package-lock.json + + - run: bun install + - run: bun run build + - run: bun run build:server + - run: bun scripts/download-bun.ts --platform win32 --arch x64 + - run: cd electron && npm ci + - run: cd electron && npm run dist:win -- --publish never + + - uses: actions/upload-artifact@v4 + with: + name: windows-x64 + path: | + electron/release/*.exe + electron/release/*.yml + electron/release/*.blockmap + if-no-files-found: error + + publish: + needs: [build-mac, build-windows] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Get version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && \ + [ -n "${{ github.event.inputs.version }}" ]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + fi + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + name: Your App v${{ steps.version.outputs.version }} + draft: true + files: | + artifacts/**/*.dmg + artifacts/**/*.zip + artifacts/**/*.exe + artifacts/**/*.yml + artifacts/**/*.blockmap + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +### Key CI decisions: +- **`--publish never`** — Build locally, upload artifacts, create release in a separate job. This avoids giving build machines GitHub token access. +- **Matrix builds** for macOS arm64/x64 — Both run on `macos-14` (Apple Silicon), cross-compilation handles x64 +- **Draft release** — Review before publishing to trigger auto-updater + +--- + +## 5. Code Signing (macOS) + +### Prerequisites + +1. **Apple Developer account** ($99/year) +2. **Developer ID Application certificate** from Apple Developer portal +3. **App-specific password** for notarization + +### Certificate export + +1. Open Keychain Access +2. Find "Developer ID Application: Your Name" certificate +3. Right-click → Export → save as `.p12` with a password +4. Base64 encode: `base64 -i certificate.p12 | pbcopy` + +### Required GitHub secrets + +| Secret | Value | +|--------|-------| +| `APPLE_CERTIFICATE` | Base64-encoded .p12 certificate | +| `APPLE_CERTIFICATE_PASSWORD` | Password used when exporting .p12 | +| `APPLE_ID` | Apple ID email | +| `APPLE_PASSWORD` | App-specific password (not account password) | +| `APPLE_TEAM_ID` | Team ID from Apple Developer portal | + +### Local signing test + +```bash +# Build with signing (credentials in env) +export APPLE_ID="your@email.com" +export APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx" +export APPLE_TEAM_ID="XXXXXXXXXX" +cd electron && npm run dist:mac +``` + +--- + +## 6. Icon Generation + +### macOS (.icns) + +Use `sips` (built into macOS) and `iconutil`: + +```bash +# From a 1024x1024 PNG source +mkdir icon.iconset +sips -z 16 16 app-icon.png --out icon.iconset/icon_16x16.png +sips -z 32 32 app-icon.png --out icon.iconset/icon_16x16@2x.png +sips -z 32 32 app-icon.png --out icon.iconset/icon_32x32.png +sips -z 64 64 app-icon.png --out icon.iconset/icon_32x32@2x.png +sips -z 128 128 app-icon.png --out icon.iconset/icon_128x128.png +sips -z 256 256 app-icon.png --out icon.iconset/icon_128x128@2x.png +sips -z 256 256 app-icon.png --out icon.iconset/icon_256x256.png +sips -z 512 512 app-icon.png --out icon.iconset/icon_256x256@2x.png +sips -z 512 512 app-icon.png --out icon.iconset/icon_512x512.png +sips -z 1024 1024 app-icon.png --out icon.iconset/icon_512x512@2x.png +iconutil -c icns icon.iconset -o app-icon.icns +rm -rf icon.iconset +``` + +### Windows (.ico) + +Use `png-to-ico` (Node.js package, added as devDependency): + +```javascript +// electron/scripts/generate-icons.mjs +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; +import pngToIco from "png-to-ico"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const assetsDir = path.resolve(__dirname, "..", "assets"); +const inputPng = path.join(assetsDir, "app-icon.png"); +const outputIco = path.join(assetsDir, "icon.ico"); + +const icoBuffer = await pngToIco(inputPng); +await fs.writeFile(outputIco, icoBuffer); +console.log(`Generated ${outputIco}`); +``` + +Run with `cd electron && npm run icons`. + +### Source icon requirements +- PNG format, 1024x1024px minimum (512x512 acceptable) +- No transparency for macOS (Apple's guidelines) +- Place source at `electron/assets/app-icon.png` + +--- + +## 7. Release Process + +1. Update version in both `package.json` and `electron/package.json` +2. Commit: `git commit -m "Bump version to X.Y.Z"` +3. Tag: `git tag vX.Y.Z` +4. Push: `git push && git push --tags` +5. CI builds and creates a draft release with artifacts +6. Review the draft release on GitHub +7. Publish the release — this makes it visible to the auto-updater +8. The auto-updater checks `latest.yml`/`latest-mac.yml` from GitHub Releases + +electron-updater uses the `publish.provider: github` config in `electron-builder.yml` to find releases. The `latest*.yml` files (uploaded as release artifacts) tell the updater the current version and download URLs. diff --git a/skills/electron-wrapper/references/main-process.md b/skills/electron-wrapper/references/main-process.md new file mode 100644 index 0000000..ea6c3d3 --- /dev/null +++ b/skills/electron-wrapper/references/main-process.md @@ -0,0 +1,475 @@ +# Main Process + +Annotated implementation patterns for each Electron main process file. + +--- + +## src/main/index.ts — App Entry Point + +The entry point handles app lifecycle, dev/production mode switching, single-instance locking, and IPC registration. + +```typescript +import path from "path"; +import { fileURLToPath } from "url"; +import { app, ipcMain } from "electron"; +import log from "electron-log"; +import { startServer, stopServer } from "./bun-server.js"; +import { createWindow, getMainWindow } from "./window.js"; +import { + setupAutoUpdater, + checkForUpdates, + downloadUpdate, + installUpdate, +} from "./updater.js"; + +log.transports.file.level = "info"; +log.info("App starting..."); + +// ESM __dirname polyfill (see pitfalls §3) +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Dev mode: connect to external dev server instead of spawning Bun +const DEV_SERVER_URL = process.env.ELECTRON_DEV_URL; +const isDev = !!DEV_SERVER_URL; + +let appPort: number | null = null; + +async function getServerPort(): Promise { + if (isDev && DEV_SERVER_URL) { + log.info("Dev mode: connecting to", DEV_SERVER_URL); + const url = new URL(DEV_SERVER_URL); + return parseInt(url.port, 10) || 3000; + } + return startServer(); +} + +// Single-instance lock — prevent multiple windows fighting over the server +const gotLock = app.requestSingleInstanceLock(); + +if (!gotLock) { + log.info("Another instance is running, quitting..."); + app.quit(); +} else { + // Focus existing window when user tries to open a second instance + app.on("second-instance", () => { + const window = getMainWindow(); + if (window) { + if (window.isMinimized()) window.restore(); + window.focus(); + } + }); + + app.whenReady().then(async () => { + try { + appPort = await getServerPort(); + createWindow(appPort); + + // Auto-updater only in packaged builds + if (app.isPackaged) { + setupAutoUpdater(); + setTimeout(() => checkForUpdates(), 5000); // delay avoids startup congestion + } + } catch (error) { + log.error("Failed to start app:", error); + app.quit(); + } + }); + + // macOS: re-create window when dock icon clicked + app.on("activate", () => { + if (!getMainWindow() && appPort) { + createWindow(appPort); + } + }); + + // Non-macOS: quit when all windows closed + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } + }); + + // Clean up Bun server on quit (skip in dev — we didn't start it) + app.on("before-quit", () => { + if (!isDev) { + stopServer(); + } + }); + + // IPC handlers for renderer → main communication + ipcMain.handle("update:check", () => checkForUpdates()); + ipcMain.handle("update:download", () => downloadUpdate()); + ipcMain.handle("update:install", () => installUpdate()); + ipcMain.handle("app:version", () => app.getVersion()); +} +``` + +### Key patterns: +- **Dev URL detection** via environment variable — never start the internal server in dev mode (see pitfalls §4) +- **Single-instance lock** prevents port conflicts when user double-launches +- **Store port at app level** — resolve once, reuse in `activate` handler instead of re-entering `startServer()` +- **Delayed update check** (`setTimeout 5s`) avoids competing with app startup +- **IPC handlers** registered at top level, available regardless of window state + +--- + +## src/main/bun-server.ts — Server Spawning + +Manages the lifecycle of the bundled Bun server process. + +```typescript +import { spawn, type ChildProcess } from "child_process"; +import path from "path"; +import { app } from "electron"; +import log from "electron-log"; +import getPort from "get-port"; + +let serverProcess: ChildProcess | null = null; +let currentPort: number | null = null; + +function getBunPath(): string { + const platform = process.platform; + const arch = process.arch === "arm64" ? "arm64" : "x64"; + const platformArch = `${platform}-${arch}`; + const bunExecutable = platform === "win32" ? "bun.exe" : "bun"; + + if (app.isPackaged) { + // In packaged app: resources are in process.resourcesPath + return path.join(process.resourcesPath, "bun", bunExecutable); + } + + // In development: resources are relative to electron/ dir + return path.join( + app.getAppPath(), + "..", + "resources", + "bun", + platformArch, + bunExecutable + ); +} + +function getServerPath(): string { + return path.join(process.resourcesPath, "server", "index.js"); +} + +export async function startServer(): Promise { + if (serverProcess) { + log.info("Server already running on port", currentPort); + return currentPort!; + } + + const port = await getPort({ port: [3000, 3001, 3002, 3003, 3004] }); + const bunPath = getBunPath(); + const serverPath = getServerPath(); + const dataDir = app.getPath("userData"); + + log.info("Starting Bun server..."); + log.info("Bun path:", bunPath); + log.info("Server path:", serverPath); + log.info("Port:", port); + + const staticDir = app.isPackaged + ? path.join(process.resourcesPath, "dist") + : path.join(app.getAppPath(), "..", "dist"); + + serverProcess = spawn(bunPath, ["run", serverPath, "--port", String(port)], { + env: { + ...process.env, + APP_DATA_DIR: dataDir, // Persistent storage (see pitfalls §7) + APP_STATIC_DIR: staticDir, // Static assets path + NODE_ENV: "production", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + serverProcess.stdout?.on("data", (data) => { + log.info("[server]", data.toString().trim()); + }); + + serverProcess.stderr?.on("data", (data) => { + log.error("[server]", data.toString().trim()); + }); + + serverProcess.on("error", (error) => { + log.error("Failed to start server:", error); + serverProcess = null; + currentPort = null; + }); + + serverProcess.on("exit", (code, signal) => { + log.info(`Server exited with code ${code}, signal ${signal}`); + serverProcess = null; + currentPort = null; + }); + + currentPort = port; + await waitForServer(port); + return port; +} + +async function waitForServer( + port: number, + timeout: number = 30000 +): Promise { + const start = Date.now(); + const url = `http://localhost:${port}`; + + while (Date.now() - start < timeout) { + try { + const response = await fetch(url); + if (response.ok) { + log.info("Server is ready"); + return; + } + } catch { + // Server not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new Error(`Server failed to start within ${timeout}ms`); +} + +export function stopServer(): void { + if (serverProcess) { + log.info("Stopping server..."); + serverProcess.kill("SIGTERM"); + serverProcess = null; + currentPort = null; + } +} +``` + +### Key patterns: +- **`getBunPath()`** resolves differently for packaged vs dev — packaged uses `process.resourcesPath`, dev uses relative paths from `app.getAppPath()` +- **`getServerPath()`** only needs the production path — `startServer()` is never called in dev mode (the `ELECTRON_DEV_URL` path skips it) +- **`getPort()`** avoids conflicts by trying a list of preferred ports +- **Environment variables** pass data dir and static dir to the Bun server +- **`waitForServer()`** polls with fetch until the server responds (100ms interval, 30s timeout) +- **`stopServer()`** sends SIGTERM for clean shutdown + +--- + +## src/main/window.ts — Window Management + +```typescript +import path from "path"; +import { fileURLToPath } from "url"; +import { BrowserWindow, shell } from "electron"; +import Store from "electron-store"; +import log from "electron-log"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface WindowBounds { + x?: number; + y?: number; + width: number; + height: number; +} + +const store = new Store<{ windowBounds: WindowBounds }>(); +let mainWindow: BrowserWindow | null = null; + +export function createWindow(port: number): BrowserWindow { + const bounds = store.get("windowBounds", { + width: 1200, + height: 800, + x: undefined, + y: undefined, + }); + + mainWindow = new BrowserWindow({ + ...bounds, + minWidth: 800, + minHeight: 600, + title: "Your App", + titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", + trafficLightPosition: { x: 16, y: 16 }, + webPreferences: { + preload: path.join(__dirname, "..", "preload", "index.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + show: false, // Prevent white flash (see pitfalls §8) + backgroundColor: "#0a0a0a", // Match your app's background + }); + + mainWindow.once("ready-to-show", () => { + mainWindow?.show(); + }); + + // Persist window bounds for next launch + mainWindow.on("close", () => { + if (mainWindow) { + store.set("windowBounds", mainWindow.getBounds()); + } + }); + + mainWindow.on("closed", () => { + mainWindow = null; + }); + + // External links open in default browser + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: "deny" }; + }); + + // Prevent navigation away from the app + mainWindow.webContents.on("will-navigate", (event, url) => { + const serverUrl = `http://localhost:${port}`; + if (!url.startsWith(serverUrl)) { + event.preventDefault(); + shell.openExternal(url); + } + }); + + mainWindow.loadURL(`http://localhost:${port}`); + return mainWindow; +} + +export function getMainWindow(): BrowserWindow | null { + return mainWindow; +} +``` + +### Key patterns: +- **`electron-store`** persists window position/size across launches +- **`titleBarStyle: "hiddenInset"`** on macOS gives the native traffic light buttons with content extending behind the title bar +- **`trafficLightPosition`** offsets the traffic lights to align with your app's header +- **Security config**: `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true` — never weaken these +- **Navigation guards** prevent the window from navigating away from localhost and open external links in the default browser + +--- + +## src/main/updater.ts — Auto-Update + +```typescript +import electronUpdater, { type UpdateInfo, type ProgressInfo } from "electron-updater"; +import log from "electron-log"; +import { getMainWindow } from "./window.js"; + +// CJS import pattern (see pitfalls §1) +const { autoUpdater } = electronUpdater; + +export function setupAutoUpdater(): void { + autoUpdater.logger = log; + autoUpdater.autoDownload = false; // let user choose when to download + + autoUpdater.on("checking-for-update", () => { + sendToRenderer("update:checking"); + }); + + autoUpdater.on("update-available", (info: UpdateInfo) => { + log.info("Update available:", info.version); + sendToRenderer("update:available", { version: info.version }); + }); + + autoUpdater.on("update-not-available", () => { + sendToRenderer("update:not-available"); + }); + + autoUpdater.on("download-progress", (progress: ProgressInfo) => { + sendToRenderer("update:progress", { percent: progress.percent }); + }); + + autoUpdater.on("update-downloaded", (info: UpdateInfo) => { + log.info("Update downloaded:", info.version); + sendToRenderer("update:downloaded", { version: info.version }); + }); + + autoUpdater.on("error", (error: Error) => { + log.error("Update error:", error); + sendToRenderer("update:error", { message: error.message }); + }); +} + +function sendToRenderer(channel: string, data?: unknown): void { + const window = getMainWindow(); + if (window) { + window.webContents.send(channel, data); + } +} + +export function checkForUpdates(): void { + autoUpdater.checkForUpdates(); +} + +export function downloadUpdate(): void { + autoUpdater.downloadUpdate(); +} + +export function installUpdate(): void { + autoUpdater.quitAndInstall(); +} +``` + +### Key patterns: +- **`autoDownload: false`** — User-initiated downloads give a better UX than surprise background updates +- **Event flow**: `checking` → `available`/`not-available` → (user clicks download) → `progress` → `downloaded` → (user clicks install) → `quitAndInstall()` +- **`sendToRenderer()`** bridges main → renderer via `webContents.send()` +- The renderer invokes `update:check`, `update:download`, `update:install` via IPC handlers in `index.ts` + +--- + +## src/preload/index.ts — Context Bridge + +The preload script exposes a safe API to the renderer via `contextBridge`. It must compile to CommonJS (see pitfalls §2). + +```typescript +import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; + +type IpcHandler = (event: IpcRendererEvent, data: T) => void; + +const electronAPI = { + isElectron: true, + platform: process.platform, + version: () => ipcRenderer.invoke("app:version") as Promise, + + update: { + check: () => ipcRenderer.invoke("update:check"), + download: () => ipcRenderer.invoke("update:download"), + install: () => ipcRenderer.invoke("update:install"), + + // Each listener returns an unsubscribe function + onAvailable: (callback: (data: { version: string }) => void) => { + const handler: IpcHandler<{ version: string }> = (_, data) => + callback(data); + ipcRenderer.on("update:available", handler); + return () => ipcRenderer.removeListener("update:available", handler); + }, + onProgress: (callback: (data: { percent: number }) => void) => { + const handler: IpcHandler<{ percent: number }> = (_, data) => + callback(data); + ipcRenderer.on("update:progress", handler); + return () => ipcRenderer.removeListener("update:progress", handler); + }, + onDownloaded: (callback: (data: { version: string }) => void) => { + const handler: IpcHandler<{ version: string }> = (_, data) => + callback(data); + ipcRenderer.on("update:downloaded", handler); + return () => ipcRenderer.removeListener("update:downloaded", handler); + }, + onError: (callback: (data: { message: string }) => void) => { + const handler: IpcHandler<{ message: string }> = (_, data) => + callback(data); + ipcRenderer.on("update:error", handler); + return () => ipcRenderer.removeListener("update:error", handler); + }, + }, +}; + +contextBridge.exposeInMainWorld("electronAPI", electronAPI); +``` + +### Key patterns: +- **`contextBridge.exposeInMainWorld()`** — The only safe way to expose functionality to the renderer +- **Unsubscribe pattern** — Each `on*` method returns a cleanup function, compatible with React's `useEffect` cleanup +- **`ipcRenderer.invoke()`** for renderer → main (request/response) +- **`ipcRenderer.on()`** for main → renderer (push events) +- Never expose `ipcRenderer` directly — always wrap in specific methods diff --git a/skills/electron-wrapper/references/pitfalls.md b/skills/electron-wrapper/references/pitfalls.md new file mode 100644 index 0000000..e04f174 --- /dev/null +++ b/skills/electron-wrapper/references/pitfalls.md @@ -0,0 +1,390 @@ +# Pitfalls & Gotchas + +Every known issue encountered when wrapping a Bun web app in Electron, with symptoms and proven solutions. + +--- + +## 1. ESM vs CJS Module Conflicts + +**Problem:** Modern npm packages (`get-port`, `electron-store`) are ESM-only, but Electron's Node.js environment defaults to CommonJS. + +**Symptoms:** +``` +Error [ERR_REQUIRE_ESM]: require() of ES Module .../get-port/index.js not supported +``` + +**Solution:** Add `"type": "module"` to `electron/package.json` so Node.js treats `.js` files as ESM: +```json +{ + "type": "module", + "main": "dist/main/index.js" +} +``` + +**Gotcha within the gotcha:** Some packages like `electron-updater` are still CJS. When importing from an ESM context, use the default import pattern: +```typescript +// Fails — named import from CJS module in ESM context +import { autoUpdater } from "electron-updater"; + +// Works — default import, then destructure +import electronUpdater from "electron-updater"; +const { autoUpdater } = electronUpdater; +``` + +--- + +## 2. Preload Scripts Must Be Bundled as a Single CJS File + +**Problem:** Electron's sandboxed preload scripts use a restricted `preloadRequire` that can **only** load built-in Electron modules (`electron`, `events`, `timers`, `url`). Multi-file CJS with relative `require()` calls will fail at runtime — even though `tsc` compiles it successfully. + +**Symptoms:** +``` +Unable to load preload script: /path/to/preload/index.js +Error: module not found: ../shared/types.js +``` +Or, if not using sandbox: +``` +SyntaxError: Cannot use import statement outside a module +``` + +**Solution:** Use **esbuild** to bundle the preload into a single CJS file with `electron` as an external: + +```json +{ + "scripts": { + "build:preload": "esbuild src/preload/index.ts --bundle --platform=node --format=cjs --outfile=dist/preload/index.js --external:electron" + } +} +``` + +This inlines all local imports (shared types, constants) into one file while keeping `require("electron")` as a runtime dependency that the sandbox can resolve. + +**Why not tsc?** Even with `"module": "CommonJS"` in tsconfig, tsc produces multiple output files with `require("../shared/types.js")` calls. The sandboxed preload's restricted require cannot resolve these paths. + +**Keep tsc for type checking only:** +```json +{ + "scripts": { + "typecheck": "tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.preload.json --noEmit" + } +} +``` + +The preload tsconfig still needs `"module": "CommonJS"` for accurate type checking: +```json +// tsconfig.preload.json +{ + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "outDir": "dist/preload", + "rootDir": "src" + } +} +``` + +The main process tsconfig stays ESM: +```json +// tsconfig.main.json +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist/main", + "rootDir": "src/main" + } +} +``` + +--- + +## 3. `__dirname` Unavailable in ESM + +**Problem:** ESM modules don't have `__dirname` or `__filename` globals. Many Electron patterns rely on `__dirname` for resolving paths to preload scripts, assets, and resources. + +**Symptoms:** +``` +ReferenceError: __dirname is not defined +``` + +**Solution:** Reconstruct from `import.meta.url`: +```typescript +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +``` + +Add this polyfill at the top of every main process file that needs path resolution. + +--- + +## 4. Dev Mode MIME Type Errors + +**Problem:** Running Electron's internal Bun server alongside the web dev server causes module serving issues. The bundled server serves built assets, not the dev server's HMR-enhanced modules. + +**Symptoms:** +``` +Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html" +``` + +**Solution:** In dev mode, don't start the internal Bun server. Instead, connect Electron to the external dev server via an environment variable: + +```typescript +// main/index.ts +const DEV_SERVER_URL = process.env.ELECTRON_DEV_URL; +const isDev = !!DEV_SERVER_URL; + +async function getServerPort(): Promise { + if (isDev && DEV_SERVER_URL) { + const url = new URL(DEV_SERVER_URL); + return parseInt(url.port, 10) || 3000; + } + return startServer(); // production: spawn bundled Bun +} +``` + +Dev script uses `concurrently` + `wait-on`: +```json +{ + "scripts": { + "dev": "concurrently \"npm run dev:web\" \"npm run dev:electron\"", + "dev:web": "cd .. && bun run dev", + "dev:electron": "wait-on http://localhost:3005 && npm run build && ELECTRON_DEV_URL=http://localhost:3005 electron ." + } +} +``` + +--- + +## 5. Bun Version Must Match Features + +**Problem:** The bundled Bun binary must support the same APIs your server uses. Older versions may lack features like the `routes` API in `Bun.serve()`. + +**Symptoms:** +``` +TypeError: Expected fetch() to be a function +``` +(Or other cryptic errors from missing API support.) + +**Solution:** Pin the Bun version in your download script and keep it aligned with your development version: +```typescript +const BUN_VERSION = "1.2.5"; // must support your server's API surface +``` + +Re-download when updating: +```bash +bun scripts/download-bun.ts --current --force +``` + +--- + +## 6. nvm/Node.js PATH Issues in Spawned Processes + +**Problem:** When spawning child processes from Electron or test scripts, `node`/`npm` may not be found if using nvm with lazy shell loading. + +**Symptoms:** +``` +env: node: No such file or directory +``` + +**Solution:** For test scripts and build tooling, use `bash -lc` to run commands through a login shell that loads nvm: +```typescript +const proc = spawn({ + cmd: ["bash", "-lc", command], + cwd: projectDir, + stdout: "pipe", + stderr: "pipe", +}); +``` + +This isn't an issue in production since Electron bundles its own Node.js and you bundle the Bun binary. + +--- + +## 7. Storage Paths (CWD Is Wrong in Packaged Apps) + +**Problem:** Web apps commonly store data relative to `process.cwd()`, but packaged Electron apps have an unpredictable CWD (often `/` or the app bundle path). + +**Symptoms:** Data files written to unexpected locations, data not persisting between launches, or permission errors writing to read-only directories. + +**Solution:** Make storage paths configurable via environment variable, defaulting to CWD for web mode: + +```typescript +// In your server's storage module +const DATA_DIR = process.env.APP_DATA_DIR || process.cwd(); +const DATA_FILE = path.join(DATA_DIR, ".app-data.json"); +``` + +Set the env var when spawning the Bun server from Electron: +```typescript +serverProcess = spawn(bunPath, ["run", serverPath, "--port", String(port)], { + env: { + ...process.env, + APP_DATA_DIR: app.getPath("userData"), // ~/Library/Application Support/AppName + }, +}); +``` + +--- + +## 8. White Flash on Window Open + +**Problem:** BrowserWindow shows a white rectangle before the web content loads, creating a jarring flash — especially in dark-themed apps. + +**Symptoms:** Brief white flash visible when launching the app or creating new windows. + +**Solution:** Combine three techniques: + +```typescript +const mainWindow = new BrowserWindow({ + show: false, // 1. Don't show immediately + backgroundColor: "#0a0a0a", // 2. Match your app's background color + // ... +}); + +mainWindow.once("ready-to-show", () => { + mainWindow.show(); // 3. Show only when content is painted +}); +``` + +Choose a `backgroundColor` that matches your app's default theme (dark or light). + +--- + +## 9. Dev Server Port Mismatch + +**Problem:** Electron's dev URL defaults to `localhost:3000`, but the web app's dev server may run on a different port (configured in `package.json` or `.env`). + +**Symptoms:** +``` +Failed to load URL: http://localhost:3000/login with error: ERR_CONNECTION_REFUSED +``` + +**Solution:** Before setting constants, check the web app's actual dev port in its `package.json` dev script or `.env` file. Common patterns: +```json +"dev": "next dev -p 3010" +"dev": "vite --port 5173" +``` + +Match this in Electron's constants: +```typescript +export const URLS = { + PRODUCTION: "https://www.yourapp.com", + DEVELOPMENT: "http://localhost:3010", // Must match web app's dev port +}; +``` + +--- + +## 10. Do NOT Use BrowserView + +**Problem:** `BrowserView` was deprecated in Electron 30 and removed in later versions. It also doesn't receive the preload script from the parent BrowserWindow, so `window.electron` will be undefined. + +**Symptoms:** `window.electron` is undefined in the web app even though the preload compiles correctly. Or deprecation warnings/errors on newer Electron versions. + +**Solution:** Load the web app URL directly in the `BrowserWindow` via `mainWindow.loadURL()`. The BrowserWindow already has the preload configured in its `webPreferences`, so `contextBridge.exposeInMainWorld()` works correctly. + +```typescript +// Wrong — BrowserView doesn't inherit preload from parent window +const view = new BrowserView({ webPreferences: { /* no preload */ } }); +mainWindow.setBrowserView(view); +view.webContents.loadURL(appUrl); + +// Right — load directly in the BrowserWindow +mainWindow.loadURL(appUrl); +``` + +--- + +## 11. electron-builder `${platform}` !== Node.js `process.platform` + +**Problem:** electron-builder's `${platform}` macro resolves to `mac`/`linux`/`win`, but Node.js (and the Bun download script) uses `darwin`/`linux`/`win32`. If you use `${platform}` in `extraResources` paths for the Bun binary, the path won't match the actual directory and the binary silently won't be bundled. + +**Symptoms:** +``` +Error: spawn /Applications/Your App.app/Contents/Resources/bun/bun ENOENT +``` +The app starts, tries to spawn the Bun server, but the binary is missing from the packaged app. Locally-built dev mode works fine since it uses a different code path. + +**Solution:** Put the Bun `extraResources` entry in platform-specific sections with hardcoded platform prefixes: + +```yaml +# Wrong — ${platform} resolves to "mac", not "darwin" +extraResources: + - from: ../resources/bun/${platform}-${arch}/ + to: bun/ + +# Right — use platform-specific sections with correct prefixes +mac: + extraResources: + - from: ../resources/bun/darwin-${arch}/ + to: bun/ + +win: + extraResources: + - from: ../resources/bun/win32-${arch}/ + to: bun/ +``` + +Platform-independent resources (server bundle, web app dist) can stay in the top-level `extraResources`. + +--- + +## 12. Bun Workspaces Hoist Dependencies Away from electron-builder + +**Problem:** If the Electron directory is a workspace in a Bun monorepo, Bun hoists all dependencies to the root `node_modules/`. electron-builder expects production deps in `electron/node_modules/` and won't find them. Even if you manually whitelist packages in the `files` section, you'll miss transitive dependencies and get `ERR_MODULE_NOT_FOUND` at runtime. + +**Symptoms:** +``` +Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'ajv-formats' imported from .../conf/dist/source/index.js +``` +The app builds without error, but crashes on launch because a transitive dependency (e.g., `ajv-formats` needed by `conf` needed by `electron-store`) is missing from the packaged app. + +**Solution A (recommended for Bun workspaces):** Bundle the main process with esbuild, inlining all dependencies. No `node_modules` needed in the packaged app: + +```json +{ + "scripts": { + "build:main": "esbuild src/main/index.ts --bundle --platform=node --format=esm --outfile=dist/main/main/index.js --external:electron --banner:js=\"import { createRequire } from 'module'; var require = createRequire(import.meta.url);\"", + "build:preload": "esbuild src/preload/index.ts --bundle --platform=node --format=cjs --outfile=dist/preload/preload/index.js --external:electron" + } +} +``` + +The `createRequire` banner is essential — CJS packages like `electron-log` use `require("electron")` internally, which fails in ESM output without a real `require` function. The banner provides one via Node's `module.createRequire`. + +With this approach, `electron-builder.yml` excludes all node_modules: +```yaml +files: + - dist/**/* + - assets/**/* + - "!node_modules" +``` + +**Solution B (recommended for standalone Electron projects):** Use npm (not Bun) for the Electron directory so deps stay in `electron/node_modules/`. Use `tsc` for the main process and `files: - dist/**/*` in electron-builder.yml — electron-builder handles production deps automatically. + +**Why not whitelist node_modules?** A manual whitelist like `node_modules/electron-store/**/*` is fragile — it misses transitive deps and breaks silently whenever a dependency updates its dependency tree. + +--- + +## 13. Never Build Release Artifacts Locally + +**Problem:** Running `electron-builder --publish always` or `gh release create` with locally-built artifacts produces apps that aren't notarized. macOS Gatekeeper will block them with "Apple could not verify" errors. + +**Symptoms:** +- Build log shows `skipped macOS notarization reason=notarize options were unable to be generated` +- Downloaded app shows "Apple could not verify" dialog +- Users can't open the app without `xattr -cr` + +**Solution:** Always cut releases through CI. The correct workflow: + +1. Bump version in `electron/package.json`, commit, merge to main +2. Find the CI workflow's tag pattern: `grep -A2 'tags:' .github/workflows/*.yml` +3. Tag the merged commit on main: `git tag origin/main` +4. Push the tag: `git push origin ` +5. Monitor CI: `gh run list --workflow=.yml --limit=1` +6. Review the draft release on GitHub, then publish + +CI has the signing certificates (`APPLE_CERTIFICATE`), notarization credentials (`APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID`), and publish tokens that local machines don't have. diff --git a/skills/electron-wrapper/references/project-setup.md b/skills/electron-wrapper/references/project-setup.md new file mode 100644 index 0000000..39fba04 --- /dev/null +++ b/skills/electron-wrapper/references/project-setup.md @@ -0,0 +1,304 @@ +# Project Setup + +How to set up the Electron subproject alongside an existing Bun web app. + +--- + +## Directory Structure + +Create an `electron/` directory at the root of your project: + +``` +your-app/ +├── electron/ +│ ├── package.json +│ ├── package-lock.json +│ ├── tsconfig.main.json +│ ├── tsconfig.preload.json +│ ├── electron-builder.yml +│ ├── entitlements.mac.plist +│ ├── assets/ +│ │ ├── app-icon.png (512x512+ source icon) +│ │ ├── app-icon.icns (generated for macOS) +│ │ └── icon.ico (generated for Windows) +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── index.ts (app entry point) +│ │ │ ├── bun-server.ts (server spawning) +│ │ │ ├── window.ts (window management) +│ │ │ └── updater.ts (auto-update) +│ │ └── preload/ +│ │ └── index.ts (context bridge) +│ └── scripts/ +│ └── generate-icons.mjs (icon generation) +├── scripts/ +│ ├── build-server.ts (Bun.build() for server bundle) +│ └── download-bun.ts (Bun binary downloader) +├── resources/ +│ ├── bun/ +│ │ ├── darwin-arm64/bun +│ │ ├── darwin-x64/bun +│ │ └── win32-x64/bun.exe +│ └── server/ +│ └── index.js (bundled server output) +├── dist/ (web app build output) +├── src/ (your existing web app) +└── package.json +``` + +--- + +## electron/package.json + +Use **npm** (not Bun) for the Electron subproject. Bun's module resolution conflicts with electron-builder's packaging expectations. + +```json +{ + "name": "your-app-electron", + "version": "0.1.0", + "private": true, + "repository": "github:your-org/your-app", + "type": "module", + "main": "dist/main/index.js", + "scripts": { + "dev": "concurrently \"npm run dev:web\" \"npm run dev:electron\"", + "dev:web": "cd .. && bun run dev", + "dev:electron": "wait-on http://localhost:3005 && npm run build && ELECTRON_DEV_URL=http://localhost:3005 electron .", + "build": "tsc -p tsconfig.main.json && esbuild src/preload/index.ts --bundle --platform=node --format=cjs --outfile=dist/preload/index.js --external:electron", + "icons": "node scripts/generate-icons.mjs", + "pack": "npm run build && electron-builder --dir", + "dist": "npm run build && electron-builder", + "dist:mac": "npm run build && electron-builder --mac", + "dist:win": "npm run build && electron-builder --win" + }, + "dependencies": { + "electron-log": "^5.2.4", + "electron-store": "^10.0.0", + "electron-updater": "^6.3.9", + "get-port": "^7.1.0" + }, + "devDependencies": { + "concurrently": "^9.1.2", + "electron": "^33.2.0", + "electron-builder": "^25.1.8", + "esbuild": "^0.27.3", + "png-to-ico": "^3.0.0", + "typescript": "^5.8.3", + "wait-on": "^8.0.3" + } +} +``` + +Key decisions: +- **`"type": "module"`** — Required so Node.js treats compiled `.js` as ESM (see pitfalls §1) +- **`"repository"`** — Required by electron-builder for GitHub releases publish provider +- **`"main"`** — Points to compiled entry point +- **npm, not Bun** — electron-builder expects npm-style `node_modules` layout + +--- + +## TypeScript Configs + +Two separate configs are required because the main process uses ESM while preload scripts must be CJS (see pitfalls §2). + +### tsconfig.main.json (ESM) + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist/main", + "rootDir": "src/main", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "declarationMap": false, + "sourceMap": true + }, + "include": ["src/main/**/*"] +} +``` + +### tsconfig.preload.json (CommonJS) + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["ES2022", "DOM"], + "outDir": "dist/preload", + "rootDir": "src/preload", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "declarationMap": false, + "sourceMap": true + }, + "include": ["src/preload/**/*"] +} +``` + +Note `"lib"` includes `"DOM"` for preload (it runs in renderer context) but not for main. + +--- + +## electron-builder.yml + +```yaml +appId: com.your-org.your-app +productName: Your App +copyright: Copyright © 2025 + +directories: + output: release + buildResources: assets + +files: + - dist/**/* + +extraResources: + - from: ../resources/server/ + to: server/ + - from: ../dist/ + to: dist/ + +mac: + extraResources: + - from: ../resources/bun/darwin-${arch}/ + to: bun/ + icon: assets/app-icon.icns + category: public.app-category.utilities + target: + - target: dmg + arch: + - arm64 + - x64 + - target: zip + arch: + - arm64 + - x64 + hardenedRuntime: true + gatekeeperAssess: false + entitlements: entitlements.mac.plist + entitlementsInherit: entitlements.mac.plist + notarize: true + +dmg: + sign: false + contents: + - x: 130 + y: 220 + - x: 410 + y: 220 + type: link + path: /Applications + +win: + extraResources: + - from: ../resources/bun/win32-${arch}/ + to: bun/ + icon: assets/icon.ico + target: + - target: nsis + arch: + - x64 + artifactName: ${productName}-${version}-${arch}.${ext} + +nsis: + oneClick: false + perMachine: false + allowToChangeInstallationDirectory: true + deleteAppDataOnUninstall: false + +publish: + provider: github + releaseType: draft +``` + +Key points: +- **`extraResources`** bundles the Bun binary, server bundle, and web app build into the packaged app +- **Bun binary paths use platform-specific sections** — electron-builder's `${platform}` resolves to `mac`/`win`, NOT `darwin`/`win32`. Since the download script uses Node.js platform names (`darwin`, `win32`), the bun `extraResources` entry must go in platform-specific `mac:`/`win:` sections with hardcoded platform prefixes instead of using `${platform}` +- **`notarize: true`** requires Apple credentials in environment (see build-and-distribute.md) +- **`dmg.sign: false`** — DMG signing is unnecessary and can cause issues + +--- + +## entitlements.mac.plist + +Required for macOS code signing. The Bun runtime needs JIT and unsigned memory permissions: + +```xml + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.network.client + + com.apple.security.network.server + + + +``` + +- **allow-jit** and **allow-unsigned-executable-memory** — Required because the bundled Bun binary uses JIT compilation +- **network.client** and **network.server** — The app runs a local server and makes outbound API calls + +--- + +## Parent Project Changes + +### package.json scripts + +Add these scripts to the root `package.json`: + +```json +{ + "scripts": { + "build:server": "bun scripts/build-server.ts", + "download-bun": "bun scripts/download-bun.ts", + "electron:dev": "cd electron && npm run dev", + "electron:build": "bun run build:server && cd electron && npm run build", + "electron:pack": "bun run build && bun run build:server && cd electron && npm run pack", + "electron:dist": "bun run build && bun run build:server && cd electron && npm run dist" + } +} +``` + +### tsconfig.json excludes + +Exclude Electron and script directories from your web app's TypeScript config: + +```json +{ + "exclude": ["electron", "scripts", "resources"] +} +``` + +### .gitignore additions + +```gitignore +# Electron +electron/dist/ +electron/release/ +electron/node_modules/ +resources/bun/ +resources/server/ +``` + +The `resources/` directories contain large binaries and build artifacts that should not be committed. diff --git a/skills/electron-wrapper/references/web-adaptation.md b/skills/electron-wrapper/references/web-adaptation.md new file mode 100644 index 0000000..952a283 --- /dev/null +++ b/skills/electron-wrapper/references/web-adaptation.md @@ -0,0 +1,339 @@ +# Web App Adaptation + +Changes to the existing web app to support running inside Electron while remaining fully functional as a standalone web app. + +--- + +## 1. Electron Detection Utility + +Create a utility module for Electron detection. All checks are optional-chained so they're safe in browser environments. + +```typescript +// src/lib/electron.ts + +type ElectronPlatform = "darwin" | "win32" | "linux" | string; + +export function isElectron(): boolean { + return Boolean(window?.electronAPI?.isElectron); +} + +export function getElectronPlatform(): ElectronPlatform | null { + return window?.electronAPI?.platform ?? null; +} + +export function isMacElectron(): boolean { + return isElectron() && getElectronPlatform() === "darwin"; +} + +export function applyElectronDocumentAttributes(): void { + if (!isElectron()) return; + const platform = getElectronPlatform(); + const root = document.documentElement; + root.dataset.electron = "true"; + if (platform) { + root.dataset.platform = platform; + } +} +``` + +Call `applyElectronDocumentAttributes()` at app startup (e.g., in your entry file before React renders). This sets `data-electron="true"` and `data-platform="darwin"` on ``, enabling CSS targeting. + +--- + +## 2. Type Declarations + +Declare the `window.electronAPI` shape with all properties optional so TypeScript doesn't complain in browser environments: + +```typescript +// src/types/electron.d.ts + +export {}; + +declare global { + interface Window { + electronAPI?: { + isElectron?: boolean; + platform?: string; + version?: () => Promise; + update?: { + check?: () => Promise; + download?: () => Promise; + install?: () => Promise; + onAvailable?: ( + callback: (data: { version: string }) => void + ) => () => void; + onProgress?: ( + callback: (data: { percent: number }) => void + ) => () => void; + onDownloaded?: ( + callback: (data: { version: string }) => void + ) => () => void; + onError?: ( + callback: (data: { message: string }) => void + ) => () => void; + }; + }; + } +} +``` + +Every property is optional (`?`) so `window.electronAPI?.update?.download?.()` works safely in both contexts. + +--- + +## 3. CSS Drag Regions + +Electron's frameless/hidden-inset title bar requires explicit CSS regions for window dragging. + +### CSS utility classes + +If using Tailwind CSS 4, add `@utility` rules in your globals.css: + +```css +@utility drag { + -webkit-app-region: drag; +} + +@utility no-drag { + -webkit-app-region: no-drag; +} +``` + +For plain CSS or older Tailwind: + +```css +.app-window-drag { + -webkit-app-region: drag; +} + +.app-window-no-drag { + -webkit-app-region: no-drag; +} +``` + +### Applying drag regions in components + +Add the drag class to your app's header/toolbar areas when in Electron. Mark interactive children as no-drag: + +```tsx +
+
+ +
+ +
+``` + +### Traffic light clearance (macOS) + +**Prefer a taller header over left padding.** With `titleBarStyle: "hiddenInset"` and `trafficLightPosition: { x: 16, y: 12 }`, the traffic light buttons occupy roughly y=12 to y=24. Instead of adding `padding-left: 72px` to dodge them horizontally, make the header tall enough so content sits below them: + +```tsx +// Good — taller header, content below traffic lights +isMacElectron() ? "h-auto pb-3 pt-7" : "h-12" + +// Avoid — left padding wastes horizontal space in narrow windows +isMacElectron() ? "pl-[72px]" : "" +``` + +This approach works better for narrow/compact windows where horizontal space is at a premium. + +### Dialog backdrop dragging + +Make dialog backdrops draggable so users can still drag the window when a modal is open, but mark the dialog content as non-draggable: + +```tsx +// In your Dialog component + + + {children} + +``` + +--- + +## 4. Storage Path Adaptation + +Make your server's data directory configurable via environment variable so Electron can redirect storage to `userData`: + +```typescript +// In your server's storage module +const DATA_DIR = process.env.APP_DATA_DIR || process.cwd(); +const DATA_FILE = path.join(DATA_DIR, ".app-data.json"); +``` + +The Bun server process receives `APP_DATA_DIR` from Electron's main process (see main-process.md §bun-server). In web mode, it falls back to `process.cwd()`. + +Namespace the env var per project (e.g., `MY_APP_DATA_DIR`) to avoid conflicts. + +--- + +## 5. Static Asset Serving in Production + +In production mode, the Bun server needs to serve the built web assets (HTML, JS, CSS). The static directory path comes from an environment variable since the packaged app's file layout differs from development. + +```typescript +// In your server's request handler +const STATIC_DIR = process.env.APP_STATIC_DIR; + +// Serve static files from the build output +if (STATIC_DIR) { + const filePath = path.join(STATIC_DIR, url.pathname); + const resolved = path.resolve(filePath); + + // Security: prevent directory traversal + if (resolved.startsWith(path.resolve(STATIC_DIR))) { + const file = Bun.file(resolved); + if (await file.exists()) { + return new Response(file); + } + } +} +``` + +In development, the dev server handles this automatically with HMR. + +--- + +## 6. Auto-Update UI + +### useElectronUpdater hook + +A React hook that subscribes to update events from the preload bridge: + +```tsx +type UpdateStatus = "available" | "downloading" | "ready"; + +function useElectronUpdater(devOverride: UpdateStatus | null) { + const [status, setStatus] = useState(null); + const [version, setVersion] = useState(null); + const [progress, setProgress] = useState(0); + + useEffect(() => { + if (!isElectron()) return; + const api = window.electronAPI?.update; + if (!api) return; + + const unsubs: (() => void)[] = []; + + if (api.onAvailable) { + unsubs.push( + api.onAvailable((data) => { + setVersion(data.version); + setStatus("available"); + }) + ); + } + if (api.onProgress) { + unsubs.push( + api.onProgress((data) => { + setStatus("downloading"); + setProgress(Math.round(data.percent)); + }) + ); + } + if (api.onDownloaded) { + unsubs.push(api.onDownloaded(() => setStatus("ready"))); + } + if (api.onError) { + unsubs.push( + api.onError((data) => { + console.error("Auto-update error:", data.message); + setStatus(null); + }) + ); + } + + return () => unsubs.forEach((fn) => fn()); + }, []); + + // Allow dev tools to override the state for testing + const effective = devOverride ?? status; + if (!effective) return null; + + return { + status: effective, + version: devOverride ? "0.0.0-dev" : version, + progress: devOverride === "downloading" ? 42 : progress, + download: () => window.electronAPI?.update?.download?.(), + install: () => window.electronAPI?.update?.install?.(), + }; +} +``` + +### Update notification component + +A minimal pill-shaped notification that appears when an update is available: + +```tsx +function UpdatePill({ updater }: { updater: NonNullable> }) { + return ( +
+ {updater.status === "available" && ( + <> + v{updater.version} available + + + )} + {updater.status === "downloading" && ( + Downloading... {updater.progress}% + )} + {updater.status === "ready" && ( + <> + Update ready + + + )} +
+ ); +} +``` + +### DevTools override for testing + +Add a keyboard shortcut (e.g., Shift+U) that cycles through update states for testing the UI without a real update: + +```tsx +function DevTools() { + const [updateOverride, setUpdateOverride] = useState(null); + + useHotkeys("shift+u", () => { + setUpdateOverride((prev) => { + if (prev === null) return "available"; + if (prev === "available") return "downloading"; + if (prev === "downloading") return "ready"; + return null; + }); + }); + + // Pass updateOverride to useElectronUpdater +} +``` + +--- + +## 7. Environment-Based Feature Gating + +Use `isElectron()` to gate features that only make sense in one context: + +```typescript +// Demo mode doesn't apply in Electron (user has their own data) +export function resolveDemoMode(): boolean { + if (isElectron()) return false; + // ... web-specific demo logic +} + +// "Hosted environment" features (analytics, etc.) don't apply in Electron +export function isHostedEnvironment(): boolean { + if (isElectron()) return false; + // ... check for hosted domain +} +``` + +The general principle: Electron users have the app installed locally with their own data, so hosted/demo/marketing features should be disabled. diff --git a/skills/electron-wrapper/skill.md b/skills/electron-wrapper/skill.md new file mode 100644 index 0000000..e24c964 --- /dev/null +++ b/skills/electron-wrapper/skill.md @@ -0,0 +1,198 @@ +--- +name: electron-wrapper +description: > + Wrap a Bun web app into an Electron desktop app with native window management, + auto-updates, code signing, and CI/CD distribution. Use when the user wants to + create a native desktop application from an existing Bun-based web server, + package it for macOS/Windows, set up auto-updating, or handle Electron UX + concerns like drag regions and traffic lights. Also use when cutting releases + or tagging versions for Electron apps. +--- + +# Electron Wrapper for Bun Web Apps + +This skill guides wrapping an existing Bun web server into a native desktop app using Electron. It's based on a proven implementation that solved every major integration challenge. + +## Architecture + +**"Electron as chrome, Bun as server"** — Two runtimes working together: + +- **Electron/Node.js** handles window management, native menus, auto-updates, and IPC +- **Bun** runs the actual web server with all your application logic + +The Electron main process spawns a bundled Bun binary that runs your server, then loads `http://localhost:{port}` in a `BrowserWindow`. Your web app doesn't know or care that it's inside Electron — it's just a web page with an optional `window.electronAPI` bridge for native features. + +This architecture means: +- Zero changes to your server code (it's still a standard Bun HTTP server) +- The web app works identically in a browser or in Electron +- Electron handles only what browsers can't: window chrome, system tray, auto-updates, file system access +- Two separate `node_modules` — Electron uses npm, your web app uses Bun + +## Phase 1: Project Setup + +Create the Electron subproject alongside your existing Bun web app. + +**What to create:** +- `electron/` directory with its own `package.json` (npm, not Bun), two tsconfigs (ESM for main, CJS for preload), `electron-builder.yml`, and macOS entitlements +- `scripts/build-server.ts` for bundling the server +- `scripts/download-bun.ts` for downloading platform-specific Bun binaries +- Parent project changes: new scripts, tsconfig excludes, .gitignore entries + +**Reference:** [project-setup.md](references/project-setup.md) + +## Phase 2: Main Process + +Build the Electron main process — the entry point, server spawning, window management, auto-updater, and preload bridge. + +**Files to create:** + +| File | Purpose | +|------|---------| +| `electron/src/main/index.ts` | App lifecycle, dev/prod mode, single-instance lock, IPC handlers | +| `electron/src/main/bun-server.ts` | Spawn bundled Bun, port selection, health polling, env var injection | +| `electron/src/main/window.ts` | BrowserWindow config, bounds persistence, security settings, navigation guards | +| `electron/src/main/updater.ts` | electron-updater setup, event forwarding to renderer | +| `electron/src/preload/index.ts` | contextBridge API with invoke/on patterns and unsubscribe support | + +**Key decisions:** +- Dev mode uses `ELECTRON_DEV_URL` env var to connect to the external dev server (no internal Bun spawn) +- `autoDownload: false` — let users choose when to download updates +- Preload exposes only specific methods, never raw `ipcRenderer` +- Window persists bounds via `electron-store` + +**Reference:** [main-process.md](references/main-process.md) + +## Phase 3: Web App Adaptation + +Adapt the existing web app to detect and respond to the Electron environment while remaining fully functional as a standalone web app. + +**Changes to the web app:** + +| Change | Details | +|--------|---------| +| Electron detection utility | `isElectron()`, `getElectronPlatform()`, `isMacElectron()`, `applyElectronDocumentAttributes()` | +| Type declarations | `window.electronAPI` with all properties optional | +| CSS drag regions | `.app-window-drag`/`.app-window-no-drag` classes, auto-exclude interactive elements | +| Traffic light spacing | `--electron-traffic-left` CSS variable (72px on macOS, 0px elsewhere) | +| Storage path | Env var for data directory, falling back to CWD | +| Static asset serving | Env var for static dir in production mode | +| Auto-update hook | `useElectronUpdater()` React hook with download/install controls | +| Update notification | Pill component showing available → downloading → ready states | +| Feature gating | Disable demo mode, hosted features when in Electron | + +**Reference:** [web-adaptation.md](references/web-adaptation.md) + +## Phase 4: Build & Distribution + +Bundle everything, set up CI/CD, and handle code signing. + +**Build pipeline:** +1. Build web app (`bun run build`) +2. Bundle server to single file (`Bun.build()` → `resources/server/index.js`) +3. Download platform-specific Bun binaries → `resources/bun/{platform}-{arch}/` +4. Compile Electron TypeScript (two passes: main ESM + preload CJS) +5. electron-builder packages everything with `extraResources` + +**CI/CD:** +- GitHub Actions triggered by version tags (e.g., `v*`, `clippy-v*`) +- Matrix builds: macOS arm64/x64 on `macos-14`, Windows x64 on `windows-latest` +- `--publish never` in build step, separate publish job creates draft GitHub release +- Apple certificate import and notarization in CI + +**Code signing:** +- macOS: Developer ID Application certificate, exported as base64 .p12 +- Notarization via Apple ID + app-specific password +- 5 GitHub secrets required: `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_ID`, `APPLE_PASSWORD`, `APPLE_TEAM_ID` + +**Icons:** +- macOS: `sips` + `iconutil` from source PNG → `.icns` +- Windows: `png-to-ico` npm package → `.ico` + +**Reference:** [build-and-distribute.md](references/build-and-distribute.md) + +## Cutting a Release + +**Never build release artifacts locally.** CI has the signing certificates and notarization credentials. Local builds produce unsigned apps that macOS Gatekeeper will block. + +### Release workflow: + +1. **Bump version** in `electron/package.json`, commit, and merge to main +2. **Find the tag pattern** the CI workflow expects: + ```bash + grep -A2 'tags:' .github/workflows/*.yml + ``` +3. **Tag the merged commit on main:** + ```bash + git tag origin/main + git push origin + ``` +4. **Monitor CI:** + ```bash + gh run list --workflow=.yml --limit=1 + ``` +5. **Review and publish** the draft release on GitHub + +### Common mistakes: +- Running `electron-builder --publish always` locally — no notarization +- Using `gh release create` with local artifacts — unsigned +- Tagging before the version bump is merged — wrong version in build +- Tagging a feature branch instead of `origin/main` + +See [pitfalls.md §13](references/pitfalls.md) for full details. + +## Critical Pitfalls + +Quick-reference list — see [pitfalls.md](references/pitfalls.md) for full details with symptoms and code examples. + +| # | Pitfall | One-line fix | +|---|---------|-------------| +| 1 | ESM/CJS conflicts | `"type": "module"` + default import pattern for CJS packages | +| 2 | Preload must be CJS | Separate tsconfig with `"module": "CommonJS"` | +| 3 | `__dirname` unavailable | `fileURLToPath(import.meta.url)` polyfill | +| 4 | Dev mode MIME errors | Connect to external dev server via `ELECTRON_DEV_URL` | +| 5 | Bun version mismatch | Pin version in download script, match dev version | +| 6 | nvm PATH issues | `bash -lc` for spawned processes | +| 7 | Wrong storage path | Env var + `app.getPath("userData")` | +| 8 | White flash on open | `show: false` + `ready-to-show` + dark `backgroundColor` | +| 11 | `${platform}` != `process.platform` | Put Bun `extraResources` in `mac:`/`win:` sections with `darwin-`/`win32-` prefixes | +| 12 | Bun workspace hoists deps | Bundle main with esbuild + `createRequire` banner, or use npm for electron dir | +| 13 | Local builds aren't notarized | Always release via CI tags, never `electron-builder --publish` locally | + +## Dev Workflow + +The `electron:dev` command runs the full development environment: + +``` +npm run dev + ├── concurrently + │ ├── dev:web → cd .. && bun run dev (Bun dev server with HMR) + │ └── dev:electron + │ ├── wait-on http://localhost:3005 (wait for dev server) + │ ├── npm run build (compile TS) + │ └── ELECTRON_DEV_URL=... electron . (launch Electron) +``` + +- The web dev server runs with HMR — changes reflect instantly +- Electron connects to the dev server instead of spawning its own Bun +- Preload and main process changes require restarting `electron:dev` +- Web app changes hot-reload automatically + +To test production-like behavior locally: +```bash +bun run electron:pack # builds everything, packages without installer +# Output in electron/release/ +``` + +## Customization Checklist + +When adapting this for a new project, update these project-specific values: + +- [ ] App name in `electron-builder.yml` (`productName`, `appId`) +- [ ] Window title in `window.ts` +- [ ] Dev server port in `dev:electron` script and `dev` script +- [ ] Environment variable names (e.g., `APP_DATA_DIR`, `APP_STATIC_DIR`) +- [ ] `backgroundColor` in window config to match your app's theme +- [ ] `category` in `electron-builder.yml` mac section +- [ ] Repository URL in `electron/package.json` +- [ ] Bun version in `download-bun.ts` +- [ ] Icon assets in `electron/assets/` diff --git a/skills/favicon/SKILL.md b/skills/favicon/SKILL.md new file mode 100644 index 0000000..2126eae --- /dev/null +++ b/skills/favicon/SKILL.md @@ -0,0 +1,224 @@ +--- +name: favicon +description: Generate a complete set of favicons from a source image and update HTML. Use when setting up favicons for a web project. +argument-hint: [path to source image] +--- + +Generate a complete set of favicons from the source image at `$1` and update the project's HTML with the appropriate link tags. + +## Prerequisites + +First, verify ImageMagick v7+ is installed by running: +```bash +which magick +``` + +If not found, stop and instruct the user to install it: +- **macOS**: `brew install imagemagick` +- **Linux**: `sudo apt install imagemagick` + +## Step 1: Validate Source Image + +1. Verify the source image exists at the provided path: `$1` +2. Check the file extension is a supported format (PNG, JPG, JPEG, SVG, WEBP, GIF) +3. If the file doesn't exist or isn't a valid image format, report the error and stop + +Note whether the source is an SVG file - if so, it will also be copied as `favicon.svg`. + +## Step 2: Detect Project Type and Static Assets Directory + +Detect the project type and determine where static assets should be placed. Check in this order: + +| Framework | Detection | Static Assets Directory | +|-----------|-----------|------------------------| +| **Rails** | `config/routes.rb` exists | `public/` | +| **Next.js** | `next.config.*` exists | `public/` | +| **Gatsby** | `gatsby-config.*` exists | `static/` | +| **SvelteKit** | `svelte.config.*` exists | `static/` | +| **Astro** | `astro.config.*` exists | `public/` | +| **Hugo** | `hugo.toml` or `config.toml` with Hugo markers | `static/` | +| **Jekyll** | `_config.yml` with Jekyll markers | Root directory (same as `index.html`) | +| **Vite** | `vite.config.*` exists | `public/` | +| **Create React App** | `package.json` has `react-scripts` dependency | `public/` | +| **Vue CLI** | `vue.config.*` exists | `public/` | +| **Angular** | `angular.json` exists | `src/assets/` | +| **Eleventy** | `.eleventy.js` or `eleventy.config.*` exists | Check `_site` output or root | +| **Static HTML** | `index.html` in root | Same directory as `index.html` | + +**Important**: If existing favicon files are found (e.g., `favicon.ico`, `apple-touch-icon.png`), use their location as the target directory regardless of framework detection. + +Report the detected project type and the static assets directory that will be used. + +**When in doubt, ask**: If you are not 100% confident about where static assets should be placed (e.g., ambiguous project structure, multiple potential locations, unfamiliar framework), use `AskUserQuestionTool` to confirm the target directory before proceeding. It's better to ask than to put files in the wrong place. + +## Step 3: Determine App Name + +Find the app name from these sources (in priority order): + +1. **Existing `site.webmanifest`** - Check the detected static assets directory for an existing manifest and extract the `name` field +2. **`package.json`** - Extract the `name` field if it exists +3. **Rails `config/application.rb`** - Extract the module name (e.g., `module MyApp` → "MyApp") +4. **Directory name** - Use the current working directory name as fallback + +Convert the name to title case if needed (e.g., "my-app" → "My App"). + +## Step 4: Ensure Static Assets Directory Exists + +Check if the detected static assets directory exists. If not, create it. + +## Step 5: Generate Favicon Files + +Run these ImageMagick commands to generate all favicon files. Replace `[STATIC_DIR]` with the detected static assets directory from Step 2. + +### favicon.ico (multi-resolution: 16x16, 32x32, 48x48) +```bash +magick "$1" \ + \( -clone 0 -resize 16x16 \) \ + \( -clone 0 -resize 32x32 \) \ + \( -clone 0 -resize 48x48 \) \ + -delete 0 -alpha on -background none \ + [STATIC_DIR]/favicon.ico +``` + +### favicon-96x96.png +```bash +magick "$1" -resize 96x96 -background none -alpha on [STATIC_DIR]/favicon-96x96.png +``` + +### apple-touch-icon.png (180x180) +```bash +magick "$1" -resize 180x180 -background none -alpha on [STATIC_DIR]/apple-touch-icon.png +``` + +### web-app-manifest-192x192.png +```bash +magick "$1" -resize 192x192 -background none -alpha on [STATIC_DIR]/web-app-manifest-192x192.png +``` + +### web-app-manifest-512x512.png +```bash +magick "$1" -resize 512x512 -background none -alpha on [STATIC_DIR]/web-app-manifest-512x512.png +``` + +### favicon.svg (only if source is SVG) +If the source file has a `.svg` extension, copy it: +```bash +cp "$1" [STATIC_DIR]/favicon.svg +``` + +## Step 6: Create/Update site.webmanifest + +Create or update `[STATIC_DIR]/site.webmanifest` with this content (substitute the detected app name): + +```json +{ + "name": "[APP_NAME]", + "short_name": "[APP_NAME]", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} +``` + +If `site.webmanifest` already exists in the static directory, preserve the existing `theme_color`, `background_color`, and `display` values while updating the `name`, `short_name`, and `icons` array. + +## Step 7: Update HTML/Layout Files + +Based on the detected project type, update the appropriate file. Adjust the `href` paths based on where the static assets directory is relative to the web root: +- If static files are in `public/` or `static/` and served from root → use `/favicon.ico` +- If static files are in `src/assets/` → use `/assets/favicon.ico` +- If static files are in the same directory as HTML → use `./favicon.ico` or just `favicon.ico` + +### For Rails Projects + +Edit `app/views/layouts/application.html.erb`. Find the `` section and add/replace favicon-related tags with: + +```html + + + + + + +``` + +**Important**: +- If the source was NOT an SVG, omit the `` line +- Remove any existing `` section, after `` and `` if present + +### For Next.js Projects + +Edit the detected layout file (`app/layout.tsx` or `src/app/layout.tsx`). Update or add the `metadata` export to include icons configuration: + +```typescript +export const metadata: Metadata = { + // ... keep existing metadata fields + icons: { + icon: [ + { url: '/favicon.ico' }, + { url: '/favicon-96x96.png', sizes: '96x96', type: 'image/png' }, + { url: '/favicon.svg', type: 'image/svg+xml' }, + ], + shortcut: '/favicon.ico', + apple: '/apple-touch-icon.png', + }, + manifest: '/site.webmanifest', + appleWebApp: { + title: '[APP_NAME]', + }, +}; +``` + +**Important**: +- If the source was NOT an SVG, omit the `{ url: '/favicon.svg', type: 'image/svg+xml' }` entry from the icon array +- If metadata export doesn't exist, create it with just the icons-related fields +- If metadata export exists, merge the icons configuration with existing fields + +### For Static HTML Projects + +Edit the detected `index.html` file. Add the same HTML as Rails within the `` section. + +### If No Project Detected + +Skip HTML updates and inform the user they need to manually add the following to their HTML ``: + +```html + + + + + + +``` + +## Step 8: Summary + +Report completion with: +- Detected project type and framework +- Static assets directory used +- List of files generated +- App name used in manifest and HTML +- Layout file updated (or note if manual update is needed) +- Note if any existing files were overwritten + +## Error Handling + +- If ImageMagick is not installed, provide installation instructions and stop +- If the source image doesn't exist, report the exact path that was tried and stop +- If ImageMagick commands fail, report the specific error message +- If the layout file cannot be found for HTML updates, generate files anyway and instruct on manual HTML addition diff --git a/skills/find-skills/SKILL.md b/skills/find-skills/SKILL.md new file mode 100644 index 0000000..c797184 --- /dev/null +++ b/skills/find-skills/SKILL.md @@ -0,0 +1,133 @@ +--- +name: find-skills +description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill. +--- + +# Find Skills + +This skill helps you discover and install skills from the open agent skills ecosystem. + +## When to Use This Skill + +Use this skill when the user: + +- Asks "how do I do X" where X might be a common task with an existing skill +- Says "find a skill for X" or "is there a skill for X" +- Asks "can you do X" where X is a specialized capability +- Expresses interest in extending agent capabilities +- Wants to search for tools, templates, or workflows +- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.) + +## What is the Skills CLI? + +The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools. + +**Key commands:** + +- `npx skills find [query]` - Search for skills interactively or by keyword +- `npx skills add ` - Install a skill from GitHub or other sources +- `npx skills check` - Check for skill updates +- `npx skills update` - Update all installed skills + +**Browse skills at:** https://skills.sh/ + +## How to Help Users Find Skills + +### Step 1: Understand What They Need + +When a user asks for help with something, identify: + +1. The domain (e.g., React, testing, design, deployment) +2. The specific task (e.g., writing tests, creating animations, reviewing PRs) +3. Whether this is a common enough task that a skill likely exists + +### Step 2: Search for Skills + +Run the find command with a relevant query: + +```bash +npx skills find [query] +``` + +For example: + +- User asks "how do I make my React app faster?" → `npx skills find react performance` +- User asks "can you help me with PR reviews?" → `npx skills find pr review` +- User asks "I need to create a changelog" → `npx skills find changelog` + +The command will return results like: + +``` +Install with npx skills add + +vercel-labs/agent-skills@vercel-react-best-practices +└ https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices +``` + +### Step 3: Present Options to the User + +When you find relevant skills, present them to the user with: + +1. The skill name and what it does +2. The install command they can run +3. A link to learn more at skills.sh + +Example response: + +``` +I found a skill that might help! The "vercel-react-best-practices" skill provides +React and Next.js performance optimization guidelines from Vercel Engineering. + +To install it: +npx skills add vercel-labs/agent-skills@vercel-react-best-practices + +Learn more: https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices +``` + +### Step 4: Offer to Install + +If the user wants to proceed, you can install the skill for them: + +```bash +npx skills add -g -y +``` + +The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts. + +## Common Skill Categories + +When searching, consider these common categories: + +| Category | Example Queries | +| --------------- | ---------------------------------------- | +| Web Development | react, nextjs, typescript, css, tailwind | +| Testing | testing, jest, playwright, e2e | +| DevOps | deploy, docker, kubernetes, ci-cd | +| Documentation | docs, readme, changelog, api-docs | +| Code Quality | review, lint, refactor, best-practices | +| Design | ui, ux, design-system, accessibility | +| Productivity | workflow, automation, git | + +## Tips for Effective Searches + +1. **Use specific keywords**: "react testing" is better than just "testing" +2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd" +3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills` + +## When No Skills Are Found + +If no relevant skills exist: + +1. Acknowledge that no existing skill was found +2. Offer to help with the task directly using your general capabilities +3. Suggest the user could create their own skill with `npx skills init` + +Example: + +``` +I searched for skills related to "xyz" but didn't find any matches. +I can still help you with this task directly! Would you like me to proceed? + +If this is something you do often, you could create your own skill: +npx skills init my-xyz-skill +``` diff --git a/skills/fix-sentry-issues/SKILL.md b/skills/fix-sentry-issues/SKILL.md new file mode 100644 index 0000000..768d41d --- /dev/null +++ b/skills/fix-sentry-issues/SKILL.md @@ -0,0 +1,248 @@ +--- +name: fix-sentry-issues +description: Use Sentry MCP to discover, triage, and fix production issues with root-cause analysis. Use when asked to fix Sentry issues, triage production errors, investigate error spikes, or clean up Sentry noise. Requires Sentry MCP server. Triggers on "fix sentry", "triage errors", "production bugs", "sentry issues". +--- + +# Fix Sentry Issues + +Systematically discover, triage, investigate, and fix production issues using Sentry MCP. One PR per issue, root-cause analysis required. + +## Critical Rule: Truth-Seek, Don't Suppress + +**NEVER** treat log level changes as fixes. Changing `logger.error` to `logger.warn` or `logger.info` silences Sentry but doesn't fix the user's experience. + +For every failing code path, ask **"Why does this fail?"** — not **"How do I make Sentry quiet?"** + +### Anti-patterns to avoid + +These are specific failure modes from real experience. Do NOT do these: + +1. **Batch-classifying issues as "expected" without investigating each one.** Reading an error message and seeing a fallback path does NOT mean you understand the failure. You must trace the full input path to understand what's being sent and why it fails. + +2. **Treating "has a fallback" as "not a problem."** A fallback means the user gets degraded results. Ask: why does the primary path fail? Can we prevent the failure upstream? Is the input wrong? Is the timeout too tight? Is there a missing filter? + +3. **Combining multiple issues into one "noise reduction" PR.** Each issue has its own root cause. Investigate and fix them individually. The only exception is issues that share an identical root cause discovered through investigation. + +4. **Throwing away error details.** Never change `catch (error) { logger.error(..., error) }` to `catch { logger.info(...) }`. The structured error data (status codes, messages, stack traces) is exactly what you need to understand the failure. + +5. **Deciding the fix during triage.** The triage table should classify issues as "Investigate" or "Ignore" — never pre-decide that the fix is a log level change. You don't know the fix until you've completed investigation. + +### When a log level change IS valid + +A downgrade to `logger.info` is valid ONLY for genuinely expected operational states — NOT for failures with fallbacks. Examples: + +- **Valid:** User's Notion database doesn't have an optional "Author" column → property skipped. This is user configuration, not a failure. +- **Valid:** Supabase returns 404 for a link the user deleted. The resource genuinely doesn't exist. +- **Invalid:** Firecrawl scrape fails 300 times/day → downgrade to info. WHY is it failing? Are we sending URLs it can't handle? Are we hitting rate limits? +- **Invalid:** Summary generation times out → downgrade to info. WHY is the API slow? Is the content too large? Is there a network issue? + +## Phase 1: Discover + +Use Sentry MCP to find the org, project, and all unresolved issues. Use `ToolSearch` first to load the Sentry MCP tools. + +``` +mcp__sentry__find_organizations() +mcp__sentry__find_projects(organizationSlug, regionUrl) +mcp__sentry__search_issues( + organizationSlug, projectSlugOrId, regionUrl, + naturalLanguageQuery: "all unresolved issues sorted by events", + limit: 25 +) +``` + +Build a triage table. The Action column should be **Investigate** or **Ignore** — never a pre-decided fix: + +```markdown +| ID | Title | Events | Action | Reason | +|----|-------|--------|--------|--------| +| PROJ-A | Error in save | 14 | Investigate | User-facing save failure | +| PROJ-B | GM_register... | 3 | Ignore | Greasemonkey extension | +``` + +## Phase 2: Triage + +Classify every issue before writing any code. Only two categories at this stage: + +### Investigate (our code, worth understanding) +- Multiple events establishing a pattern +- User sees degraded experience (error status, missing data, broken UI) +- High-volume warnings that might indicate an upstream problem +- Recurring on every run/sync (stale references, cron-triggered) + +### Ignore (third-party noise) +- Browser extension code (`GM_registerMenuCommand`, `CONFIG`, `currentInset`, MetaMask JSON-RPC) +- Stale module imports after deploy (`ChunkLoadError` — self-resolving) +- Single-event transients with no reproduction path +- Issues already fixed by a recent commit + +Apply triage decisions: +``` +mcp__sentry__update_issue(issueId, organizationSlug, regionUrl, status: "ignored") // noise +mcp__sentry__update_issue(issueId, organizationSlug, regionUrl, status: "resolved") // already fixed +``` + +## Phase 3: Investigate (one issue at a time) + +For each "Investigate" issue, work through these steps **in order**. Do NOT skip steps or batch multiple issues together. + +### 3a. Pull event-level data + +Issue summaries hide the details you need. Always pull actual events AND the full issue details: + +``` +mcp__sentry__get_issue_details(issueId, organizationSlug, regionUrl) +mcp__sentry__search_issue_events( + issueId, organizationSlug, regionUrl, + naturalLanguageQuery: "all events with extra data", + limit: 15 +) +``` + +Extract from the events: actual URLs, request parameters, stack traces, timestamps, user context, extra data fields (status codes, content lengths, etc.). These are the real inputs that triggered the failure. + +### 3b. Cross-reference with Axiom logs + +Axiom events include `traceId` fields that correlate with Sentry errors. Use the Axiom CLI to pull surrounding logs for richer context: + +```bash +# Get the traceId from the Sentry event's trace context +# Then query Axiom for all events with that traceId +axiom query "['shiori-events'] | where traceId == ''" -f json + +# Or search by userId around the error timestamp for broader context +axiom query "['shiori-events'] | where userId == '' | where _time > datetime('2025-01-01T00:00:00Z') and _time < datetime('2025-01-01T01:00:00Z')" -f json +``` + +Axiom logs include fields like `authMethod`, `client_version`, `event` type, and request metadata that Sentry often lacks. This helps you understand what the user was doing before and after the error. + +### 3c. Read the failing code path + +Follow the stack trace. Read every file in the chain. Understand what the code does before proposing changes. Use subagents for parallel file exploration if the stack is deep. + +### 3d. Trace the input path upstream + +This is the step most often skipped, and the most important: + +- **What data reaches the failing function?** Trace backwards from the error to the original input. What URL/payload/parameters were passed? +- **Should this input have reached this code path at all?** Is there a missing filter, validation, or early return upstream? +- **What does the input look like?** For URL-based failures: is it a binary file? A redirect? A localhost URL? Something the API can't handle? +- **Is the failure in our code or an external service?** If external: can we prevent sending bad inputs? Can we add better pre-filtering? + +### 3e. Reproduce and verify + +Use the actual failing inputs from Sentry events: +- Call the function with the exact data that failed +- `fetch()` the actual URLs that timed out — are they reachable? +- Add temporary `console.log` statements to verify your understanding of the code flow +- Check if the failure is in our code or an external service + +### 3f. Identify root cause + +Ask these questions in order: + +1. **Why does this specific input fail?** (e.g., "Firecrawl can't scrape a .png URL") +2. **Why does this input reach this code path?** (e.g., "No extension check before calling Firecrawl") +3. **What's the right fix?** (e.g., "Filter binary URLs before calling Firecrawl" — not "suppress the log") +4. **Should we also improve observability?** (e.g., "Add status code to the log so we can see the failure distribution") + +Common root causes: + +| Pattern | Root Cause | Real Fix | +|---------|-----------|----------| +| External API fails on certain URLs | Wrong inputs being sent (binary files, bad formats) | Filter/validate inputs before sending | +| External API timeout | Timeout too tight, or input too large, or missing retry | Investigate what's slow, adjust timeout or input size | +| DB rejects "invalid json" | Unsanitized input (null bytes, control chars) | Sanitize before insert | +| Processing stuck in "error" | Timeout budget doesn't account for full pipeline | Adjust timeouts, save partial results on timeout | +| Same error on every cron run | Stale reference to deleted external resource | Detect staleness, auto-clean | +| Error logged but details not useful | Error object not included, or status code missing | Improve the log to include actionable details | + +### 3g. Know your log levels + +Log levels control what reaches Sentry: + +| Level | Sends to Sentry? | Use for | +|-------|-------------------|---------| +| `logger.error` | Yes (error) | Unexpected bugs, states that should never occur | +| `logger.warn` | Yes (warning) | Handled failures worth monitoring — keep until you understand the pattern | +| `logger.info` | No | Genuinely expected operational states (not "failures with fallbacks") | + +## Phase 4: Fix + +### 4a. Branch from main +```bash +git checkout main && git pull +git checkout -b fix/ +``` + +One branch per issue. Keep fixes focused. + +### 4b. Write tests first + +Tests must use data derived from actual Sentry events, not hypothetical inputs. The test should fail before the fix and pass after. + +### 4c. Implement the fix + +Fix the root cause, not the symptom. + +**Self-check before committing:** If the fix is primarily a log level change, STOP. Ask yourself: +- Did I investigate why this fails, or did I just see a fallback and suppress? +- Can I prevent the failure upstream instead of silencing it? +- Am I throwing away error details that would help debug future occurrences? +- Would a staff engineer look at this PR and say "but why does it fail in the first place?" + +### 4d. Verify + +- Run tests (e.g., `bun run test`) +- Run lint +- Confirm the fix handles the actual failing inputs from Sentry events +- Remove any temporary `console.log` statements + +### 4e. Create PR + +```bash +git push -u origin fix/ +gh pr create --title "" --body "$(cat <<'EOF' +## Summary +- **Root cause**: [What was actually wrong — the upstream reason, not just "it throws an error"] +- **Fix**: [What changed and why this prevents the failure, not just silences it] + +## Test plan +- [x] Tests written using data from Sentry events +- [x] All tests pass +- [x] Lint passes +EOF +)" +``` + +### 4f. Resolve in Sentry + +After PR is merged: +```bash +git checkout main && git pull +``` +``` +mcp__sentry__update_issue(issueId, organizationSlug, regionUrl, status: "resolved") +``` + +## Phase 5: Repeat + +Work through issues by priority (most events first). After each PR: +1. Return to main, pull latest +2. Pick next issue from the triage table +3. Start Phase 3 again — full investigation for each issue + +## Checklist Per Issue + +``` +[ ] Pulled event-level data (not just issue summary) +[ ] Cross-referenced with Axiom logs using traceId for surrounding context +[ ] Read the failing code path end-to-end +[ ] Traced the input path upstream — understood what data triggers the failure +[ ] Identified root cause (not just "it has a fallback") +[ ] Fix prevents the failure, not just suppresses the log +[ ] Tests use real-world data from Sentry events +[ ] Tests pass, lint passes +[ ] No error details thrown away (catch variables, status codes, etc.) +[ ] PR created with upstream root cause explanation +[ ] Sentry issue resolved after merge +``` diff --git a/skills/knip/SKILL.md b/skills/knip/SKILL.md new file mode 100644 index 0000000..7b77176 --- /dev/null +++ b/skills/knip/SKILL.md @@ -0,0 +1,145 @@ +--- +name: knip +description: Run knip to find and remove unused files, dependencies, and exports. Use for cleaning up dead code and unused dependencies. +--- + +# Knip Code Cleanup + +Run knip to find and remove unused files, dependencies, and exports from this codebase. + +## Setup + +1. Check if knip is available: + - Run `npx knip --version` to test + - If it fails or is very slow, check if `knip` is in package.json devDependencies + - If not installed locally, install with `npm install -D knip` (or pnpm/yarn/bun equivalent based on lockfile present) + +2. Knip does NOT remove unused imports/variables inside files — that's a linter's job. Knip finds unused files, dependencies, and exports across the project. + +## Workflow + +Always follow this configuration-first workflow. Even for simple "run knip" or "clean up codebase" prompts, configure knip properly before acting on reported issues. + +### Step 1: Understand the project + +- Check what frameworks and tools the project uses (look at package.json) +- Check if a knip config exists (`knip.json`, `knip.jsonc`, or `knip` key in package.json) +- If a config exists, review it for improvements (see Configuration Best Practices below) + +### Step 2: Run knip and read configuration hints first + +```bash +npx knip +``` + +Focus on **configuration hints** before anything else. These appear at the top of the output and suggest config adjustments to reduce false positives. + +### Step 3: Address hints by adjusting knip.json + +Fix configuration hints before addressing reported issues. Common adjustments: +- Enable/disable plugins for detected frameworks +- Add entry patterns for non-standard entry points +- Configure workspace settings for monorepos + +### Step 4: Repeat steps 2-3 + +Re-run knip after each config change. Repeat until configuration hints are resolved and false positives are minimized. + +### Step 5: Address actual issues + +Once the configuration is settled, work through reported issues. Prioritize in this order: + +1. **Unused files** — address these first ("inbox zero" approach removes the most noise) +2. **Unused dependencies** — remove from package.json +3. **Unused devDependencies** — remove from package.json +4. **Unused exports** — remove or mark as internal +5. **Unused types** — remove, or configure `ignoreExportsUsedInFile` (see below) + +### Step 6: Re-run and repeat + +Re-run knip after each batch of fixes. Removing unused files often exposes newly-unused exports and dependencies. + +## Configuration Best Practices + +When reviewing or creating a knip config, follow these rules: + +- **Never use `ignore` patterns** — `ignore` hides real issues and should almost never be used. Always prefer specific solutions. Other `ignore*` options (like `ignoreDependencies`, `ignoreExportsUsedInFile`) are fine because they target specific issue types. +- **Many unused exported types?** Add `ignoreExportsUsedInFile: { interface: true, type: true }` — this handles the common case of types only used in the same file. Prefer this over broader ignore options. +- **Remove redundant patterns** — Knip already respects `.gitignore`, so ignoring `node_modules`, `dist`, `build`, `.git` is redundant. +- **Remove entry patterns covered by defaults** — Auto-detected plugins already add standard entry points. Don't duplicate them. +- **Config files showing as unused** (e.g. `vite.config.ts`) — Enable or disable the corresponding plugin explicitly rather than ignoring the file. +- **Dependencies matching Node.js builtins** (e.g. `buffer`, `process`) — Add to `ignoreDependencies`. +- **Unresolved imports from path aliases** — Add `paths` to knip config (uses tsconfig.json semantics). + +## Production Mode + +Use `--production` to focus on production code only: + +```bash +npx knip --production +``` + +This excludes test files, config files, and other non-production entry points. Do NOT use `project` or `ignore` patterns to exclude test files — use `--production` instead. + +## Cleanup Confidence Levels + +### Auto-delete (high confidence): +- Unused exports that are clearly internal (not part of public API) +- Unused type exports +- Unused dependencies (remove from package.json) +- Unused files that are clearly orphaned (not entry points, not config files) + +### Ask first (needs clarification): +- Files that might be entry points or dynamically imported +- Exports that might be part of a public API (index.ts, lib exports) +- Dependencies that might be used via CLI or peer dependencies +- Anything in paths like `src/index`, `lib/`, or files with "public" or "api" in the name + +Use the AskUserQuestion tool to clarify before deleting these. + +## Auto-fix + +Once configuration is settled and you're confident in the results: + +```bash +# Auto-fix safe changes (removes unused exports and dependencies) +npx knip --fix + +# Auto-fix including file deletion +npx knip --fix --allow-remove-files +``` + +Only use `--fix` after the configuration-first workflow is complete. + +## Error Handling + +If knip exits with code 2 (unexpected error like "error loading file"): +- Check if a config file exists — if not, create `knip.json` in the project root +- Check for known issues at knip.dev +- Review the configuration reference for syntax/option errors +- Run knip again after fixes + +## Common Commands + +```bash +# Basic run +npx knip + +# Production only (excludes test/config entry points) +npx knip --production + +# Auto-fix what's safe +npx knip --fix + +# Auto-fix including file deletion +npx knip --fix --allow-remove-files + +# JSON output for parsing +npx knip --reporter json +``` + +## Notes + +- Watch for monorepo setups — may need `--workspace` flag +- Some frameworks need plugins enabled in config +- Knip does not handle unused imports/variables inside files — use ESLint or Biome for that diff --git a/skills/rams/SKILL.md b/skills/rams/SKILL.md new file mode 100644 index 0000000..0a564ea --- /dev/null +++ b/skills/rams/SKILL.md @@ -0,0 +1,105 @@ +--- +name: rams +description: Run accessibility and visual design review on components. Use when reviewing UI code for WCAG compliance and design issues. +--- + +# Rams Design Review + +You are Rams, an expert design engineer reviewing code for accessibility and visual design issues. + +## Mode + +If `$ARGUMENTS` is provided, analyze that specific file. +If `$ARGUMENTS` is empty, ask the user which file(s) to review, or offer to scan the project for component files. + +--- + +## 1. Accessibility Review (WCAG 2.1) + +### Critical (Must Fix) + +| Check | WCAG | What to look for | +|-------|------|------------------| +| Images without alt | 1.1.1 | `` without `alt` attribute | +| Icon-only buttons | 4.1.2 | `