diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 032205f..a80ab8c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,21 +1,29 @@ # Boxel CLI - Claude Code Integration +## GitHub Repository + +**Official repo:** https://github.com/cardstack/boxel-cli + +--- + ## How to Run Boxel Commands -**IMPORTANT:** In this development repo, the `boxel` CLI is not globally installed. Always run commands using: +After `npm install && npm run build`, use `npx boxel`: ```bash -npm run dev -- [args] +npx boxel sync . +npx boxel history ./workspace +npx boxel profile add ``` -Examples: +Or use `boxel` directly after `npm link`. + +**For development** (no rebuild needed after code changes): ```bash -npm run dev -- sync . # NOT: boxel sync . -npm run dev -- history ./workspace # NOT: boxel history ./workspace -npm run dev -- milestone ./workspace 1 -n "Name" +npm run dev -- ``` -The `--` separates npm arguments from the CLI arguments. All documentation below shows `boxel ` for brevity, but always use `npm run dev -- ` when executing. +All documentation below shows `boxel ` for brevity. --- @@ -43,67 +51,60 @@ The skill contains comprehensive Boxel development guidance including CardDef/Fi ## Onboarding Flow -When you detect a new user (no `.env` file or first interaction), guide them through setup: +When you detect a new user (no profile configured), guide them through setup: -### Step 1: Check Environment +### Step 1: Check Profile ```bash -# Check if .env exists and has content -cat .env 2>/dev/null || echo "NOT_CONFIGURED" +npx boxel profile ``` -### Step 2: Prompt for Environment Choice -If not configured, ask: +If no profile exists, run the interactive setup: +### Step 2: Add a Profile +```bash +npx boxel profile add ``` -Welcome to Boxel CLI! Let's get you set up. - -Which environment do you want to use? - -1. **Production** (app.boxel.ai) - Live Boxel -2. **Staging** (realms-staging.stack.cards) - Development/testing -You'll need your Boxel username and password (same as web login). +This launches an interactive wizard that: +1. Asks for environment (Production or Staging) +2. Asks for username and password +3. Creates the profile in `~/.boxel-cli/profiles.json` -Your username is your Boxel handle (e.g., `aallen90`, `ctse`). Find it in: -- **Account panel**: shown as `@username:stack.cards` -- **Workspace URLs**: `app.boxel.ai/username/workspace-name` +**Non-interactive option (CI/automation only):** +```bash +# Use environment variable to avoid exposing password in shell history +BOXEL_PASSWORD="password" npx boxel profile add -u @username:boxel.ai -n "My Prod Account" ``` -### Step 3: Create .env -Based on their choice: - -**Production:** -```env -MATRIX_URL=https://matrix.boxel.ai -MATRIX_USERNAME= -MATRIX_PASSWORD= -REALM_SERVER_URL=https://app.boxel.ai/ -``` +> **Security Note:** Avoid passing passwords via `-p` flag as they appear in shell history and process listings. Use the interactive wizard or `BOXEL_PASSWORD` environment variable. -**Staging:** -```env -MATRIX_URL=https://matrix-staging.stack.cards -MATRIX_USERNAME= -MATRIX_PASSWORD= -REALM_SERVER_URL=https://realms-staging.stack.cards/ +### Step 3: Verify & List Workspaces +```bash +npx boxel list ``` -### Step 4: Verify & List Workspaces +### Step 4: First Sync +Help them sync their first workspace: ```bash -npm install -npm run dev -- list +npx boxel sync @username/workspace ./workspace-name ``` -### Step 5: First Sync -Help them sync their first workspace: +### Switching Between Profiles ```bash -npm run dev -- sync @username/workspace ./workspace-name +npx boxel profile list # See all profiles (★ = active) +npx boxel profile switch username # Switch by partial match ``` --- ## Available Skills +### `/track` - Track Local Edits +Starts `boxel track` to auto-checkpoint local file changes: +- Creates checkpoints as you save files in IDE +- **IMPORTANT:** Track creates LOCAL checkpoints only +- **After editing, run `boxel sync . --prefer-local` to push to server** + ### `/watch` - Smart Watch Starts `boxel watch` with intelligent interval based on context: - **Active development** (5s interval, 3s debounce): When editing files @@ -119,7 +120,7 @@ Complete restore workflow: ### `/sync` - Smart Sync Context-aware bidirectional sync: -- After local edits → `--prefer-local` +- After local edits or track → `--prefer-local` - After server changes → `--prefer-remote` - After restore → `--prefer-local` (essential for syncing deletions) @@ -145,7 +146,17 @@ boxel sync . --delete # Sync deletions both ways boxel sync . --dry-run # Preview only ``` -### Watch +### Track ⇆ (Local File Watching) +```bash +boxel track . # Track local edits, auto-checkpoint as you save +boxel track . -d 5 -i 30 # 5s debounce, 30s min between checkpoints +boxel track . -q # Quiet mode +``` + +**Use track when:** Editing locally in IDE/VS Code. Creates checkpoints as you save files. +**Symbol:** ⇆ (horizontal arrows = local changes) + +### Watch ⇅ (Remote Server Watching) ```bash boxel watch # Watch all configured realms (from .boxel-workspaces.json) boxel watch . # Watch single workspace @@ -154,6 +165,14 @@ boxel watch . -i 5 -d 3 # Active: 5s interval, 3s debounce boxel watch . -q # Quiet mode ``` +**Use watch when:** Others are editing in Boxel web UI. Pulls their changes and creates checkpoints. +**Symbol:** ⇅ (vertical arrows = remote server changes) + +### Stop +```bash +boxel stop # Stop all running watch (⇅) and track (⇆) processes +``` + **Multi-realm watching:** Useful when code lives in one realm and data in another. Each realm gets its own checkpoint tracking and debouncing. ### Realms (Multi-Realm Configuration) @@ -175,6 +194,7 @@ boxel history . # View checkpoints boxel history . -r # Interactive restore boxel history . -r 3 # Quick restore to #3 boxel history . -r abc123 # Restore by hash +boxel history . -m "Message" # Create checkpoint with custom message ``` ### Skills @@ -186,6 +206,23 @@ boxel skills --disable "Name" # Disable a skill boxel skills --export ./project # Export as Claude commands ``` +### Profile (Authentication) +```bash +boxel profile # Show current active profile +boxel profile list # List all saved profiles (★ = active) +boxel profile add # Interactive wizard to add profile (recommended) +# Non-interactive: use BOXEL_PASSWORD env var instead of -p flag for security +boxel profile switch # Switch profile (partial match OK) +boxel profile remove # Remove a profile +boxel profile migrate # Migrate from old .env file +``` + +**Profile IDs:** Use Matrix format `@username:domain` +- Production: `@username:boxel.ai` +- Staging: `@username:stack.cards` + +**Storage:** Profiles stored in `~/.boxel-cli/profiles.json` (permissions: 0600) + ### Other ```bash boxel list # List workspaces @@ -237,7 +274,19 @@ boxel skills --export . # Re-export to .claude/commands/ ## Key Workflows -### Active Development Session +### Local Development with Track (IDE/Agent Editing) +```bash +boxel track . # Start tracking local edits (auto-checkpoints) +# ... edit files in IDE or with Claude ... +# Track creates LOCAL checkpoints as you save + +# IMPORTANT: When ready to push changes to Boxel server: +boxel sync . --prefer-local # Push your local changes to server +``` + +**Remember:** Track does NOT sync to server automatically - it only creates local checkpoints. Always run `sync --prefer-local` when you want your changes live on the server. + +### Active Development Session (Watching Server) ```bash /watch # Starts with 5s interval # ... edit in Boxel UI or locally ... @@ -373,8 +422,8 @@ Watch waits for changes to settle: ### 4. Checkpoint Classification - `[MAJOR]` - New files, deleted files, .gts changes, >3 files - `[minor]` - Small updates to existing .json files -- `LOCAL` (↑) - Changes you pushed -- `SERVER` (↓) - External changes from web UI +- `LOCAL` ⇆ - Changes from local edits (track command) +- `SERVER` ⇅ - External changes from web UI (watch command) --- @@ -403,6 +452,65 @@ Commands accept: --- +## Understanding Boxel URLs (Card IDs) + +When a user shares a URL like: +``` +https://app.boxel.ai/tribecaprep/employee-handbook/Document/d8341312-f3a0-442b-a2e5-49c5cdd84695 +``` + +**This is a Card ID, not a fetchable URL!** + +### How to Parse Boxel URLs + +| URL Part | Meaning | +|----------|---------| +| `app.boxel.ai` | Production server | +| `tribecaprep` | User/organization | +| `employee-handbook` | Realm/workspace name | +| `Document/d8341312-...` | Card type and instance path | + +### NEVER Use WebFetch on Boxel URLs + +- Boxel realms are **usually private** and require Matrix authentication +- WebFetch will fail with 401/403 errors +- The user is referencing content **they expect you to have locally** + +### Finding the Local Copy + +If the user references a Boxel URL, the file is likely already synced to the local workspace: + +1. **Parse the path**: `Document/d8341312-f3a0-442b-a2e5-49c5cdd84695` → local path is `Document/d8341312-f3a0-442b-a2e5-49c5cdd84695.json` + +2. **Search the workspace**: +```bash +# Find by card ID +find . -name "d8341312-f3a0-442b-a2e5-49c5cdd84695*" + +# Or search for the card type folder +ls ./Document/ +``` + +3. **Read the local file** using the Read tool + +### Example Workflow + +User says: "Check the handbook at https://app.boxel.ai/tribecaprep/employee-handbook/Document/abc123" + +**Do this:** +``` +# Look for local file +Read ./Document/abc123.json +``` + +**NOT this:** +``` +# This will FAIL - private realm +WebFetch https://app.boxel.ai/tribecaprep/employee-handbook/Document/abc123 +``` + +--- + ## API Reference | Endpoint | Method | Purpose | @@ -433,13 +541,15 @@ Headers: ## Troubleshooting ### "Authentication failed" -- Check credentials in `.env` -- Verify you can log into Boxel web -- For staging: use `matrix-staging.stack.cards` +- Check active profile: `boxel profile` +- Verify credentials: `boxel profile list` +- Verify you can log into Boxel web with same credentials +- For staging: ensure profile uses `@username:stack.cards` ### "No workspace found" - Run `boxel list` to see workspaces - Use full URL for first sync +- Ensure correct profile is active for the environment ### Files keep reverting after restore - Stop watch before restoring @@ -448,4 +558,8 @@ Headers: ### Watch not detecting changes - Check interval setting - Verify server URL -- Check JWT auth +- Check active profile: `boxel profile` + +### Switching environments (prod/staging) +- Add profiles for each environment +- Switch with: `boxel profile switch ` diff --git a/.claude/commands/setup.md b/.claude/commands/setup.md index f84cb12..3d01035 100644 --- a/.claude/commands/setup.md +++ b/.claude/commands/setup.md @@ -5,70 +5,91 @@ Guide new users through Boxel CLI setup. ## Trigger Run this automatically when: - User first opens the repo -- No `.env` file exists +- No profile is configured (`npx boxel profile` shows nothing) - User asks about setup or getting started ## Flow ### 1. Check Current State ```bash -cat .env 2>/dev/null || echo "NOT_CONFIGURED" -npm list 2>/dev/null || echo "NOT_INSTALLED" +npx boxel profile ``` -### 2. Ask Environment Choice -Present options: +If no profile exists, proceed with setup. -**Production (app.boxel.ai)** -- For live Boxel usage -- Your real workspaces +### 2. Add a Profile -**Staging (realms-staging.stack.cards)** -- For development/testing -- Experimental features +**Option A: Interactive (recommended)** +```bash +npx boxel profile add +``` + +This wizard will: +1. Ask for environment (Production or Staging) +2. Ask for username and password +3. Create the profile automatically -### 3. Collect Credentials -Ask for: +**Option B: Non-interactive (CI/automation)** + +Ask the user for: +- **Environment**: Production (app.boxel.ai) or Staging (realms-staging.stack.cards) - **Username**: Their Boxel handle (e.g., `aallen90`, `ctse`). Found in Account panel as `@username:stack.cards` or in workspace URLs like `app.boxel.ai/username/workspace-name` - **Password**: Same as Boxel web login -### 4. Create .env File +Then run (using environment variable for security): **Production:** -```env -MATRIX_URL=https://matrix.boxel.ai -MATRIX_USERNAME= -MATRIX_PASSWORD= -REALM_SERVER_URL=https://app.boxel.ai/ +```bash +BOXEL_PASSWORD="password" npx boxel profile add -u @username:boxel.ai -n "Production" ``` **Staging:** -```env -MATRIX_URL=https://matrix-staging.stack.cards -MATRIX_USERNAME= -MATRIX_PASSWORD= -REALM_SERVER_URL=https://realms-staging.stack.cards/ +```bash +BOXEL_PASSWORD="password" npx boxel profile add -u @username:stack.cards -n "Staging" ``` -### 5. Install & Verify +> **Security Note:** Avoid passing passwords via `-p` flag as they appear in shell history. + +### 3. Verify ```bash -npm install -npm run dev -- list +npx boxel list ``` -### 6. First Sync +### 4. First Sync Help them sync a workspace: ```bash -npm run dev -- sync @username/workspace +npx boxel sync @username/workspace ./workspace-name +``` + +## Profile Management + +**List profiles:** +```bash +npx boxel profile list +``` + +**Switch profile:** +```bash +npx boxel profile switch +``` + +**Migrate from old .env:** +```bash +npx boxel profile migrate ``` ## Success Message ``` Setup complete! You can now: -- `boxel list` - See your workspaces -- `boxel sync @username/workspace` - Sync a workspace -- `boxel watch .` - Monitor for changes -- `boxel history .` - View/restore checkpoints +- `npx boxel list` - See your workspaces +- `npx boxel sync @username/workspace` - Sync a workspace +- `npx boxel watch .` - Monitor for changes +- `npx boxel history .` - View/restore checkpoints + +Profile management: +- `npx boxel profile` - Show active profile +- `npx boxel profile list` - List all profiles +- `npx boxel profile switch ` - Switch profiles For AI-assisted development, try: - `/watch` - Smart watch with auto intervals diff --git a/.claude/commands/track.md b/.claude/commands/track.md new file mode 100644 index 0000000..52bdca5 --- /dev/null +++ b/.claude/commands/track.md @@ -0,0 +1,84 @@ +# Track Skill + +Start `boxel track` to monitor local file changes and create checkpoints automatically. + +## When to Use Track + +Use **track** when you're editing files locally (in IDE, with AI agent, etc.) and want automatic backups: +- Working in VS Code, Cursor, or other IDE +- AI agent is editing files +- You want checkpoint history of your work + +**Track vs Watch:** +| Command | Symbol | Direction | Purpose | +|---------|--------|-----------|---------| +| `track` | ⇆ | Local edits → Checkpoints | Backup your work as you edit | +| `watch` | ⇅ | Server → Local | Pull external changes from Boxel UI | + +## Commands + +```bash +# Start tracking (default: 3s debounce, 10s min interval) +boxel track . + +# Custom timing (5s debounce, 30s between checkpoints) +boxel track . -d 5 -i 30 + +# Quiet mode (only show checkpoints) +boxel track . -q + +# Stop all track/watch processes +boxel stop +``` + +## The Track → Sync Workflow + +**IMPORTANT:** Track only creates local checkpoints. To push changes to the Boxel server: + +```bash +# 1. Track creates checkpoints as you edit +boxel track . + +# 2. When ready to push to server, sync with --prefer-local +boxel sync . --prefer-local +``` + +Track does NOT automatically sync to the server. This is intentional - it lets you: +- Work offline with local backups +- Batch multiple edits before pushing +- Review changes before they go live + +## Context Detection + +When invoked, consider: + +### Standard Development (3s debounce, 10s interval) +- Normal editing workflow +- Balanced between checkpoint frequency and overhead + +### Fast Iteration (2s debounce, 5s interval) +- Rapid prototyping +- User says "track closely" or "capture everything" + +### Background Tracking (5s debounce, 30s interval) +- Long editing sessions +- User says "just backup" or "light tracking" + +## Response Format + +When invoked: +1. Confirm workspace directory +2. Start track with appropriate settings +3. **Remind user to sync when ready to push changes** + +Example: +``` +Starting track in the current workspace (3s debounce, 10s interval). +Checkpoints will be created automatically as you save files. + +Remember: Track creates LOCAL checkpoints only. +When ready to push changes to Boxel server: + boxel sync . --prefer-local + +Use Ctrl+C to stop tracking, or `boxel stop` from another terminal. +``` diff --git a/README.md b/README.md index 188f542..e6f6dc7 100644 --- a/README.md +++ b/README.md @@ -1,209 +1,406 @@ # Boxel CLI -Bidirectional sync between your local editor and [Boxel](https://boxel.ai) workspaces. +**Bidirectional sync between your local command line agent and [Boxel](https://boxel.ai) workspaces.** -**Edit Boxel cards locally with your favorite IDE, sync changes instantly, and collaborate with the web UI.** +Edit Boxel cards locally with your IDE or AI agent, sync changes instantly, and collaborate seamlessly between web UI and local development. -## Quick Start +> **Note:** Boxel CLI is developed and tested with [Claude Code](https://claude.ai/code). For the best experience, install Claude Code first and let it guide you through setup. + +--- + +## Installation ```bash -# Clone and install git clone https://github.com/cardstack/boxel-cli.git cd boxel-cli -npm install +npm install && npm run build +``` + +Now you can use `npx boxel` (or `boxel` after `npm link`): + +```bash +npx boxel profile add # Set up your account +npx boxel list # List your workspaces +npx boxel sync @user/workspace . # Sync a workspace locally +``` -# Set up your environment -cp .env.example .env -# Edit .env with your Boxel credentials +### With Claude Code (Recommended) -# List your workspaces -npm run dev -- list +If you have [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed, just start it in the repo: -# Sync a workspace -npm run dev -- sync @username/workspace +```bash +claude ``` -## Setup +Claude will detect the Boxel CLI project and guide you through setup automatically. + +### Global Command (Optional) -### 1. Get Your Credentials +To use `boxel` directly without `npx`: -You need a Boxel account. Sign up at [boxel.ai](https://boxel.ai) if you haven't already. +```bash +npm link +boxel profile add +boxel list +boxel sync . +``` -Your **username** is your Boxel handle (e.g., `aallen90`, `ctse`). You can find it: -- In your **Account panel**: shown as `@username:stack.cards` -- In your **workspace URLs**: `app.boxel.ai/username/workspace-name` +--- -### 2. Configure Environment +## Features -Copy `.env.example` to `.env` and fill in your credentials: +- **Bidirectional Sync** - Push local changes, pull remote updates, resolve conflicts +- **Track Mode** - Auto-checkpoint local edits as you type in your IDE +- **Watch Mode** - Auto-pull server changes with configurable polling +- **Checkpoint History** - Git-based undo/restore with automatic snapshots +- **Multi-Realm Support** - Manage multiple workspaces (code + data separation) +- **GitHub Integration** - Share/gather for team collaboration via PRs +- **Claude Code Integration** - AI-assisted development with Boxel skills +- **Edit Locking** - Prevent overwrites while editing locally +- **Profile Management** - Switch between production and staging environments + +## Architecture: Two Git Models + +Boxel CLI uses git in **two fundamentally different ways**: + +1. **Checkpoint System** (`.boxel-history/`) - Local-only git for undo/restore. Single-user, no remotes, continuous backup. Combined with server sync, can also act as a checkpoint / undo system for hosted Boxel usage. +2. **GitHub Collaboration** (`share`/`gather`) - Traditional branch/merge/PR workflow for team collaboration. + +This separation is intentional: **Boxel Server is the source of truth** for your realm, not git. + +```mermaid +flowchart TB + subgraph cloud["☁️ Cloud Services"] + boxel["🟣 Boxel Server
(Source of Truth)"] + github["🐙 GitHub Repo
(Team Collaboration)"] + end + + subgraph local["💻 Local Machine"] + workspace["📁 Working Folder
/my-workspace"] + history["📍 .boxel-history/
(Local Git)"] + end + + %% Sync flow + boxel <-->|"sync
push/pull"| workspace + boxel -->|"watch
(pull server changes)"| workspace + + %% Track flow (local file watching) + workspace -->|"track
(monitor local edits)"| history + history -->|"restore
history -r"| workspace + + %% GitHub flow + workspace -->|"share
(export)"| github + github -->|"gather
(import)"| workspace + + %% Styling + style boxel fill:#7c3aed,color:#fff + style github fill:#24292e,color:#fff + style workspace fill:#3b82f6,color:#fff + style history fill:#6b7280,color:#fff +``` +### Why Two Git Models? + +| Aspect | `.boxel-history/` (Checkpoints) | GitHub (Collaboration) | +|--------|--------------------------------|------------------------| +| **Purpose** | Undo/restore safety net | Team code review & merge | +| **Scope** | Single user | Multiple collaborators | +| **Remote** | None (local only) | GitHub origin | +| **Workflow** | Automatic on sync/watch | Manual share/gather | +| **Branches** | Single linear history | Feature branches + PRs | +| **Source of truth** | Boxel Server | Boxel Server (via gather→sync) | + +### The Flow + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ INDIVIDUAL WORKFLOW (You + Boxel) │ +│ │ +│ Boxel Web UI ◄───────► Boxel Server ◄────────► Local IDE │ +│ edit sync/push/pull edit │ +│ │ │ │ +│ watch ◄──┘ │ │ +│ (pull server changes) │ │ +│ │ │ +│ track │ │ +│ (local edits)│ │ +│ ▼ │ +│ .boxel-history/ │ +│ (checkpoint/restore) │ +└─────────────────────────────────────────────────────────────────┘ + │ + share ↓ ↑ gather + │ +┌─────────────────────────────────────────────────────────────────┐ +│ TEAM WORKFLOW (Collaboration via GitHub) │ +│ │ +│ Developer A ──► branch ──► PR ──► review ──► merge │ +│ Developer B ──► gather ◄────────────────────┘ │ +│ │ │ +│ ▼ │ +│ sync --prefer-local │ +│ │ │ +│ ▼ │ +│ Boxel Server │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Key insight:** Git is NOT the source of truth. Boxel Server is. Git serves two separate roles: +- **Locally:** Time machine for your work (checkpoints) +- **GitHub:** Collaboration layer for team publishing + +--- + +## Authentication + +### Option 1: Profile Manager (Recommended) ```bash -cp .env.example .env +boxel profile add # Interactive setup (recommended) +boxel profile list # Show profiles (★ = active) +boxel profile switch username # Switch profile + +# Non-interactive (CI/automation only - avoid in shell history) +BOXEL_PASSWORD="pass" boxel profile add -u @user:boxel.ai -n "Prod" ``` -**For Production (app.boxel.ai):** +> **Security Note:** Avoid passing passwords directly via `-p` flag as they may be exposed in shell history and process listings. Use the interactive wizard or environment variables for credentials. + +Profiles stored in `~/.boxel-cli/profiles.json` + +### Option 2: Environment Variables ```env +# Production (app.boxel.ai) MATRIX_URL=https://matrix.boxel.ai MATRIX_USERNAME=your-username MATRIX_PASSWORD=your-password -REALM_SERVER_URL=https://app.boxel.ai/ -``` -**For Staging (realms-staging.stack.cards):** -```env +# Staging (realms-staging.stack.cards) MATRIX_URL=https://matrix-staging.stack.cards MATRIX_USERNAME=your-username MATRIX_PASSWORD=your-password -REALM_SERVER_URL=https://realms-staging.stack.cards/ ``` -### 3. Verify Setup +--- + +## Commands Reference + +### Sync Operations ```bash -npm run dev -- list +boxel sync . # Bidirectional sync (interactive) +boxel sync . --prefer-local # Keep local on conflicts, sync deletions to server +boxel sync . --prefer-remote # Keep remote on conflicts +boxel sync . --prefer-newest # Keep newest by timestamp +boxel sync . --delete # Sync deletions both ways +boxel sync . --dry-run # Preview only + +boxel push ./local # One-way push (local → remote) +boxel pull ./local # One-way pull (remote → local) ``` -You should see your workspaces listed. - -## Commands +### Track & Watch -### Check Status ```bash -boxel status . # Current workspace status -boxel status --all # All your workspaces -boxel status . --pull # Pull remote changes +# Track LOCAL file changes (checkpoint as you edit in IDE) +boxel track . # Track local edits, auto-checkpoint +boxel track . -d 5 -i 30 # 5s debounce, 30s min between checkpoints +boxel track . -q # Quiet mode + +# Watch REMOTE server changes (pull external updates) +boxel watch . # Watch single workspace (30s default) +boxel watch . ./other-realm # Watch multiple realms +boxel watch # Watch all configured realms +boxel watch . -i 5 -d 3 # 5s interval, 3s debounce +boxel watch . -q # Quiet mode + +# Stop all watchers and trackers +boxel stop # Stops all running watch (⇅) and track (⇆) processes + +boxel status . # Show sync status +boxel status --all # All workspaces +boxel status . --pull # Auto-pull changes ``` -### Sync Changes +**Track vs Watch:** +| Command | Symbol | Direction | Purpose | +|---------|--------|-----------|---------| +| `track` | ⇆ | Local → Checkpoints | Backup your IDE edits as you type | +| `watch` | ⇅ | Server → Local | Pull external changes from Boxel web UI | + +### History & Checkpoints + ```bash -boxel sync . # Bidirectional sync -boxel sync . --prefer-local # Keep local on conflicts -boxel sync . --prefer-remote # Keep remote on conflicts +boxel history . # View checkpoint history +boxel history . -r # Interactive restore +boxel history . -r 3 # Quick restore to #3 +boxel history . -r abc123 # Restore by hash +boxel history . -m "Message" # Create checkpoint with message + +boxel milestone . 1 -n "v1.0" # Mark checkpoint as milestone +boxel milestone . --list # Show milestones ``` -### Watch for Changes +### File Management + ```bash -boxel watch . # Monitor server, auto-checkpoint -boxel watch . -i 5 # Check every 5 seconds -boxel watch # Watch all configured realms -boxel watch . ./other-realm # Watch multiple realms simultaneously +boxel edit . file.gts # Lock file before editing +boxel edit . --list # Show locked files +boxel edit . --done file.gts # Release lock +boxel edit . --clear # Clear all locks + +boxel touch . # Force re-index all files +boxel touch . Card/instance.json # Touch specific file + +boxel check ./file.json # Inspect file sync state +boxel check ./file.json --sync # Auto-sync if needed ``` -### Multi-Realm Configuration -When working with multiple realms (e.g., code in one, data in another): +### Workspace Management ```bash -boxel realms # List configured realms -boxel realms --init # Create .boxel-workspaces.json -boxel realms --add ./code --purpose "Card definitions" --patterns "*.gts" --default -boxel realms --add ./data --purpose "Content instances" --card-types "BlogPost,Product" -boxel realms --llm # Output guidance for file placement +boxel list # List your workspaces +boxel create my-app "My App" # Create new workspace ``` -Then `boxel watch` monitors all configured realms with independent checkpointing. +### Multi-Realm Configuration -### View & Restore History ```bash -boxel history . # View checkpoints -boxel history . -r 3 # Restore to checkpoint #3 -boxel milestone . 1 -n "Before refactor" # Mark important checkpoint +boxel realms --init # Create .boxel-workspaces.json +boxel realms # Show configuration +boxel realms --add ./code --purpose "Definitions" --patterns "*.gts" --default +boxel realms --add ./data --purpose "Content" --card-types "Post,Product" +boxel realms --llm # Output file placement guidance +boxel realms --remove ./code # Remove realm ``` -### Share & Gather (GitHub Workflow) +### GitHub Workflows + ```bash -boxel share . -t /path/to/repo -b branch-name # Share to GitHub repo -boxel gather . -s /path/to/repo # Pull from GitHub repo +boxel share . -t /path/to/repo -b branch-name # Export to GitHub +boxel share . -t /repo -b branch --no-pr # No auto-PR + +boxel gather . -s /path/to/repo # Import from GitHub +boxel gather . -s /repo --branch feature # From specific branch ``` -### Manage Skills +### Skills (AI Instructions) + ```bash -boxel skills --refresh # Fetch skills from Boxel -boxel skills --list # List available skills -boxel skills --enable "Boxel Development" # Enable a skill -boxel skills --disable "Boxel Development" # Disable a skill -boxel skills --export . # Export enabled skills as Claude commands +boxel skills --refresh # Fetch skills from Boxel +boxel skills --list # List available +boxel skills --enable "Name" # Enable skill +boxel skills --export . # Export to .claude/commands/ ``` -### Other Commands +--- + +## Key Workflows + +### Active Development (with auto-backup) ```bash -boxel list # List workspaces -boxel create my-app "My App" # Create workspace -boxel pull ./local # One-way pull -boxel push ./local # One-way push +boxel track . # Start tracking local edits +# In another terminal or IDE, edit files... +# Checkpoints created automatically as you save + +# IMPORTANT: Track creates LOCAL checkpoints only! +# When ready to push changes to Boxel server: +boxel sync . --prefer-local # Push changes to server ``` -## Typical Workflow +**Remember:** `track` does NOT sync to server - it only creates local checkpoints for safety. Always run `sync --prefer-local` when you want your changes live. -### 1. Clone a Workspace +### Active Development (with edit lock) ```bash -# First time: sync with explicit URL -boxel sync ./my-workspace https://app.boxel.ai/username/my-workspace/ +boxel edit . my-card.gts # Lock file (if watch is running) +# ... edit locally ... +boxel sync . --prefer-local # Push changes +boxel touch . Card/instance.json # Force re-index +boxel edit . --done my-card.gts # Release lock ``` -### 2. Edit Locally -Open the workspace in your IDE. Edit `.gts` (card definitions) or `.json` (instances). - -### 3. Sync Changes +### Undo Server Changes (Restore) ```bash -boxel sync . +# STOP watch first (Ctrl+C) +boxel history . # Find checkpoint +boxel history . -r 3 # Restore to #3 +boxel sync . --prefer-local # CRITICAL: sync deletions to server ``` -### 4. Monitor Server Changes +### Monitor Server While Working ```bash -boxel watch . -# Now any changes made in Boxel web UI are auto-pulled +boxel watch . -i 30 -d 10 # 30s poll, 10s debounce +# Checkpoints created automatically +boxel history . # Review what changed ``` -### 5. Restore if Needed +### Collaborative with GitHub ```bash -boxel history . # See what changed -boxel history . -r 2 # Restore to checkpoint #2 -boxel sync . --prefer-local # Push restoration to server +# Share to GitHub +boxel share . -t /path/to/repo -b feature/my-work --no-pr +# Push via GitHub Desktop + +# Teammate gathers +boxel gather . -s /path/to/repo --branch feature/my-work +boxel sync . --prefer-local # Push to Boxel server ``` -## Boxel Skills +--- -Boxel Skills are AI instruction cards from Boxel that guide Claude in specific tasks. The **Boxel Development** skill is enabled by default for vibe coding. +## Critical Patterns -### Managing Skills +### 1. Always Use `--prefer-local` After Restore ```bash -boxel skills --refresh # Fetch latest skills from Boxel -boxel skills --list # See available skills -boxel skills --enable "X" # Enable a skill -boxel skills --export . # Export to .claude/commands/ +boxel history . -r 3 # Deletes files locally +boxel sync . --prefer-local # Syncs deletions to server ``` +Without this, deleted files won't be removed from server. -Skills become Claude slash commands (e.g., `/boxel-development`). - -## Using with Claude Code +### 2. Stop Watch Before Restore +Watch will re-pull deleted files if running during restore. -This repo includes Claude Code integration for AI-assisted development. +### 3. Lock Files When Editing Locally +```bash +boxel edit . file.gts # Lock before editing +# ... edit ... +boxel sync . --prefer-local +boxel edit . --done file.gts # Release after sync +``` -### Available Skills -- `/watch` - Start smart watch with auto-detected interval -- `/restore` - Complete restore workflow -- `/sync` - Context-aware sync +### 4. Touch Instances After Definition Changes +```bash +# After updating card-def.gts remotely +boxel touch . CardDef/instance.json # Force re-index +``` -### Onboarding -When you open this repo in Claude Code, it will: -1. Detect if `.env` is configured -2. Prompt you to choose staging or production -3. Guide you through your first sync +### 5. Write Source Code, Never Compiled Output +When editing `.gts` files, write clean source code: +```gts +export class MyCard extends CardDef { + static fitted = class Fitted extends Component { + + }; +} +``` +**NEVER** write compiled JSON blocks or base64-encoded imports. -See `.claude/CLAUDE.md` for full documentation. +--- ## File Structure ``` workspace/ -├── .boxel-sync.json # Sync manifest (auto-generated) -├── .boxel-history/ # Checkpoint history (git-based) -├── .realm.json # Workspace config -├── index.json # Workspace index -├── blog-post.gts # Card definition (kebab-case) -└── BlogPost/ # Instance directory (PascalCase) - ├── my-first-post.json - └── another-post.json +├── .boxel-sync.json # Sync manifest (auto-generated) +├── .boxel-history/ # Checkpoint history (git-based) +├── .boxel-edit.json # Edit locks (auto-generated) +├── .boxel-workspaces.json # Multi-realm config (optional) +├── .realm.json # Workspace config +├── index.json # Workspace index +├── blog-post.gts # Card definition (kebab-case) +└── BlogPost/ # Instance directory (PascalCase) + └── my-post.json # Instance file ``` ### Naming Conventions @@ -219,12 +416,12 @@ workspace/ The `adoptsFrom.module` path is **relative to the JSON file**: ```json -// In BlogPost/my-first-post.json: +// In BlogPost/my-post.json: { "data": { "meta": { "adoptsFrom": { - "module": "../blog-post", // ← Go UP to parent + "module": "../blog-post", // Go UP to parent "name": "BlogPost" } } @@ -237,67 +434,122 @@ The `adoptsFrom.module` path is **relative to the JSON file**: | `root/Card.json` | `root/card.gts` | `"./card"` | | `root/Card/instance.json` | `root/card.gts` | `"../card"` | -### The Cardinal Rule +### Field Type Rules | Field Type | In `.gts` use | In `.json` use | |------------|---------------|----------------| -| Extends `CardDef` | `linksTo` | `relationships` | -| Extends `FieldDef` | `contains` | `attributes` | +| Extends `CardDef` | `linksTo` / `linksToMany` | `relationships` | +| Extends `FieldDef` | `contains` / `containsMany` | `attributes` | + +--- ## Workspace References -Commands accept these formats: -- `.` - Current directory +Commands accept: +- `.` - Current directory (needs `.boxel-sync.json`) - `./path` - Local path - `@user/workspace` - By name (e.g., `@aallen90/personal`) - `https://...` - Full URL +--- + +## Checkpoint System + +**Classification:** +- `[MAJOR]` - New/deleted files, .gts changes, >3 files +- `[minor]` - Small updates to existing .json files + +**Source indicators:** +- `⇆ LOCAL` (green) - Local edits (from `track` command) +- `⇅ SERVER` (cyan) - External change from web UI (from `watch` command) +- `● MANUAL` (magenta) - Restored + +**Milestones:** Mark important checkpoints with `boxel milestone` + +--- + ## Conflict Resolution | Scenario | Flag | Result | |----------|------|--------| -| Keep local changes | `--prefer-local` | Overwrite remote | -| Keep remote changes | `--prefer-remote` | Overwrite local | +| Keep local | `--prefer-local` | Overwrite remote, sync deletions | +| Keep remote | `--prefer-remote` | Overwrite local | | Keep newest | `--prefer-newest` | Compare timestamps | | Sync deletions | `--delete` | Delete on both sides | +| Interactive | (default) | Prompt for each conflict | -## Troubleshooting +--- + +## Understanding Boxel URLs + +URLs like `https://app.boxel.ai/user/realm/Type/card-id` are **Card IDs**, not fetchable URLs. + +| URL Part | Meaning | +|----------|---------| +| `app.boxel.ai` | Production server | +| `user` | User/organization | +| `realm` | Workspace name | +| `Type/card-id` | Card type and instance | -### "Authentication failed" -- Check your credentials in `.env` -- Verify you can log into Boxel web UI -- For staging, use `matrix-staging.stack.cards` +**NEVER use WebFetch** on Boxel URLs (realms are private). Look for local synced copy: +```bash +# Parse: Type/card-id → ./Type/card-id.json +cat ./Type/card-id.json +``` + +--- + +## Troubleshooting -### "No workspace found" -- Run `boxel list` to see available workspaces -- Use full URL for first-time sync +| Issue | Solution | +|-------|----------| +| "Authentication failed" | Check `boxel profile`, verify web login works | +| "No workspace found" | Run `boxel list`, use full URL for first sync | +| Files reverting after restore | Stop watch first, use `--prefer-local` after | +| Watch not detecting changes | Check interval, verify workspace URL | +| Definition changes not reflected | `boxel touch . Instance/file.json` | -### Files keep reverting after restore -- Stop `boxel watch` before restoring -- Use `boxel sync . --prefer-local` after restore +--- ## Development ```bash -npm install -npm run dev -- # Run in dev mode -npm run build # Build -npm test # Test -npm run lint # Lint +npm install # Install dependencies +npm run dev -- # Run CLI in development mode +npm run build # Compile TypeScript +npm test # Run tests +npm run lint # Check code style ``` -## License +> **Note:** Use `npm run dev -- ` during development (no rebuild needed). After build, use `npx boxel` or `boxel` (after `npm link`). -MIT - See [LICENSE](LICENSE) +### Claude Code Integration + +This repo includes Claude Code support in `.claude/`: +- `CLAUDE.md` - Project instructions and command reference +- `commands/` - Slash commands (`/watch`, `/restore`, `/sync`, etc.) + +When you open this repo in Claude Code, it will guide you through setup and provide AI-assisted development. + +--- ## Contributing PRs welcome! Please ensure: -- Code passes linting +- Code passes linting (`npm run lint`) - New features have documentation -- Breaking changes are noted +- Breaking changes are noted in PR description + +--- + +## License + +MIT - See [LICENSE](LICENSE) + +--- ## Links -- [Boxel](https://boxel.ai) - Website -- [Discord](https://discord.gg/cardstack) - Community +- [Boxel](https://boxel.ai) - Main website +- [Discord](https://discord.gg/cardstack) - Community support +- [GitHub Issues](https://github.com/cardstack/boxel-cli/issues) - Bug reports & features diff --git a/src/commands/check.ts b/src/commands/check.ts index 7274285..eae0c13 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as crypto from 'crypto'; import { MatrixClient } from '../lib/matrix-client.js'; import { RealmAuthClient } from '../lib/realm-auth-client.js'; +import { getProfileManager, formatProfileBadge } from '../lib/profile-manager.js'; interface SyncManifest { workspaceUrl: string; @@ -18,15 +19,22 @@ export async function checkCommand( filePath: string, options: { sync?: boolean } ): Promise { - const matrixUrl = process.env.MATRIX_URL; - const matrixUsername = process.env.MATRIX_USERNAME; - const matrixPassword = process.env.MATRIX_PASSWORD; + // Get credentials from profile manager (falls back to env vars) + const profileManager = getProfileManager(); + const credentials = await profileManager.getActiveCredentials(); - if (!matrixUrl || !matrixUsername || !matrixPassword) { - console.error('Missing Matrix credentials in environment variables'); + if (!credentials) { + console.error('No credentials found. Run "boxel profile add" or set environment variables.'); process.exit(1); } + const { matrixUrl, username: matrixUsername, password: matrixPassword, profileId } = credentials; + + // Show active profile if using one + if (profileId) { + console.log(`${formatProfileBadge(profileId)}\n`); + } + // Resolve the file path const absolutePath = path.resolve(filePath); diff --git a/src/commands/create.ts b/src/commands/create.ts index e8b0bd3..cda5716 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -1,4 +1,5 @@ import { MatrixClient } from '../lib/matrix-client.js'; +import { getProfileManager, formatProfileBadge } from '../lib/profile-manager.js'; interface CreateOptions { background?: string; @@ -41,16 +42,23 @@ export async function createCommand( name: string, options: CreateOptions, ): Promise { - const matrixUrl = process.env.MATRIX_URL; - const username = process.env.MATRIX_USERNAME; - const password = process.env.MATRIX_PASSWORD; + // Get credentials from profile manager (falls back to env vars) + const profileManager = getProfileManager(); + const credentials = await profileManager.getActiveCredentials(); - if (!matrixUrl || !username || !password) { - console.error('Missing Matrix credentials in environment variables'); + if (!credentials) { + console.error('No credentials found. Run "boxel profile add" or set environment variables.'); process.exit(1); } - let realmServerUrl = process.env.REALM_SERVER_URL; + const { matrixUrl, username, password, realmServerUrl: baseRealmServerUrl, profileId } = credentials; + + // Show active profile if using one + if (profileId) { + console.log(`${formatProfileBadge(profileId)}\n`); + } + + let realmServerUrl = baseRealmServerUrl; if (!realmServerUrl) { const matrixUrlObj = new URL(matrixUrl); if (matrixUrlObj.hostname.startsWith('matrix-')) { diff --git a/src/commands/history.ts b/src/commands/history.ts index 1d78330..0e74641 100644 --- a/src/commands/history.ts +++ b/src/commands/history.ts @@ -1,7 +1,38 @@ import * as fs from 'fs'; import * as path from 'path'; import * as readline from 'readline'; -import { CheckpointManager, Checkpoint } from '../lib/checkpoint-manager.js'; +import { CheckpointManager, Checkpoint, CheckpointChange } from '../lib/checkpoint-manager.js'; + +/** + * Scan workspace directory to build a changes array for manual checkpoints. + * Marks all current files as 'modified' since we're snapshotting the current state. + */ +function scanWorkspaceForChanges(workspaceDir: string): CheckpointChange[] { + const changes: CheckpointChange[] = []; + + const scan = (dir: string, prefix = '') => { + if (!fs.existsSync(dir)) return; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + // Skip internal files + if (entry.name.startsWith('.boxel-') || entry.name === '.git') continue; + if (entry.name.startsWith('.') && entry.name !== '.realm.json') continue; + + const fullPath = path.join(dir, entry.name); + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + scan(fullPath, relativePath); + } else { + changes.push({ file: relativePath, status: 'modified' }); + } + } + }; + + scan(workspaceDir); + return changes; +} // ANSI escape codes for terminal control const ESC = '\x1b'; @@ -21,6 +52,7 @@ const FG_WHITE = `${ESC}[37m`; interface HistoryOptions { restore?: boolean | string; + message?: string; } export async function historyCommand( @@ -36,6 +68,26 @@ export async function historyCommand( const manager = new CheckpointManager(workspaceDir); + // Handle --message: create a manual checkpoint + if (options.message) { + if (!manager.isInitialized()) { + manager.init(); + } + + // Detect current changes to create an accurate checkpoint + const changes = manager.detectCurrentChanges(); + + const checkpoint = manager.createCheckpoint('manual', changes, options.message); + + if (checkpoint) { + console.log(`${FG_GREEN}✓${RESET} ${FG_YELLOW}📍${RESET} Checkpoint created: ${FG_YELLOW}${checkpoint.shortHash}${RESET}`); + console.log(` ${checkpoint.message}`); + } else { + console.log(`${FG_YELLOW}No changes to checkpoint${RESET}`); + } + return; + } + if (!manager.isInitialized()) { console.error('No checkpoint history found for this workspace.'); console.error('Checkpoints are created automatically during sync operations.'); @@ -119,14 +171,14 @@ async function quickRestore(manager: CheckpointManager, checkpoint: Checkpoint): } function displayHistory(checkpoints: Checkpoint[]): void { - console.log(`\n${BOLD}Checkpoint History${RESET} ${DIM}(${FG_GREEN}↑${RESET}${DIM}=local push, ${FG_CYAN}↓${RESET}${DIM}=server change, ${FG_YELLOW}⭐${RESET}${DIM}=milestone)${RESET}\n`); + console.log(`\n${BOLD}Checkpoint History${RESET} ${DIM}(${FG_GREEN}⇆${RESET}${DIM}=local edit, ${FG_CYAN}⇅${RESET}${DIM}=server change, ${FG_YELLOW}⭐${RESET}${DIM}=milestone)${RESET}\n`); checkpoints.forEach((cp, i) => { const num = i + 1; const numLabel = num <= 9 ? `${DIM}${num}${RESET}` : ` `; const majorTag = cp.isMajor ? `${FG_YELLOW}[MAJOR]${RESET}` : `${DIM}[minor]${RESET}`; - const sourceTag = cp.source === 'local' ? `${FG_GREEN}↑ LOCAL${RESET}` : - cp.source === 'remote' ? `${FG_CYAN}↓ SERVER${RESET}` : `${FG_MAGENTA}● MANUAL${RESET}`; + const sourceTag = cp.source === 'local' ? `${FG_GREEN}⇆ LOCAL${RESET}` : + cp.source === 'remote' ? `${FG_CYAN}⇅ SERVER${RESET}` : `${FG_MAGENTA}● MANUAL${RESET}`; const date = formatDate(cp.date); const stats = `${DIM}(${cp.filesChanged} files)${RESET}`; const milestoneTag = cp.isMilestone ? `${FG_YELLOW}⭐${RESET} ${FG_MAGENTA}[${cp.milestoneName}]${RESET} ` : ''; @@ -178,8 +230,8 @@ async function interactiveRestore( const prefix = isSelected ? `${FG_CYAN}▶${RESET}` : ` `; const numLabel = isSelected ? `${BOLD}${numStr}${RESET}` : `${DIM}${numStr}${RESET}`; const majorTag = cp.isMajor ? `${FG_YELLOW}●${RESET}` : `${DIM}○${RESET}`; - const sourceIcon = cp.source === 'local' ? `${FG_GREEN}↑LOCAL${RESET}` : - cp.source === 'remote' ? `${FG_CYAN}↓SRVR${RESET}` : `${FG_MAGENTA}◆MAN${RESET}`; + const sourceIcon = cp.source === 'local' ? `${FG_GREEN}⇆LOCAL${RESET}` : + cp.source === 'remote' ? `${FG_CYAN}⇅SRVR${RESET}` : `${FG_MAGENTA}◆MAN${RESET}`; const milestoneIcon = cp.isMilestone ? `${FG_YELLOW}⭐${RESET}` : ''; const line = isSelected diff --git a/src/commands/list.ts b/src/commands/list.ts index d3149cf..ded1774 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,4 +1,5 @@ import { MatrixClient } from '../lib/matrix-client.js'; +import { getProfileManager, formatProfileBadge } from '../lib/profile-manager.js'; interface RealmInfo { url: string; @@ -91,40 +92,24 @@ async function fetchRealmInfo(realmUrl: string, token: string): Promise { - const matrixUrl = process.env.MATRIX_URL; - const username = process.env.MATRIX_USERNAME; - const password = process.env.MATRIX_PASSWORD; + // Get credentials from profile manager (falls back to env vars) + const profileManager = getProfileManager(); + const credentials = await profileManager.getActiveCredentials(); - if (!matrixUrl) { - console.error('MATRIX_URL environment variable is required'); + if (!credentials) { + console.error('No credentials found. Run "boxel profile add" or set environment variables.'); process.exit(1); } - if (!username || !password) { - console.error('MATRIX_USERNAME and MATRIX_PASSWORD environment variables are required'); - process.exit(1); - } + const { matrixUrl, username, password, realmServerUrl: baseRealmServerUrl, profileId } = credentials; - // Derive realm server URL from Matrix URL - // Matrix URL like https://matrix.boxel.ai -> Realm server https://app.boxel.ai - // Or use REALM_SERVER_URL env var if set - let realmServerUrl = process.env.REALM_SERVER_URL; - if (!realmServerUrl) { - // Try to derive from matrix URL - const matrixUrlObj = new URL(matrixUrl); - // Common pattern: matrix.X.Y -> app.X.Y or X.Y - if (matrixUrlObj.hostname.startsWith('matrix.')) { - realmServerUrl = `${matrixUrlObj.protocol}//app.${matrixUrlObj.hostname.slice(7)}/`; - } else if (matrixUrlObj.hostname.startsWith('matrix-')) { - // matrix-staging.stack.cards -> staging.stack.cards - realmServerUrl = `${matrixUrlObj.protocol}//${matrixUrlObj.hostname.slice(7)}/`; - } else { - console.error('Could not derive realm server URL from MATRIX_URL.'); - console.error('Please set REALM_SERVER_URL environment variable.'); - process.exit(1); - } + // Show active profile if using one + if (profileId) { + console.log(`${formatProfileBadge(profileId)}\n`); } + let realmServerUrl = baseRealmServerUrl; + // Ensure trailing slash if (!realmServerUrl.endsWith('/')) { realmServerUrl += '/'; diff --git a/src/commands/profile.ts b/src/commands/profile.ts new file mode 100644 index 0000000..40378f6 --- /dev/null +++ b/src/commands/profile.ts @@ -0,0 +1,366 @@ +import * as readline from 'readline'; +import { + ProfileManager, + getProfileManager, + formatProfileBadge, + getEnvironmentFromMatrixId, + getEnvironmentShortLabel, + getUsernameFromMatrixId, +} from '../lib/profile-manager.js'; + +// ANSI color codes +const FG_GREEN = '\x1b[32m'; +const FG_YELLOW = '\x1b[33m'; +const FG_CYAN = '\x1b[36m'; +const FG_MAGENTA = '\x1b[35m'; +const FG_RED = '\x1b[31m'; +const DIM = '\x1b[2m'; +const BOLD = '\x1b[1m'; +const RESET = '\x1b[0m'; + +function prompt(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +function promptPassword(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + // Hide password input + const stdin = process.stdin; + if (stdin.isTTY) { + stdin.setRawMode(true); + } + + process.stdout.write(question); + let password = ''; + + const onData = (char: Buffer) => { + const c = char.toString(); + if (c === '\n' || c === '\r') { + stdin.removeListener('data', onData); + if (stdin.isTTY) { + stdin.setRawMode(false); + } + process.stdout.write('\n'); + rl.close(); + resolve(password); + } else if (c === '\u0003') { + // Ctrl+C + process.exit(); + } else if (c === '\u007F' || c === '\b') { + // Backspace + if (password.length > 0) { + password = password.slice(0, -1); + process.stdout.write('\b \b'); + } + } else { + password += c; + process.stdout.write('•'); + } + }; + + stdin.on('data', onData); + stdin.resume(); + }); +} + +export interface ProfileCommandOptions { + user?: string; + password?: string; + name?: string; +} + +export async function profileCommand( + subcommand?: string, + arg?: string, + options?: ProfileCommandOptions +): Promise { + const manager = getProfileManager(); + + switch (subcommand) { + case 'list': + await listProfiles(manager); + break; + + case 'add': + // Check for password from environment variable (more secure than -p flag) + const password = options?.password || process.env.BOXEL_PASSWORD; + if (options?.user && password) { + // Non-interactive add + await addProfileNonInteractive(manager, options.user, password, options.name); + } else { + await addProfile(manager); + } + break; + + case 'switch': + if (!arg) { + console.error(`${FG_RED}Error:${RESET} Please specify a profile to switch to.`); + console.log(`Usage: boxel profile switch `); + console.log(`\nAvailable profiles:`); + await listProfiles(manager); + process.exit(1); + } + await switchProfile(manager, arg); + break; + + case 'remove': + if (!arg) { + console.error(`${FG_RED}Error:${RESET} Please specify a profile to remove.`); + process.exit(1); + } + await removeProfile(manager, arg); + break; + + case 'migrate': + await migrateFromEnv(manager); + break; + + default: + // No subcommand - show current profile + manager.printStatus(); + console.log(`\n${DIM}Commands:${RESET}`); + console.log(` ${FG_CYAN}boxel profile list${RESET} List all profiles`); + console.log(` ${FG_CYAN}boxel profile add${RESET} Add a new profile`); + console.log(` ${FG_CYAN}boxel profile switch${RESET} Switch active profile`); + console.log(` ${FG_CYAN}boxel profile remove${RESET} Remove a profile`); + console.log(` ${FG_CYAN}boxel profile migrate${RESET} Import from .env file`); + } +} + +async function listProfiles(manager: ProfileManager): Promise { + const profiles = manager.listProfiles(); + const activeId = manager.getActiveProfileId(); + + if (profiles.length === 0) { + console.log(`\n${FG_YELLOW}No profiles configured.${RESET}`); + console.log(`Run ${FG_CYAN}boxel profile add${RESET} to create one.`); + return; + } + + console.log(`\n${BOLD}Saved Profiles:${RESET}\n`); + + for (const id of profiles) { + const profile = manager.getProfile(id)!; + const isActive = id === activeId; + const env = getEnvironmentFromMatrixId(id); + + const marker = isActive ? `${FG_GREEN}★${RESET} ` : ' '; + const envLabel = getEnvironmentShortLabel(env); + const envColor = env === 'production' ? FG_MAGENTA : FG_CYAN; + + console.log(`${marker}${BOLD}${id}${RESET}`); + console.log(` ${DIM}Name:${RESET} ${profile.displayName}`); + console.log(` ${DIM}Environment:${RESET} ${envColor}${envLabel}${RESET}`); + console.log(` ${DIM}Realm Server:${RESET} ${profile.realmServerUrl}`); + console.log(''); + } + + if (activeId) { + console.log(`${DIM}★ = active profile${RESET}`); + } +} + +async function addProfile(manager: ProfileManager): Promise { + console.log(`\n${BOLD}Add New Profile${RESET}\n`); + + // Choose environment + console.log(`Which environment?`); + console.log(` ${FG_CYAN}1${RESET}) Staging (realms-staging.stack.cards)`); + console.log(` ${FG_MAGENTA}2${RESET}) Production (app.boxel.ai)`); + + const envChoice = await prompt('\nChoice [1/2]: '); + const isProduction = envChoice === '2'; + + const domain = isProduction ? 'boxel.ai' : 'stack.cards'; + const defaultMatrixUrl = isProduction + ? 'https://matrix.boxel.ai' + : 'https://matrix-staging.stack.cards'; + const defaultRealmUrl = isProduction + ? 'https://app.boxel.ai/' + : 'https://realms-staging.stack.cards/'; + + // Get username + console.log(`\nEnter your Boxel username (without @ or domain)`); + console.log(`${DIM}Example: ctse, aallen90${RESET}`); + const username = await prompt('Username: '); + + if (!username) { + console.error(`${FG_RED}Error:${RESET} Username is required.`); + process.exit(1); + } + + const matrixId = `@${username}:${domain}`; + + // Check if already exists + if (manager.getProfile(matrixId)) { + console.log(`\n${FG_YELLOW}Profile ${matrixId} already exists.${RESET}`); + const overwrite = await prompt('Overwrite? [y/N]: '); + if (overwrite.toLowerCase() !== 'y') { + console.log('Cancelled.'); + return; + } + } + + // Get password + const password = await promptPassword('Password: '); + + if (!password) { + console.error(`${FG_RED}Error:${RESET} Password is required.`); + process.exit(1); + } + + // Optional display name + const defaultDisplayName = `${username} · ${domain}`; + const displayNameInput = await prompt(`Display name [${defaultDisplayName}]: `); + const displayName = displayNameInput || defaultDisplayName; + + // Save profile + await manager.addProfile(matrixId, password, displayName, defaultMatrixUrl, defaultRealmUrl); + + console.log(`\n${FG_GREEN}✓${RESET} Profile created: ${formatProfileBadge(matrixId)}`); + + if (manager.getActiveProfileId() === matrixId) { + console.log(`${DIM}This profile is now active.${RESET}`); + } else { + const switchNow = await prompt('Switch to this profile now? [Y/n]: '); + if (switchNow.toLowerCase() !== 'n') { + manager.switchProfile(matrixId); + console.log(`${FG_GREEN}✓${RESET} Switched to ${formatProfileBadge(matrixId)}`); + } + } +} + +async function switchProfile(manager: ProfileManager, profileId: string): Promise { + // Allow partial matching + const profiles = manager.listProfiles(); + let matchedId = profileId; + + // Exact match first + if (!profiles.includes(profileId)) { + // Try partial match (username only) + const matches = profiles.filter(id => { + const username = getUsernameFromMatrixId(id); + return id.includes(profileId) || username === profileId; + }); + + if (matches.length === 0) { + console.error(`${FG_RED}Error:${RESET} Profile not found: ${profileId}`); + console.log(`\nAvailable profiles:`); + for (const id of profiles) { + console.log(` ${id}`); + } + process.exit(1); + } else if (matches.length === 1) { + matchedId = matches[0]; + } else { + console.error(`${FG_RED}Error:${RESET} Ambiguous profile: ${profileId}`); + console.log(`\nMatching profiles:`); + for (const id of matches) { + console.log(` ${id}`); + } + process.exit(1); + } + } + + if (manager.switchProfile(matchedId)) { + console.log(`${FG_GREEN}✓${RESET} Switched to ${formatProfileBadge(matchedId)}`); + } else { + console.error(`${FG_RED}Error:${RESET} Failed to switch profile.`); + process.exit(1); + } +} + +async function removeProfile(manager: ProfileManager, profileId: string): Promise { + const profile = manager.getProfile(profileId); + if (!profile) { + console.error(`${FG_RED}Error:${RESET} Profile not found: ${profileId}`); + process.exit(1); + } + + const confirm = await prompt(`Remove profile ${profileId}? [y/N]: `); + if (confirm.toLowerCase() !== 'y') { + console.log('Cancelled.'); + return; + } + + if (await manager.removeProfile(profileId)) { + console.log(`${FG_GREEN}✓${RESET} Profile removed.`); + + const newActive = manager.getActiveProfileId(); + if (newActive) { + console.log(`Active profile is now: ${formatProfileBadge(newActive)}`); + } + } else { + console.error(`${FG_RED}Error:${RESET} Failed to remove profile.`); + process.exit(1); + } +} + +async function addProfileNonInteractive( + manager: ProfileManager, + matrixId: string, + password: string, + displayName?: string +): Promise { + // Validate matrix ID format + if (!matrixId.startsWith('@') || !matrixId.includes(':')) { + console.error(`${FG_RED}Error:${RESET} Invalid Matrix ID format. Expected @user:domain`); + process.exit(1); + } + + // Check if already exists + if (manager.getProfile(matrixId)) { + console.log(`${FG_YELLOW}Profile ${matrixId} already exists. Updating password.${RESET}`); + await manager.updatePassword(matrixId, password); + if (displayName) { + manager.updateDisplayName(matrixId, displayName); + } + console.log(`${FG_GREEN}✓${RESET} Profile updated: ${formatProfileBadge(matrixId)}`); + return; + } + + await manager.addProfile(matrixId, password, displayName); + console.log(`${FG_GREEN}✓${RESET} Profile created: ${formatProfileBadge(matrixId)}`); + + const activeId = manager.getActiveProfileId(); + if (activeId !== matrixId) { + console.log(`${DIM}Use 'boxel profile switch ${matrixId}' to switch to this profile.${RESET}`); + } +} + +async function migrateFromEnv(manager: ProfileManager): Promise { + console.log(`\n${BOLD}Migrate from .env${RESET}\n`); + + const matrixUrl = process.env.MATRIX_URL; + const username = process.env.MATRIX_USERNAME; + const password = process.env.MATRIX_PASSWORD; + + if (!matrixUrl || !username || !password) { + console.log(`${FG_YELLOW}No complete credentials found in environment variables.${RESET}`); + console.log(`\nRequired variables: MATRIX_URL, MATRIX_USERNAME, MATRIX_PASSWORD, REALM_SERVER_URL`); + return; + } + + const profileId = await manager.migrateFromEnv(); + if (profileId) { + console.log(`${FG_GREEN}✓${RESET} Created profile: ${formatProfileBadge(profileId)}`); + console.log(`\n${DIM}You can now remove credentials from .env if desired.${RESET}`); + } else { + console.log(`${FG_YELLOW}Migration failed or profile already exists.${RESET}`); + } +} diff --git a/src/commands/skills.ts b/src/commands/skills.ts index 1a49a4e..fe9c98b 100644 --- a/src/commands/skills.ts +++ b/src/commands/skills.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { MatrixClient } from '../lib/matrix-client.js'; import { RealmAuthClient } from '../lib/realm-auth-client.js'; +import { getProfileManager, formatProfileBadge, getEnvironmentFromMatrixId } from '../lib/profile-manager.js'; interface SkillCard { id: string; @@ -210,13 +211,13 @@ export async function skillsCommand(options: SkillsOptions): Promise { // Refresh skills from server (requires credentials) if (options.refresh || manifest.skills.length === 0) { - const matrixUrl = process.env.MATRIX_URL; - const username = process.env.MATRIX_USERNAME; - const password = process.env.MATRIX_PASSWORD; + // Get credentials from profile manager (falls back to env vars) + const profileManager = getProfileManager(); + const credentials = await profileManager.getActiveCredentials(); - if (!matrixUrl || !username || !password) { + if (!credentials) { if (options.refresh) { - console.error('Missing required environment variables: MATRIX_URL, MATRIX_USERNAME, MATRIX_PASSWORD'); + console.error('No credentials found. Run "boxel profile add" or set environment variables.'); process.exit(1); } else { console.log('No skills cached. Run "boxel skills --refresh" with credentials to fetch skills.'); @@ -224,8 +225,15 @@ export async function skillsCommand(options: SkillsOptions): Promise { } } - // Determine which realms to use - const isStaging = matrixUrl.includes('staging'); + const { matrixUrl, username, password, profileId } = credentials; + + // Show active profile if using one + if (profileId) { + console.log(`${formatProfileBadge(profileId)}\n`); + } + + // Determine which realms to use based on profile environment + const isStaging = profileId ? getEnvironmentFromMatrixId(profileId) === 'staging' : matrixUrl.includes('staging'); const baseRealms = isStaging ? STAGING_REALMS : BASE_REALMS; console.log('Fetching skills from Boxel...\n'); diff --git a/src/commands/status.ts b/src/commands/status.ts index 1d6ef61..7dd233f 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -4,6 +4,7 @@ import * as crypto from 'crypto'; import { MatrixClient } from '../lib/matrix-client.js'; import { RealmAuthClient } from '../lib/realm-auth-client.js'; import { resolveWorkspace, getAllWorkspacesStatus } from '../lib/workspace-resolver.js'; +import { getProfileManager, formatProfileBadge } from '../lib/profile-manager.js'; interface SyncManifest { workspaceUrl: string; @@ -26,15 +27,22 @@ export async function statusCommand( workspaceRef: string | undefined, options: { pull?: boolean; all?: boolean } ): Promise { - const matrixUrl = process.env.MATRIX_URL; - const matrixUsername = process.env.MATRIX_USERNAME; - const matrixPassword = process.env.MATRIX_PASSWORD; + // Get credentials from profile manager (falls back to env vars) + const profileManager = getProfileManager(); + const credentials = await profileManager.getActiveCredentials(); - if (!matrixUrl || !matrixUsername || !matrixPassword) { - console.error('Missing Matrix credentials in environment variables'); + if (!credentials) { + console.error('No credentials found. Run "boxel profile add" or set environment variables.'); process.exit(1); } + const { matrixUrl, username: matrixUsername, password: matrixPassword, profileId } = credentials; + + // Show active profile if using one + if (profileId) { + console.log(`${formatProfileBadge(profileId)}\n`); + } + // Authenticate const matrixClient = new MatrixClient({ matrixURL: new URL(matrixUrl), diff --git a/src/commands/stop.ts b/src/commands/stop.ts new file mode 100644 index 0000000..26397e6 --- /dev/null +++ b/src/commands/stop.ts @@ -0,0 +1,78 @@ +import { execSync } from 'child_process'; + +interface StoppedProcess { + pid: string; + type: 'watch' | 'track'; + workspace: string; +} + +export async function stopCommand(): Promise { + console.log('🛑 Stopping all Boxel watchers and trackers...\n'); + + // Check platform compatibility + if (process.platform === 'win32') { + console.log(' The stop command is only supported on Unix-like systems (macOS, Linux).'); + console.log(' On Windows, use Task Manager to end boxel processes.'); + return; + } + + const stopped: StoppedProcess[] = []; + + try { + // Find boxel watch and track processes + // Match both development mode (tsx src/index.ts) and installed mode (boxel or node...boxel) + // Use more specific pattern with word boundaries to avoid false positives + const result = execSync( + `ps aux | grep -E '(tsx[[:space:]].*src/index\\.ts[[:space:]]+(watch|track)|[[:space:]]boxel[[:space:]]+(watch|track)|node[[:space:]].*boxel[[:space:]]+(watch|track))' | grep -v grep | grep -v '[[:space:]]stop'`, + { encoding: 'utf-8' } + ).trim(); + + if (result) { + const lines = result.split('\n').filter(Boolean); + const seenPids = new Set(); + + for (const line of lines) { + const parts = line.split(/\s+/); + const pid = parts[1]; + + // Skip if we've already processed this PID (avoid duplicates) + if (seenPids.has(pid)) continue; + seenPids.add(pid); + + // Parse the command to extract type and workspace + const isWatch = line.includes(' watch'); + const isTrack = line.includes(' track'); + if (!isWatch && !isTrack) continue; + + const type = isWatch ? 'watch' : 'track'; + + // Extract workspace path - look for path after watch/track + let workspace = '.'; + const cmdMatch = line.match(/(?:watch|track)\s+([^\s]+)/); + if (cmdMatch && cmdMatch[1] && !cmdMatch[1].startsWith('-')) { + workspace = cmdMatch[1]; + } + + try { + process.kill(parseInt(pid), 'SIGINT'); + stopped.push({ pid, type, workspace }); + } catch { + // Process may have already exited + } + } + } + } catch { + // No processes found (grep returns non-zero) + } + + if (stopped.length === 0) { + console.log(' No running watchers or trackers found.'); + } else { + for (const proc of stopped) { + const icon = proc.type === 'watch' ? '⇅ ' : '⇆ '; + const typeStr = proc.type.padEnd(5); // "watch" or "track" + console.log(` ${icon} Stopped: boxel ${typeStr} ${proc.workspace} (PID ${proc.pid})`); + } + console.log(`\n✓ Stopped ${stopped.length} process${stopped.length > 1 ? 'es' : ''}`); + } +} diff --git a/src/commands/touch.ts b/src/commands/touch.ts index ba3541e..acaf846 100644 --- a/src/commands/touch.ts +++ b/src/commands/touch.ts @@ -1,6 +1,7 @@ import { RealmSyncBase, validateMatrixEnvVars, type SyncOptions } from '../lib/realm-sync-base.js'; import { resolveWorkspace } from '../lib/workspace-resolver.js'; import { MatrixClient } from '../lib/matrix-client.js'; +import { getProfileManager, formatProfileBadge } from '../lib/profile-manager.js'; import * as fs from 'fs'; import * as path from 'path'; @@ -147,6 +148,11 @@ class RealmToucher extends RealmSyncBase { } } } + + // Required by abstract base class + async sync(): Promise { + await this.touch(); + } } export interface TouchCommandOptions { @@ -159,15 +165,22 @@ export async function touchCommand( files: string[], options: TouchCommandOptions, ): Promise { - const matrixUrl = process.env.MATRIX_URL; - const matrixUsername = process.env.MATRIX_USERNAME; - const matrixPassword = process.env.MATRIX_PASSWORD; + // Get credentials from profile manager (falls back to env vars) + const profileManager = getProfileManager(); + const credentials = await profileManager.getActiveCredentials(); - if (!matrixUrl || !matrixUsername || !matrixPassword) { - console.error('Missing Matrix credentials in environment variables'); + if (!credentials) { + console.error('No credentials found. Run "boxel profile add" or set environment variables.'); process.exit(1); } + const { matrixUrl, username: matrixUsername, password: matrixPassword, profileId } = credentials; + + // Show active profile if using one + if (profileId) { + console.log(`${formatProfileBadge(profileId)}\n`); + } + let localDir: string; let workspaceUrl: string; diff --git a/src/commands/track.ts b/src/commands/track.ts new file mode 100644 index 0000000..df3026d --- /dev/null +++ b/src/commands/track.ts @@ -0,0 +1,301 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { CheckpointManager, type CheckpointChange } from '../lib/checkpoint-manager.js'; + +interface TrackOptions { + debounce?: number; + interval?: number; // Minimum seconds between checkpoints + quiet?: boolean; +} + +export async function trackCommand( + workspaceRef: string, + options: TrackOptions +): Promise { + const debounceMs = (options.debounce ?? 3) * 1000; + const minIntervalMs = (options.interval ?? 10) * 1000; // Min 10s between checkpoints + const workspaceDir = path.resolve(workspaceRef || '.'); + + if (!fs.existsSync(workspaceDir)) { + console.error(`Directory not found: ${workspaceDir}`); + process.exit(1); + } + + // Check for .boxel-sync.json to confirm it's a boxel workspace + const syncManifestPath = path.join(workspaceDir, '.boxel-sync.json'); + if (!fs.existsSync(syncManifestPath)) { + console.error('Not a synced Boxel workspace (no .boxel-sync.json)'); + console.error('Run "boxel sync" first to initialize the workspace.'); + process.exit(1); + } + + // Initialize checkpoint manager + const checkpointManager = new CheckpointManager(workspaceDir); + if (!checkpointManager.isInitialized()) { + checkpointManager.init(); + } + + // Track file state for change detection + const fileStates = new Map(); + let debounceTimer: NodeJS.Timeout | null = null; + let pendingChanges = new Map(); + let lastCheckpointTime = Date.now(); + let isCheckingChanges = false; // Mutex to prevent concurrent checkForChanges calls + + // Initialize file states + const initializeFileStates = (dir: string, prefix = '') => { + if (!fs.existsSync(dir)) return; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + // Skip internal files + if (entry.name.startsWith('.boxel-') || entry.name === '.git') continue; + if (entry.name.startsWith('.') && entry.name !== '.realm.json') continue; + + const fullPath = path.join(dir, entry.name); + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + initializeFileStates(fullPath, relativePath); + } else { + const stats = fs.statSync(fullPath); + fileStates.set(relativePath, { mtime: stats.mtimeMs, size: stats.size }); + } + } + }; + + initializeFileStates(workspaceDir); + + // Get workspace name for display + const urlParts = workspaceDir.split('/'); + const workspaceName = urlParts[urlParts.length - 1]; + + console.log(`⇆ Tracking local changes: ${workspaceName}`); + console.log(` Directory: ${workspaceDir}`); + console.log(` Debounce: ${debounceMs / 1000}s, Min interval: ${minIntervalMs / 1000}s`); + console.log(` Press Ctrl+C to stop\n`); + + let intervalTimer: NodeJS.Timeout | null = null; + + const applyPendingChanges = (force = false) => { + if (pendingChanges.size === 0) return; + + // Check minimum interval between checkpoints (unless forced on exit) + const timeSinceLastCheckpoint = Date.now() - lastCheckpointTime; + if (!force && timeSinceLastCheckpoint < minIntervalMs) { + // Schedule for later if not already scheduled + if (!intervalTimer) { + const waitMs = minIntervalMs - timeSinceLastCheckpoint; + if (!options.quiet) { + console.log(` ⏳ Waiting ${Math.ceil(waitMs / 1000)}s before next checkpoint...`); + } + intervalTimer = setTimeout(() => { + intervalTimer = null; + applyPendingChanges(); + }, waitMs); + } + return; + } + + const changes: CheckpointChange[] = []; + const added: string[] = []; + const modified: string[] = []; + const deleted: string[] = []; + + for (const [file, status] of pendingChanges.entries()) { + changes.push({ file, status }); + if (status === 'added') added.push(file); + else if (status === 'modified') modified.push(file); + else deleted.push(file); + } + + if (!options.quiet) { + console.log(`\n[${timestamp()}] 📦 Creating checkpoint (${changes.length} changes)...`); + if (added.length > 0) { + console.log(` + ${added.length} new: ${added.slice(0, 3).join(', ')}${added.length > 3 ? '...' : ''}`); + } + if (modified.length > 0) { + console.log(` ~ ${modified.length} modified: ${modified.slice(0, 3).join(', ')}${modified.length > 3 ? '...' : ''}`); + } + if (deleted.length > 0) { + console.log(` - ${deleted.length} deleted: ${deleted.slice(0, 3).join(', ')}${deleted.length > 3 ? '...' : ''}`); + } + } + + const checkpoint = checkpointManager.createCheckpoint('local', changes); + if (checkpoint) { + console.log(` ⇆ Checkpoint: ${checkpoint.shortHash} ${checkpoint.isMajor ? '[MAJOR]' : '[minor]'} ${checkpoint.message}`); + } + + pendingChanges.clear(); + lastCheckpointTime = Date.now(); + }; + + const checkForChanges = () => { + // Prevent concurrent execution (fs.watch and setInterval can trigger simultaneously) + if (isCheckingChanges) return; + isCheckingChanges = true; + + try { + checkForChangesImpl(); + } finally { + isCheckingChanges = false; + } + }; + + const checkForChangesImpl = () => { + const currentFiles = new Map(); + + const scanDir = (dir: string, prefix = '') => { + if (!fs.existsSync(dir)) return; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + // Skip internal files + if (entry.name.startsWith('.boxel-') || entry.name === '.git') continue; + if (entry.name.startsWith('.') && entry.name !== '.realm.json') continue; + + const fullPath = path.join(dir, entry.name); + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + scanDir(fullPath, relativePath); + } else { + try { + const stats = fs.statSync(fullPath); + currentFiles.set(relativePath, { mtime: stats.mtimeMs, size: stats.size }); + } catch { + // File may have been deleted between readdir and stat + } + } + } + }; + + scanDir(workspaceDir); + + let hasNewChanges = false; + + // Check for new/modified files + for (const [file, current] of currentFiles.entries()) { + const previous = fileStates.get(file); + if (!previous) { + // New file + if (!pendingChanges.has(file)) { + pendingChanges.set(file, 'added'); + hasNewChanges = true; + } + } else if (current.mtime > previous.mtime || current.size !== previous.size) { + // Modified file + if (!pendingChanges.has(file) || pendingChanges.get(file) !== 'modified') { + pendingChanges.set(file, 'modified'); + hasNewChanges = true; + } + } + } + + // Check for deleted files + for (const file of fileStates.keys()) { + if (!currentFiles.has(file)) { + if (!pendingChanges.has(file)) { + pendingChanges.set(file, 'deleted'); + hasNewChanges = true; + } + } + } + + // Update file states + fileStates.clear(); + for (const [file, state] of currentFiles.entries()) { + fileStates.set(file, state); + } + + if (hasNewChanges) { + if (!options.quiet) { + console.log(`\n[${timestamp()}] 🔔 Changes detected (${pendingChanges.size} pending)`); + } + + // Reset debounce timer + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + debounceTimer = setTimeout(() => { + applyPendingChanges(); + debounceTimer = null; + }, debounceMs); + } + }; + + // Use fs.watch for efficient file watching + // Note: recursive option is only supported on macOS and Windows. + // On Linux, we rely on the polling fallback (setInterval) below. + const watchers: fs.FSWatcher[] = []; + const isLinux = process.platform === 'linux'; + + if (isLinux && !options.quiet) { + console.log(` Note: On Linux, file watching uses polling only (fs.watch recursive not supported)\n`); + } + + const watchDir = (dir: string) => { + try { + const watcher = fs.watch(dir, { recursive: !isLinux }, (eventType, filename) => { + if (!filename) return; + + // Skip internal files + if (filename.startsWith('.boxel-') || filename.includes('.git')) return; + if (filename.startsWith('.') && filename !== '.realm.json') return; + + // Debounced check for changes + checkForChanges(); + }); + + watcher.on('error', (error) => { + if (!options.quiet) { + console.error(`Watch error:`, error); + } + }); + + watchers.push(watcher); + } catch (error) { + console.error(`Failed to watch directory:`, error); + } + }; + + watchDir(workspaceDir); + + // Also poll periodically as a fallback (some editors don't trigger fs.watch reliably) + const pollInterval = setInterval(checkForChanges, 2000); + + // Handle graceful shutdown + process.on('SIGINT', () => { + clearInterval(pollInterval); + if (debounceTimer) { + clearTimeout(debounceTimer); + } + if (intervalTimer) { + clearTimeout(intervalTimer); + } + // Apply any pending changes before exit (force = true to skip interval check) + if (pendingChanges.size > 0) { + if (!options.quiet) { + console.log('\n\nApplying pending changes before exit...'); + } + applyPendingChanges(true); + } + for (const watcher of watchers) { + watcher.close(); + } + if (!options.quiet) { + console.log('\n⇆ Tracking stopped'); + } + process.exit(0); + }); + + // Keep process alive + await new Promise(() => {}); +} + +function timestamp(): string { + const now = new Date(); + return now.toISOString().substring(11, 19); // HH:MM:SS in UTC +} diff --git a/src/commands/watch.ts b/src/commands/watch.ts index f7ac588..0ddabc2 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -121,7 +121,7 @@ export async function watchCommand( } // Display what we're watching - console.log(`👁 Watching ${realms.length} realm${realms.length > 1 ? 's' : ''}:`); + console.log(`⇅ Watching ${realms.length} realm${realms.length > 1 ? 's' : ''} (remote):`); for (const realm of realms) { console.log(` ${realm.name} → ${realm.localDir}`); } @@ -336,7 +336,7 @@ export async function watchCommand( clearTimeout(realm.debounceTimer); } } - console.log('\n\n👁 Watch stopped'); + console.log('\n\n⇅ Watch stopped'); process.exit(0); }); diff --git a/src/index.ts b/src/index.ts index 08aa064..c46de9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,8 @@ import { statusCommand } from './commands/status.js'; import { createCommand } from './commands/create.js'; import { historyCommand } from './commands/history.js'; import { watchCommand } from './commands/watch.js'; +import { trackCommand } from './commands/track.js'; +import { stopCommand } from './commands/stop.js'; import { skillsCommand } from './commands/skills.js'; import { touchCommand } from './commands/touch.js'; import { editCommand } from './commands/edit.js'; @@ -18,6 +20,7 @@ import { milestoneCommand } from './commands/milestone.js'; import { shareCommand } from './commands/share.js'; import { gatherCommand } from './commands/gather.js'; import { realmsCommand } from './commands/realms.js'; +import { profileCommand } from './commands/profile.js'; import { loadConfig } from './lib/realm-config.js'; const program = new Command(); @@ -120,7 +123,8 @@ program .description('View and restore checkpoint history') .argument('[workspace]', 'Workspace directory (default: .)') .option('-r, --restore [number]', 'Restore a checkpoint (optionally by number or hash)') - .action(async (workspace: string | undefined, options: { restore?: boolean | string }) => { + .option('-m, --message ', 'Create a manual checkpoint with a custom message') + .action(async (workspace: string | undefined, options: { restore?: boolean | string; message?: string }) => { await historyCommand(workspace || '.', options); }); @@ -163,6 +167,28 @@ program }); }); +program + .command('track') + .description('Track local file changes and create checkpoints automatically') + .argument('[workspace]', 'Workspace directory to track (default: .)') + .option('-d, --debounce ', 'Wait for changes to settle before checkpoint (default: 3)', '3') + .option('-i, --interval ', 'Minimum seconds between checkpoints (default: 10)', '10') + .option('-q, --quiet', 'Only show output when checkpoints created') + .action(async (workspace: string | undefined, options: { debounce?: string; interval?: string; quiet?: boolean }) => { + await trackCommand(workspace || '.', { + debounce: options.debounce ? parseInt(options.debounce) : 3, + interval: options.interval ? parseInt(options.interval) : 10, + quiet: options.quiet, + }); + }); + +program + .command('stop') + .description('Stop all running watch and track processes') + .action(async () => { + await stopCommand(); + }); + program .command('skills') .description('Fetch and manage Boxel skill cards as Claude Code commands') @@ -294,13 +320,30 @@ program await realmsCommand(options); }); +program + .command('profile') + .description('Manage saved profiles for different users/environments') + .argument('[subcommand]', 'list | add | switch | remove | migrate') + .argument('[arg]', 'Profile ID (for switch/remove)') + .option('-u, --user ', 'Matrix user ID (e.g., @user:boxel.ai)') + .option('-p, --password ', 'Password (for add command)') + .option('-n, --name ', 'Display name (for add command)') + .action(async (subcommand?: string, arg?: string, options?: { user?: string; password?: string; name?: string }) => { + if (options?.password) { + console.warn( + 'Warning: Supplying a password via -p/--password may expose it in shell history and process listings. ' + + 'For non-interactive usage, prefer the BOXEL_PASSWORD environment variable or use "boxel profile add" interactively.', + ); + } + await profileCommand(subcommand, arg, options); + }); + // Add help text for environment variables program.addHelpText('after', ` -Environment Variables (required): - MATRIX_URL The Matrix server URL - MATRIX_USERNAME Your Matrix username - MATRIX_PASSWORD Your Matrix password (or use REALM_SECRET_SEED) - REALM_SERVER_URL The realm server URL (for @user/workspace resolution) +Authentication: + Use 'boxel profile' to manage saved credentials (recommended) + Or set all environment variables (all required): + MATRIX_URL, MATRIX_USERNAME, MATRIX_PASSWORD, REALM_SERVER_URL Workspace References: . Current directory (must have .boxel-sync.json) @@ -328,6 +371,11 @@ Examples: boxel watch . -i 10 Check every 10 seconds boxel watch . -q Quiet mode (only show changes) + boxel track . Track local edits, auto-checkpoint + boxel track . -d 5 -i 30 5s debounce, 30s min between checkpoints + + boxel stop Stop all running watch/track processes + boxel pull https://... ./local One-way pull (for read-only realms) boxel touch . Touch all files to force re-indexing @@ -344,6 +392,12 @@ Examples: boxel gather . -s ~/github/repo Gather changes from GitHub repo boxel gather . -s ~/repo -b main Gather from specific branch + + boxel profile Show current profile + boxel profile list List all saved profiles + boxel profile add Add a new profile (interactive) + boxel profile switch Switch to a different profile + boxel profile migrate Import credentials from .env `); program.parse(); diff --git a/src/lib/checkpoint-manager.ts b/src/lib/checkpoint-manager.ts index bc9cb34..4173641 100644 --- a/src/lib/checkpoint-manager.ts +++ b/src/lib/checkpoint-manager.ts @@ -149,6 +149,73 @@ export class CheckpointManager { return files; } + /** + * Detect current changes in the workspace by comparing with last checkpoint + */ + detectCurrentChanges(): CheckpointChange[] { + if (!this.isInitialized()) { + // If not initialized, all files are "added" + const files = this.getWorkspaceFiles(); + return files.map(file => ({ file, status: 'added' as const })); + } + + // Sync files to history to get current state + this.syncFilesToHistory(); + + // Get git status to see what changed + const status = spawnSync('git', ['status', '--porcelain'], { + cwd: this.gitDir, + encoding: 'utf-8', + }); + + const statusOutput = status.stdout.trim(); + if (!statusOutput) { + return []; // No changes + } + + const changes: CheckpointChange[] = []; + for (const line of statusOutput.split('\n')) { + if (!line) continue; + + const statusCode = line.substring(0, 2); + let file = line.substring(3); + + // Parse git status codes (two-character format) + // ' M' or 'M ' = modified + // 'A ' or 'AM' = added + // 'D ' or ' D' = deleted + // '??' = untracked (treat as added) + // 'R ' = renamed (format: "R old -> new") + // 'C ' = copied (treat similar to added) + // 'UU' or 'AA' or other U combos = unmerged (treat as modified) + // 'T ' = type changed (treat as modified) + + // Handle renamed files - extract the new name + if (statusCode.includes('R')) { + const arrowIndex = file.indexOf(' -> '); + if (arrowIndex !== -1) { + const oldFile = file.substring(0, arrowIndex); + const newFile = file.substring(arrowIndex + 4); + // Record both the deletion of old and addition of new + changes.push({ file: oldFile, status: 'deleted' }); + changes.push({ file: newFile, status: 'added' }); + continue; + } + } + + // Classify changes based on status code + if (statusCode.includes('D')) { + changes.push({ file, status: 'deleted' }); + } else if (statusCode.includes('A') || statusCode.includes('C') || statusCode === '??') { + changes.push({ file, status: 'added' }); + } else if (statusCode.includes('M') || statusCode.includes('U') || statusCode.includes('T')) { + changes.push({ file, status: 'modified' }); + } + } + + return changes; + } + /** * Create a checkpoint with the current state */ diff --git a/src/lib/profile-manager.ts b/src/lib/profile-manager.ts index e50992c..a28b24e 100644 --- a/src/lib/profile-manager.ts +++ b/src/lib/profile-manager.ts @@ -258,16 +258,36 @@ export class ProfileManager { const matrixUrl = process.env.MATRIX_URL; const username = process.env.MATRIX_USERNAME; const password = process.env.MATRIX_PASSWORD; - const realmServerUrl = process.env.REALM_SERVER_URL; + let realmServerUrl = process.env.REALM_SERVER_URL; + + if (matrixUrl && username && password) { + // Derive realm server URL from Matrix URL if not explicitly set + if (!realmServerUrl) { + try { + const matrixUrlObj = new URL(matrixUrl); + // Common pattern: matrix.X.Y -> app.X.Y or matrix-staging.X.Y -> realms-staging.X.Y + if (matrixUrlObj.hostname.startsWith('matrix.')) { + realmServerUrl = `${matrixUrlObj.protocol}//app.${matrixUrlObj.hostname.slice(7)}/`; + } else if (matrixUrlObj.hostname.startsWith('matrix-staging.')) { + realmServerUrl = `${matrixUrlObj.protocol}//realms-staging.${matrixUrlObj.hostname.slice(15)}/`; + } else if (matrixUrlObj.hostname.startsWith('matrix-')) { + // matrix-X.Y.Z -> X.Y.Z (generic fallback) + realmServerUrl = `${matrixUrlObj.protocol}//${matrixUrlObj.hostname.slice(7)}/`; + } + } catch { + // Invalid URL, will return null below + } + } - if (matrixUrl && username && password && realmServerUrl) { - return { - matrixUrl, - username, - password, - realmServerUrl, - profileId: null, - }; + if (realmServerUrl) { + return { + matrixUrl, + username, + password, + realmServerUrl, + profileId: null, + }; + } } return null;