diff --git a/bin/flutter_skill.dart b/bin/flutter_skill.dart index c0dec014..9f6b54f2 100644 --- a/bin/flutter_skill.dart +++ b/bin/flutter_skill.dart @@ -3,6 +3,8 @@ import 'package:flutter_skill/src/cli/launch.dart'; import 'package:flutter_skill/src/cli/inspect.dart'; import 'package:flutter_skill/src/cli/act.dart'; import 'package:flutter_skill/src/cli/server.dart'; +import 'package:flutter_skill/src/cli/server_cmd.dart'; +import 'package:flutter_skill/src/cli/connect.dart'; import 'package:flutter_skill/src/cli/report_error.dart'; import 'package:flutter_skill/src/cli/setup_priority.dart'; import 'package:flutter_skill/src/cli/doctor.dart'; @@ -17,6 +19,7 @@ import 'package:flutter_skill/src/cli/security.dart'; import 'package:flutter_skill/src/cli/diff.dart'; import 'package:flutter_skill/src/cli/quickstart.dart'; import 'package:flutter_skill/src/cli/client.dart'; +import 'package:flutter_skill/src/cli/ping_cmd.dart'; void main(List args) async { if (args.isEmpty) { @@ -27,7 +30,10 @@ void main(List args) async { print(' quickstart Guided demo — see flutter-skill in action in 30s'); print(' demo Launch a built-in demo app — zero setup needed'); print(' launch Launch and auto-connect to an app'); - print(' server Start MCP server (used by IDEs)'); + print(' connect Attach to a running Flutter app and name it'); + print(' ping Health check one or more named server instances'); + print(' server Start MCP server / manage named server instances'); + print(' servers List all running named server instances'); print(' inspect Inspect interactive elements'); print(' act Perform actions (tap, enter_text, scroll)'); print(' screenshot Take a screenshot of the running app'); @@ -104,8 +110,27 @@ void main(List args) async { case 'act': await runAct(commandArgs); break; + case 'connect': + await runConnect(commandArgs); + break; + case 'ping': + // Quick health check for one or more named servers. + // Usage: flutter_skill ping --server=[,,...] + await runPing(commandArgs); + break; + case 'servers': + // Shorthand for `server list` + await runServerCmd(['list', ...commandArgs]); + break; case 'server': - await runServer(commandArgs); + // Route server subcommands (list, stop, status) to server_cmd. + // Plain `server` (no subcommand) or `server` with MCP flags → MCP server. + if (commandArgs.isNotEmpty && + const {'list', 'stop', 'status'}.contains(commandArgs[0])) { + await runServerCmd(commandArgs); + } else { + await runServer(commandArgs); + } break; case 'setup': await runSetupPriority(commandArgs); diff --git a/docs/cli-server-commands.md b/docs/cli-server-commands.md new file mode 100644 index 00000000..b00b38a4 --- /dev/null +++ b/docs/cli-server-commands.md @@ -0,0 +1,1091 @@ +# Named Server Registry and CLI IPC Layer + +## Overview + +The named server registry and CLI IPC layer is a modern approach to managing connections to Flutter applications without requiring the MCP (Model Context Protocol) server. This feature enables you to: + +- **Give running Flutter apps memorable names** using `flutter_skill connect --id=myapp` +- **Target multiple apps in parallel** with commands like `flutter_skill tap "Button" --server=app-a,app-b` +- **Work seamlessly across git worktrees** — each worktree targets its named server independently +- **Integrate easily into CI/CD pipelines** with JSON output and detached process modes +- **Escape MCP complexity** — use simple CLI commands instead of JSON-RPC protocol details + +### The Core Concept: Named Server Instances + +Instead of managing a single anonymous connection or relying on a shared MCP server process, each running `flutter_skill` server gets a memorable name (ID). A named server instance is a lightweight daemon that: + +1. Holds a connection to a running Flutter application via its VM Service +2. Listens on a local TCP port (with a Unix socket fast path on macOS/Linux) for incoming commands +3. Registers itself in `~/.flutter_skill/servers/` so other CLI invocations can find it +4. Executes commands (tap, inspect, screenshot, etc.) on the app and returns results + +This enables a **distribute-and-query** model where multiple tools, scripts, and environments can interact with the same app instance without blocking each other. + +--- + +## Quick Start + +### Single App (Backward Compatible) + +If you're developing on one app, the workflow is unchanged: + +```bash +# Terminal A: Run your app with Dart VM Service +flutter run --vm-service-port=50000 + +# Terminal B: Attach flutter-skill to it (just once) +flutter_skill connect --id=myapp --port=50000 +# Output: Skill server "myapp" listening on port +# Press Ctrl+C to stop. + +# Terminal C (or anywhere else): Use flutter-skill +flutter_skill inspect --server=myapp +flutter_skill tap "Login" --server=myapp +flutter_skill screenshot "app.png" --server=myapp + +# When done +Ctrl+C in Terminal B +``` + +### Multiple Apps in Parallel + +If you're testing multiple apps at once (e.g., two different features): + +```bash +# Terminal A: Run feature-auth app +flutter run -d "iPhone 16" --vm-service-port=50000 + +# Terminal B: Connect it +flutter_skill connect --id=feature-auth --port=50000 + +# Terminal C: Run feature-payments app +flutter run -d "Pixel 8" --vm-service-port=50001 + +# Terminal D: Connect it +flutter_skill connect --id=feature-payments --port=50001 + +# Now use both apps from anywhere: +flutter_skill tap "Login" --server=feature-auth +flutter_skill tap "Pay Now" --server=feature-payments + +# Run the same action on both in parallel: +flutter_skill tap "Logout" --server=feature-auth,feature-payments +``` + +### Git Worktrees + +Each git worktree is an isolated environment. This feature lets each worktree target its own named server: + +```bash +# Main worktree: run one app +git checkout main +flutter run --vm-service-port=50000 & +flutter_skill connect --id=main-app --port=50000 & + +# Worktree A: run a different app +git worktree add ../wt-feature-a origin/feature-a +cd ../wt-feature-a +flutter run --vm-service-port=50001 & +flutter_skill connect --id=feature-a-app --port=50001 & + +# From worktree A, target its own server +flutter_skill inspect --server=feature-a-app +flutter_skill tap "New Button" --server=feature-a-app + +# Switch back to main and target main's server +cd ../flutter-skill-cli +flutter_skill inspect --server=main-app +``` + +### CI/CD Pipeline + +In continuous integration, you typically can't use interactive terminals. Use `--detach` to start everything in the background: + +```bash +# .github/workflows/e2e.yml + +- name: Launch Flutter app in background + run: flutter_skill launch . --id=ci-test --device=chrome --detach + # Now flutter run + skill server are both running as background processes + +- name: Run smoke tests + run: | + flutter_skill inspect --server=ci-test + flutter_skill tap "Login" --server=ci-test + flutter_skill enter_text "email" "test@example.com" --server=ci-test + flutter_skill tap "Submit" --server=ci-test + flutter_skill screenshot "dashboard.png" --server=ci-test + # Output is automatically JSON when CI=true (GitHub Actions sets this) + +- name: Cleanup + if: always() + run: flutter_skill server stop --id=ci-test + # Kills both flutter run and skill server +``` + +--- + +## Commands Reference + +### `flutter_skill connect` + +**Attach flutter-skill to a running Flutter app and register it with a name.** + +```bash +flutter_skill connect --id= [--port=|--uri=] \ + [--project=] [--device=] +``` + +#### Options + +| Option | Required | Default | Description | +|--------|----------|---------|-------------| +| `--id=` | Yes | — | Server name (alphanumeric, hyphens, underscores only). | +| `--port=` | No | — | VM Service port (e.g., 50000). Either this or `--uri` must be provided. | +| `--uri=` | No | — | Full VM Service URI (e.g., `ws://127.0.0.1:50000/ws` or `http://127.0.0.1:50000`). | +| `--project=` | No | `.` | Project directory (stored in registry for reference). | +| `--device=` | No | — | Device ID (stored in registry for reference). | + +#### Behavior + +- Connects to the running Flutter app via VM Service +- Starts a JSON-RPC server on a random free local TCP port +- Registers the server in `~/.flutter_skill/servers/.json` +- **Stays in foreground** — prints logs and waits for `Ctrl+C` to disconnect cleanly +- On `Ctrl+C`: closes the server, unregisters from the registry, and exits + +#### Examples + +```bash +# Connect to app on port 50000, name it "myapp" +flutter_skill connect --id=myapp --port=50000 + +# Connect using a full URI (useful for discovery output) +flutter_skill connect --id=myapp \ + --uri=ws://127.0.0.1:50000/ws \ + --project=/Users/you/projects/myapp \ + --device="iPhone 16 Pro" + +# Connect with auto-discovery (looks up URI automatically) +flutter_skill connect --id=myapp +``` + +#### Exit Codes + +- `0` — Connected and cleanly shut down +- `1` — Failed to connect or invalid arguments + +--- + +### `flutter_skill launch` + +**Start a Flutter app and optionally register a named server for it.** + +```bash +flutter_skill launch [] [--id=] [--detach] [...] +``` + +#### Options + +| Option | Description | +|--------|-------------| +| `--id=` | Optional. If provided, automatically starts a skill server with this name once the app is running. | +| `--detach` | Optional. Spawns the skill server in a detached background process. Useful for CI/CD. Without this flag, the skill server runs in the same process. | +| `` | Any flags you'd normally pass to `flutter run` (e.g., `-d "iPhone 16"`, `-r` for release mode). | + +#### Behavior + +- Runs `flutter run` with your project +- Auto-adds `--vm-service-port=50000` if no other port is specified (recommended for faster discovery) +- If `--id` is provided: + - Once the VM Service is ready, starts a skill server with that ID + - Registers it in the server registry + - Writes `.flutter_skill_server` file in the project directory (for auto-discovery by other commands) +- If `--detach` is provided: + - Spawns the skill server as a separate background process + - Parent `flutter run` continues in foreground (you see app output normally) + - Useful when you want to keep the `flutter run` window open for interactive debugging +- Without `--detach`: + - Skill server runs in the same process (non-blocking background task) + +#### Examples + +```bash +# Launch app and attach a skill server (in-process) +flutter_skill launch . --id=myapp + +# Launch app, attach skill server, and run flutter in background +flutter_skill launch . --id=myapp --detach + +# Launch on a specific device +flutter_skill launch . --id=myapp -d "iPhone 16" --detach + +# Launch in release mode +flutter_skill launch . --id=myapp -r --detach + +# Custom VM Service port +flutter_skill launch . --id=myapp --vm-service-port=8888 +``` + +#### Exit Codes + +- `0` — App exited cleanly +- `1` — Setup failed or app crashed + +--- + +### `flutter_skill server list` + +**Show all registered skill servers and their status.** + +```bash +flutter_skill server list [--output=json|human] +``` + +#### Output (Human) + +``` +Running skill servers: + +ID PORT PID PROJECT +myapp 52341 84921 /Users/you/projects/myapp +feature-auth 52342 84922 /Users/you/projects/feature-auth +feature-payments 52343 84923 /Users/you/projects/feature-payments +``` + +#### Output (JSON) + +```json +[ + { + "id": "myapp", + "port": 52341, + "pid": 84921, + "projectPath": "/Users/you/projects/myapp", + "deviceId": "iPhone 16 Pro", + "vmServiceUri": "ws://127.0.0.1:50000/ws", + "startedAt": "2026-04-01T10:30:00.000Z" + } +] +``` + +#### Notes + +- Automatically filters out stale entries (processes that are no longer running) +- Shows "unreachable" status next to servers whose TCP port is not responding +- In CI environments (when `CI=true` or `GITHUB_ACTIONS=true`), JSON output is default + +--- + +### `flutter_skill server stop` + +**Stop a named skill server and clean up its registry entry.** + +```bash +flutter_skill server stop --id= [--output=json|human] +``` + +#### Behavior + +- Sends a shutdown signal to the skill server +- Server unregisters itself from the registry +- If the server process is unreachable, manually cleans up the registry entry +- Exits with code 0 if stopped successfully, 1 if the ID does not exist + +#### Examples + +```bash +flutter_skill server stop --id=myapp + +# In CI, capture the result as JSON +flutter_skill server stop --id=ci-test --output=json +``` + +#### Output + +``` +Server "myapp" stopped. +``` + +--- + +### `flutter_skill server status` + +**Show detailed status of a named skill server.** + +```bash +flutter_skill server status --id= [--output=json|human] +``` + +#### Output (Human) + +``` +Server: myapp + Status : running + Port : 52341 + PID : 84921 + Project : /Users/you/projects/myapp + Device : iPhone 16 Pro + URI : ws://127.0.0.1:50000/ws + Started : 2026-04-01 10:30:00.000 +``` + +#### Output (JSON) + +```json +{ + "id": "myapp", + "port": 52341, + "pid": 84921, + "projectPath": "/Users/you/projects/myapp", + "deviceId": "iPhone 16 Pro", + "vmServiceUri": "ws://127.0.0.1:50000/ws", + "startedAt": "2026-04-01T10:30:00.000Z", + "alive": true +} +``` + +--- + +### `flutter_skill servers` + +**Shorthand for `flutter_skill server list`.** + +```bash +flutter_skill servers [--output=json|human] +``` + +Identical to `flutter_skill server list`. Useful for quick checks. + +--- + +### `flutter_skill ping` + +**Quick health check for one or more named server instances.** + +```bash +flutter_skill ping --server=[,,...] [--output=json|human] +``` + +Sends a `ping` request to each named server and reports whether it responded. +Exits with code 0 if all servers are reachable, or 1 if any are unreachable. + +#### Examples + +```bash +# Check a single server +flutter_skill ping --server=myapp + +# Check multiple servers +flutter_skill ping --server=feature-auth,feature-payments + +# JSON output (useful for scripting and CI) +flutter_skill ping --server=ci-test --output=json +``` + +#### Output (Human) + +``` +[myapp] pong (12ms) +``` + +#### Output (Human, Unreachable) + +``` +[myapp] unreachable: Could not connect to server "myapp": Connection refused +``` + +#### Output (JSON) + +```json +[ + {"server": "myapp", "success": true, "action": "ping", "duration_ms": 12} +] +``` + +--- + +### `flutter_skill inspect` + +**Inspect the interactive elements of a Flutter app.** + +```bash +flutter_skill inspect [--server=[,,...]] [--output=json|human] +``` + +#### Without `--server` (Auto-Discovery) + +- Uses `.flutter_skill_server` file if present (written by `launch`) +- Falls back to `.flutter_skill_uri` file if present (backward compatibility) +- If multiple servers are running, prompts you to specify one +- Otherwise, uses direct VM Service connection via discovery + +#### With `--server` + +- Connects to the named server(s) via the registry + +#### Examples + +```bash +# Auto-discover (works after flutter_skill launch . --id=myapp) +flutter_skill inspect + +# Target a specific server +flutter_skill inspect --server=myapp + +# Target multiple servers (concurrent) +flutter_skill inspect --server=feature-auth,feature-payments + +# JSON output for parsing +flutter_skill inspect --server=myapp --output=json +``` + +#### Output (Human) + +``` +Interactive Elements: +- **ElevatedButton** [Key: "loginBtn"] [Text: "Login"] + - **Text** [Text: "Login"] +- **TextField** [Key: "emailField"] +- **Row** [Key: "header"] + - **Text** [Text: "Welcome"] +``` + +#### Output (JSON) + +```json +{ + "elements": [ + { + "type": "ElevatedButton", + "key": "loginBtn", + "text": "Login" + }, + { + "type": "TextField", + "key": "emailField" + } + ] +} +``` + +--- + +### `flutter_skill act` (and related commands) + +**Perform actions on a Flutter app (tap, enter text, scroll, screenshot, etc.).** + +```bash +flutter_skill act [...] [--server=[,,...]] +flutter_skill tap [--server=[,,...]] +flutter_skill enter_text [--server=[,,...]] +flutter_skill swipe [] [] [--server=[,,...]] +flutter_skill scroll_to [--server=[,,...]] +flutter_skill screenshot [] [--server=[,,...]] +``` + +#### Available Actions + +| Action | Parameters | Description | +|--------|-----------|-------------| +| `tap` | `` | Tap a button or widget by key or visible text. | +| `enter_text` | ` ` | Enter text into a text field. | +| `swipe` | `[] []` | Swipe the screen (up, down, left, right). Default: 300px up. | +| `scroll_to` | `` | Scroll until a widget is visible. | +| `screenshot` | `[]` | Capture the screen. Saves to file or prints base64 if no path given. | +| `get_text` | `` | Get the text value of a widget. | +| `wait_for_element` | ` []` | Wait for a widget to appear (default timeout 5000ms). | +| `assert_visible` | `` | Verify a widget is visible; fail if not. | +| `assert_gone` | `` | Verify a widget is NOT visible; fail if it is. | +| `go_back` | — | Trigger Android back button or iOS back gesture. | +| `hot_reload` | — | Hot reload the app. | +| `hot_restart` | — | Hot restart the app. | + +#### Examples + +```bash +# Single app (auto-discovery) +flutter_skill tap "Login" + +# Specific server +flutter_skill tap "Login" --server=myapp + +# Multiple servers (runs in parallel) +flutter_skill tap "Login" --server=app-a,app-b + +# Enter text +flutter_skill enter_text "email" "user@example.com" --server=myapp + +# Scroll and assert +flutter_skill scroll_to "Submit Button" --server=myapp +flutter_skill assert_visible "Submit Button" --server=myapp + +# Capture screenshot +flutter_skill screenshot "screenshots/login.png" --server=myapp + +# Wait for element to appear (up to 10 seconds) +flutter_skill wait_for_element "Dashboard" 10000 --server=myapp + +# Hot reload +flutter_skill hot_reload --server=myapp + +# Get text value +flutter_skill get_text "emailLabel" --server=myapp +``` + +#### Output (Human, Single Server) + +``` +Tapped "Login" +``` + +or + +``` +Entered text "user@example.com" into "email" +``` + +#### Output (Human, Multiple Servers) + +``` +[app-a] tap completed (123ms) +[app-b] tap completed (145ms) +``` + +#### Output (JSON) + +```json +[ + { + "server": "app-a", + "action": "tap", + "success": true, + "duration_ms": 123 + }, + { + "server": "app-b", + "action": "tap", + "success": true, + "duration_ms": 145 + } +] +``` + +--- + +## Use Cases + +### Single Developer Workflow + +You're developing a single app and want simple commands without MCP complexity. + +```bash +# Start your app (one terminal) +flutter run --vm-service-port=50000 + +# In another terminal, attach flutter-skill +flutter_skill connect --id=myapp --port=50000 + +# From anywhere, use flutter-skill +flutter_skill inspect --server=myapp +flutter_skill tap "Login" +flutter_skill enter_text "email" "test@example.com" +flutter_skill tap "Submit" +flutter_skill screenshot "result.png" +``` + +**Benefit**: No MCP server to manage. Just two CLI commands and you're done. + +--- + +### Multiple Apps in Parallel + +You're testing two features simultaneously on different devices or emulators. + +```bash +# Terminal 1: Feature A on iPhone +flutter run -d "iPhone 16 Pro" --vm-service-port=50000 +# Terminal 2 +flutter_skill connect --id=feature-a --port=50000 + +# Terminal 3: Feature B on Pixel 8 +flutter run -d "Pixel 8" --vm-service-port=50001 +# Terminal 4 +flutter_skill connect --id=feature-b --port=50001 + +# Now, from anywhere, test both: +flutter_skill tap "Login" --server=feature-a +flutter_skill tap "Login" --server=feature-b + +# Or run the same action on both in parallel: +flutter_skill tap "Logout" --server=feature-a,feature-b +``` + +**Benefit**: No context switching. Both apps are always available via named servers. Script or automate across multiple apps seamlessly. + +--- + +### Git Worktrees Without MCP + +Each git worktree is an isolated environment. Normally, if two worktrees want to use flutter-skill, they'd have to share a single MCP server (which doesn't work well) or duplicate the MCP setup. + +With named servers, each worktree independently targets its own server: + +```bash +# Main branch worktree +git checkout main +cd /path/to/flutter-skill-cli +flutter run --vm-service-port=50000 & +flutter_skill connect --id=main --port=50000 & + +# Feature A worktree +git worktree add ../wt-a origin/feature-a +cd ../wt-a +flutter run --vm-service-port=50001 & +flutter_skill connect --id=feature-a --port=50001 & + +# Feature B worktree +git worktree add ../wt-b origin/feature-b +cd ../wt-b +flutter run --vm-service-port=50002 & +flutter_skill connect --id=feature-b --port=50002 & + +# Now each worktree can independently test: +# In wt-a: +flutter_skill tap "New Button" --server=feature-a + +# In wt-b: +flutter_skill tap "Updated Flow" --server=feature-b + +# Back in main: +flutter_skill tap "Original Button" --server=main +``` + +**Benefit**: Zero coordination between worktrees. Each one runs its own flutter app and skill server independently. Perfect for parallel feature development. + +--- + +### CI/CD Pipeline + +In a CI pipeline, there's no interactive terminal. Use `--detach` to start everything in the background and `--output=json` for machine-readable results. + +```yaml +# .github/workflows/e2e.yml +name: E2E Tests + +on: [push, pull_request] + +jobs: + e2e: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + + - name: Install flutter-skill CLI + run: dart pub global activate --source path . + + - name: Launch app in background + run: | + flutter_skill launch . \ + --id=ci-test \ + --device=chrome \ + --detach + + - name: Wait for app readiness + run: | + for i in {1..30}; do + if flutter_skill ping --server=ci-test --output=json 2>/dev/null; then + echo "App is ready" + exit 0 + fi + sleep 1 + done + echo "App failed to start" + exit 1 + + - name: Run smoke tests + run: | + # All these run with JSON output (CI=true from GitHub Actions) + flutter_skill tap "Login" --server=ci-test + flutter_skill enter_text "email" "test@ci.com" --server=ci-test + flutter_skill enter_text "password" "secret123" --server=ci-test + flutter_skill tap "Sign In" --server=ci-test + flutter_skill assert_visible "Dashboard" --server=ci-test + flutter_skill screenshot "dashboard.png" --server=ci-test + + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v3 + with: + name: screenshots + path: "*.png" + + - name: Cleanup + if: always() + run: flutter_skill server stop --id=ci-test +``` + +**Benefits**: +- No interactive prompts; everything runs unattended +- `--detach` starts the app and server as background processes +- `CI=true` (set automatically by GitHub Actions) triggers JSON output +- Easily parse results for pass/fail decisions +- Clean shutdown with `server stop` ensures resources are freed + +--- + +### Testing Against Remote VM Service + +If your Flutter app is running on a remote machine, you can still use flutter-skill by connecting to the remote VM Service URI. + +```bash +# On the app's machine (or wherever flutter run is) +flutter run --vm-service-port=50000 + +# On your local machine, connect to the remote +flutter_skill connect --id=remote-app \ + --uri=ws://192.168.1.100:50000/ws \ + --project="/path/to/project" \ + --device="remote-emulator" + +# Now use flutter-skill locally +flutter_skill inspect --server=remote-app +flutter_skill tap "Login" --server=remote-app +``` + +**Benefit**: Great for testing shared CI machines or testing with apps running on team infrastructure without needing to duplicate the development environment locally. + +--- + +## Server Registry + +### Location + +All server registration data is stored in `~/.flutter_skill/servers/` (cross-platform): + +``` +~/.flutter_skill/ + servers/ + myapp.json ← Server registration file (human-readable JSON) + myapp.sock ← Unix socket (optional, macOS/Linux only, for lower latency) + feature-auth.json + feature-auth.sock +``` + +### Server Entry Format + +Each `.json` file contains: + +```json +{ + "id": "myapp", + "port": 52341, + "pid": 84921, + "projectPath": "/Users/you/projects/myapp", + "deviceId": "iPhone 16 Pro", + "vmServiceUri": "ws://127.0.0.1:50000/ws", + "startedAt": "2026-04-01T10:30:00.000Z" +} +``` + +| Field | Purpose | +|-------|---------| +| `id` | The server name (must match the filename without `.json`) | +| `port` | Local TCP port the server listens on | +| `pid` | Process ID of the server (used to detect stale entries) | +| `projectPath` | Project directory (for reference and organizing server lists) | +| `deviceId` | Device identifier (for reference, e.g., "iPhone 16 Pro") | +| `vmServiceUri` | Full VM Service URI of the connected Flutter app | +| `startedAt` | ISO 8601 timestamp when the server started | + +### Cleanup + +- **Stale entries are automatically cleaned up** when you list servers. If a registered server's PID is no longer running, it's silently removed. +- **Manual cleanup**: Delete `~/.flutter_skill/servers/.json` and `~/.flutter_skill/servers/.sock` (if present) to unregister a server. +- **Broken connections**: If a server crashes, its registry entry is cleaned up the next time you run `flutter_skill server list`. + +--- + +## Output Formats + +### Human-Readable Output (Default) + +Optimized for developers reading output in a terminal: + +```bash +$ flutter_skill tap "Login" +Tapped "Login" + +$ flutter_skill inspect +Interactive Elements: +- **ElevatedButton** [Key: "loginBtn"] [Text: "Login"] + - **Text** [Text: "Login"] +- **TextField** [Key: "emailField"] + +$ flutter_skill server list +Running skill servers: + +ID PORT PID PROJECT +myapp 52341 84921 /Users/you/projects/myapp +feature-auth 52342 84922 /Users/you/projects/feature-auth +``` + +### JSON Output + +Machine-readable format for CI, scripting, and automation: + +```bash +$ flutter_skill tap "Login" --output=json +{"server":"myapp","action":"tap","success":true,"duration_ms":123} + +$ flutter_skill server list --output=json +[{"id":"myapp","port":52341,"pid":84921,...}] +``` + +### Automatic CI Detection + +When running in a CI environment, output is automatically JSON: + +- GitHub Actions: `GITHUB_ACTIONS=true` +- CircleCI: `CIRCLECI=true` +- Travis CI: `TRAVIS=true` +- Buildkite: `BUILDKITE=true` +- Generic CI: `CI=true` + +Override with `--output=human` if needed: + +```bash +# CI environment defaults to JSON +flutter_skill tap "Login" --server=myapp +# Output: {"server":"myapp",...} + +# Force human output even in CI +flutter_skill tap "Login" --server=myapp --output=human +# Output: Tapped "Login" +``` + +--- + +## Architecture + +### Communication Model + +The named server registry uses a **distributed client-server model** over local IPC: + +``` +┌─ Developer Machine ────────────────────────────────┐ +│ │ +│ flutter_skill launch ← starts flutter run │ +│ flutter_skill connect ← attaches skill server│ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ SkillServer Instance (myapp) │ │ +│ │ - Listens on TCP 127.0.0.1:52341 │ │ +│ │ - (Optional Unix socket: ~/.flutter_skill/ │ │ +│ │ servers/myapp.sock) │ │ +│ │ - Registered in ~/.flutter_skill/servers/ │ │ +│ │ myapp.json │ │ +│ └──────────────────────────────────────────────┘ │ +│ ▲ │ +│ │ JSON-RPC 2.0 │ +│ │ (TCP or Unix socket) │ +│ │ │ +│ ┌────────┴────────────────────────────────────┐ │ +│ │ CLI Client (flutter_skill tap ...) │ │ +│ │ 1. Read ~/.flutter_skill/servers/myapp.json│ │ +│ │ 2. Connect to 127.0.0.1:52341 │ │ +│ │ 3. Send JSON-RPC request │ │ +│ │ 4. Receive response, print to user │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────┘ +``` + +### SkillServer: The Daemon + +`SkillServer` is the long-lived process that runs during `flutter_skill connect` or `flutter_skill launch`. It: + +1. **Owns the AppDriver connection**: Maintains a WebSocket connection to the Flutter app's VM Service +2. **Runs a JSON-RPC server**: Listens on a local TCP port and optionally a Unix socket +3. **Dispatches commands**: Receives requests (tap, screenshot, etc.), delegates to AppDriver, and returns results +4. **Self-manages lifecycle**: Unregisters from the registry when shut down + +### SkillClient: The CLI Tool + +`SkillClient` is what the CLI uses to communicate with a `SkillServer`. It: + +1. **Resolves the server**: Reads `~/.flutter_skill/servers/.json` to find the port +2. **Connects**: Establishes a TCP socket (or prefers Unix socket on macOS/Linux) +3. **Sends one request**: A single JSON-RPC call with the command and parameters +4. **Reads the response**: Waits for the result and returns it +5. **Closes the socket**: Cleans up immediately + +### ServerRegistry: The Catalog + +`ServerRegistry` manages the `~/.flutter_skill/servers/` directory: + +- **Register**: Write `.json` with server metadata when a server starts +- **List**: Read all `.json` files, filter out stale PIDs, return active servers +- **Get**: Retrieve a single server's metadata by ID +- **Unregister**: Delete `.json` and `.sock` when a server stops +- **Check alive**: Try to connect to the server's TCP port to verify it's responsive + +### JSON-RPC 2.0 Protocol + +Commands are sent as newline-delimited JSON-RPC 2.0 requests: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tap", + "params": { + "text": "Login" + } +} +``` + +Success response: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "success": true + } +} +``` + +Error response: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32000, + "message": "Element not found: 'Login'" + } +} +``` + +--- + +## Troubleshooting + +### "No server registered with id 'myapp'" + +**Problem**: You tried to use `--server=myapp` but that server hasn't been started yet. + +**Solution**: +1. Verify the server is running: `flutter_skill server list` +2. If not listed, start it: `flutter_skill connect --id=myapp --port=50000` +3. The registry is in `~/.flutter_skill/servers/` — check for `myapp.json` + +### "Could not connect to server: Connection refused" + +**Problem**: The server is registered but not responding on its TCP port. + +**Solution**: +1. Check if the server process is still running: `flutter_skill server status --id=myapp` +2. If the PID is no longer alive, unregister it: `flutter_skill server stop --id=myapp` +3. Check if the Flutter app itself crashed +4. Restart the server: `flutter_skill connect --id=myapp --port=50000` + +### Server appears in list but shows "unreachable" + +**Problem**: A server entry is in the registry but the TCP port is not responding. + +**Solution**: +- This often means the server process crashed but didn't clean up its registry file +- Clean it up: `flutter_skill server stop --id=myapp` +- Or manually delete: `rm ~/.flutter_skill/servers/myapp.json` +- Restart: `flutter_skill connect --id=myapp --port=50000` + +### "Found DTD URI but no VM Service URI" + +**Problem**: Flutter couldn't establish the VM Service (despite DTD being available). This is rare with modern Flutter versions. + +**Solution**: +1. Ensure you're on Flutter 3.x or later: `flutter --version` +2. Try explicitly setting a VM Service port: `--vm-service-port=8888` +3. Try a different port if 50000 is already in use +4. Check that the device/emulator is responding normally (try `flutter doctor`) + +### Multiple servers running but `flutter_skill inspect` asks me to specify one + +**Problem**: You used `flutter_skill inspect` without `--server=` and there are multiple servers running. + +**Solution**: +- Explicitly name the server: `flutter_skill inspect --server=myapp` +- Or set the default: `export FLUTTER_SKILL_SERVER=myapp` then `flutter_skill inspect` +- Or use the `.flutter_skill_server` file by running from the project directory + +### Command hangs or times out + +**Problem**: A `flutter_skill` command is waiting too long or hanging indefinitely. + +**Solution**: +1. Press `Ctrl+C` to interrupt +2. Check if the Flutter app is responsive (look at the app window) +3. Check if the skill server process is running: `flutter_skill server list` +4. Try a simpler command first (e.g., `flutter_skill server list`) to verify connectivity +5. Check system resource usage (disk, memory, CPU) — the app might be thrashing + +### Unix socket not being used (always TCP on macOS/Linux) + +**Problem**: You expected lower latency via Unix socket but the CLI is using TCP. + +**Solution**: +- This is fine; TCP is the default and reliable +- Unix socket is an optional optimization +- Verify socket was created: `ls -la ~/.flutter_skill/servers/.sock` +- If it exists, the CLI will prefer it automatically; if not, TCP is used as fallback + +### Permission denied when accessing `~/.flutter_skill/servers/` + +**Problem**: Registry directory or files have restrictive permissions. + +**Solution**: +1. Check permissions: `ls -la ~/.flutter_skill/servers/` +2. Ensure your user owns the directory: `chown -R $(whoami) ~/.flutter_skill` +3. Ensure readability: `chmod 700 ~/.flutter_skill` and `chmod 600 ~/.flutter_skill/servers/*` + +### Stale servers accumulate over time + +**Problem**: Registrations are building up even though the servers aren't running. + +**Solution**: +- This shouldn't happen (stale entries are auto-cleaned when you list) +- But if it does, manually clean up: + ```bash + rm ~/.flutter_skill/servers/*.json ~/.flutter_skill/servers/*.sock 2>/dev/null + flutter_skill server list # Verify they're gone + ``` + +--- + +## Best Practices + +1. **Always give servers meaningful names**: Use `--id=feature-auth` instead of `--id=app1`. Makes logs and debugging much easier. + +2. **Use `--detach` in CI/CD**: Non-interactive environments should detach the server so tests can run unattended. + +3. **Prefer explicit `--server=`**: While auto-discovery works, explicitly naming the server makes scripts more maintainable and less ambiguous. + +4. **Monitor servers during development**: Use `flutter_skill server list` periodically to see what's running and clean up old servers if needed. + +5. **Combine with logging**: Redirect logs to files for debugging: + ```bash + flutter_skill connect --id=myapp --port=50000 > server.log 2>&1 & + ``` + +6. **Use `--output=json` in scripts**: When parsing output programmatically, always use `--output=json` to get structured results. + +7. **Handle parallel failures gracefully**: When using `--server=a,b,c`, individual failures don't stop the entire command. Check the exit code (1 if any failed) and parse JSON results to identify which ones failed. + +8. **Clean up on exit**: In CI pipelines, always use `flutter_skill server stop` in a cleanup step (or `if: always()` block) to prevent resource leaks. diff --git a/lib/src/cli/act.dart b/lib/src/cli/act.dart index a8d32516..d6fc1ebf 100644 --- a/lib/src/cli/act.dart +++ b/lib/src/cli/act.dart @@ -1,15 +1,29 @@ import 'dart:convert'; import 'dart:io'; import '../drivers/flutter_driver.dart'; +import 'output_format.dart'; Future runAct(List args) async { + // --server=[,,...] — forward to named SkillServer instance(s) + final serverIds = parseServerIds(args); + final format = resolveOutputFormat(args); + final effectiveArgs = stripOutputFormatFlag(args) + .where((a) => !a.startsWith('--server=')) + .toList(); + + if (serverIds.isNotEmpty) { + await _actViaServers(serverIds, effectiveArgs, format); + return; + } + String uri; int argOffset; // Check if first arg is a URI - if (args.isNotEmpty && - (args[0].startsWith('ws://') || args[0].startsWith('http://'))) { - uri = args[0]; + if (effectiveArgs.isNotEmpty && + (effectiveArgs[0].startsWith('ws://') || + effectiveArgs[0].startsWith('http://'))) { + uri = effectiveArgs[0]; argOffset = 1; } else { // Use auto-discovery (no need for .flutter_skill_uri file!) @@ -22,19 +36,19 @@ Future runAct(List args) async { } } - if (args.length <= argOffset) { + if (effectiveArgs.length <= argOffset) { print('Missing action argument'); print('Usage: flutter_skill act [vm-uri] '); exit(1); } - String action = args[argOffset]; + String action = effectiveArgs[argOffset]; final client = FlutterSkillClient(uri); String? param1; String? param2; - if (args.length > argOffset + 1) param1 = args[argOffset + 1]; - if (args.length > argOffset + 2) param2 = args[argOffset + 2]; + if (effectiveArgs.length > argOffset + 1) param1 = effectiveArgs[argOffset + 1]; + if (effectiveArgs.length > argOffset + 2) param2 = effectiveArgs[argOffset + 2]; try { await client.connect(); @@ -43,28 +57,44 @@ Future runAct(List args) async { case 'tap': if (param1 == null) throw ArgumentError('tap requires a key or text'); await client.tap(key: param1); - print('Tapped "$param1"'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': true, 'action': 'tap', 'target': param1})); + } else { + print('Tapped "$param1"'); + } break; case 'enter_text': if (param1 == null || param2 == null) throw ArgumentError('enter_text requires key and text'); await client.enterText(param1, param2); - print('Entered text "$param2" into "$param1"'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': true, 'action': 'enter_text', 'key': param1, 'text': param2})); + } else { + print('Entered text "$param2" into "$param1"'); + } break; case 'scroll_to': if (param1 == null) throw ArgumentError('scroll_to requires a key or text'); await client.scrollTo(key: param1); - print('Scrolled to "$param1"'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': true, 'action': 'scroll_to', 'target': param1})); + } else { + print('Scrolled to "$param1"'); + } break; case 'scroll': if (param1 == null) throw ArgumentError('scroll requires a key or text to scroll to'); await client.scrollTo(key: param1); - print('Scrolled to "$param1"'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': true, 'action': 'scroll', 'target': param1})); + } else { + print('Scrolled to "$param1"'); + } break; case 'screenshot': @@ -74,12 +104,24 @@ Future runAct(List args) async { if (param1 != null) { final bytes = base64Decode(image); await File(param1).writeAsBytes(bytes); - print('Screenshot saved to $param1 (${bytes.length} bytes)'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': true, 'action': 'screenshot', 'path': param1, 'bytes': bytes.length})); + } else { + print('Screenshot saved to $param1 (${bytes.length} bytes)'); + } } else { - print('Screenshot captured (${image.length} base64 chars)'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': true, 'action': 'screenshot', 'base64Length': image.length})); + } else { + print('Screenshot captured (${image.length} base64 chars)'); + } } } else { - print('Screenshot failed'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': false, 'error': 'Screenshot failed'})); + } else { + print('Screenshot failed'); + } exit(1); } break; @@ -87,14 +129,22 @@ Future runAct(List args) async { case 'get_text': if (param1 == null) throw ArgumentError('get_text requires a key'); final text = await client.getTextValue(param1); - print(text ?? '(null)'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': true, 'action': 'get_text', 'key': param1, 'text': text})); + } else { + print(text ?? '(null)'); + } break; case 'find_element': if (param1 == null) throw ArgumentError('find_element requires a key or text'); final found = await client.waitForElement(key: param1, timeout: 2000); - print(found ? 'Found "$param1"' : 'Not found "$param1"'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': found, 'action': 'find_element', 'target': param1, 'found': found})); + } else { + print(found ? 'Found "$param1"' : 'Not found "$param1"'); + } break; case 'wait_for_element': @@ -103,13 +153,21 @@ Future runAct(List args) async { final timeout = param2 != null ? int.tryParse(param2) ?? 5000 : 5000; final appeared = await client.waitForElement(key: param1, timeout: timeout); - print(appeared ? 'Found "$param1"' : 'Timeout waiting for "$param1"'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': appeared, 'action': 'wait_for_element', 'target': param1, 'found': appeared})); + } else { + print(appeared ? 'Found "$param1"' : 'Timeout waiting for "$param1"'); + } if (!appeared) exit(1); break; case 'go_back': await client.goBack(); - print('Navigated back'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': true, 'action': 'go_back'})); + } else { + print('Navigated back'); + } break; case 'swipe': @@ -117,7 +175,11 @@ Future runAct(List args) async { final distance = param2 != null ? double.tryParse(param2) ?? 300 : 300.0; await client.swipe(direction: direction, distance: distance); - print('Swiped $direction by $distance'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': true, 'action': 'swipe', 'direction': direction, 'distance': distance})); + } else { + print('Swiped $direction by $distance'); + } break; case 'assert_visible': @@ -126,7 +188,11 @@ Future runAct(List args) async { final target = param1; final elements = await client.getInteractiveElements(); if (_findTarget(elements, target)) { - print('Assertion Passed: "$target" is visible.'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': true, 'action': 'assert_visible', 'target': target, 'visible': true})); + } else { + print('Assertion Passed: "$target" is visible.'); + } } else { throw Exception('Assertion Failed: "$target" is NOT visible.'); } @@ -138,24 +204,201 @@ Future runAct(List args) async { final target = param1; final elements = await client.getInteractiveElements(); if (!_findTarget(elements, target)) { - print('Assertion Passed: "$target" is gone.'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': true, 'action': 'assert_gone', 'target': target, 'gone': true})); + } else { + print('Assertion Passed: "$target" is gone.'); + } } else { throw Exception('Assertion Failed: "$target" is STILL visible.'); } break; default: - print('Unknown action: $action'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': false, 'error': 'Unknown action: $action'})); + } else { + print('Unknown action: $action'); + } exit(1); } } catch (e) { - print('Error: $e'); + if (format == OutputFormat.json) { + print(jsonEncode({'success': false, 'error': e.toString()})); + } else { + print('Error: $e'); + } exit(1); } finally { await client.disconnect(); } } +// --------------------------------------------------------------------------- +// Server-forwarding helpers +// --------------------------------------------------------------------------- + +/// Build a JSON-RPC method name + params from the act CLI args. +Map _buildRpcCall(List actArgs) { + if (actArgs.isEmpty) return {'method': 'ping', 'params': {}}; + + final action = actArgs[0]; + final param1 = actArgs.length > 1 ? actArgs[1] : null; + final param2 = actArgs.length > 2 ? actArgs[2] : null; + + switch (action) { + case 'tap': + return { + 'method': 'tap', + 'params': {'key': param1} + }; + case 'enter_text': + return { + 'method': 'enter_text', + 'params': {'key': param1, 'text': param2 ?? ''} + }; + case 'scroll': + // param1 = widget key/text to scroll to (matches direct VM path semantics) + return { + 'method': 'scroll_to', + 'params': {'key': param1} + }; + case 'scroll_to': + return { + 'method': 'scroll_to', + 'params': { + if (param1 != null) 'key': param1, + } + }; + case 'screenshot': + return { + 'method': 'screenshot', + 'params': param1 != null ? {'path': param1} : {} + }; + case 'swipe': + return { + 'method': 'swipe', + 'params': { + 'direction': param1 ?? 'up', + 'distance': double.tryParse(param2 ?? '') ?? 300, + } + }; + case 'go_back': + return {'method': 'go_back', 'params': {}}; + case 'assert_visible': + return { + 'method': 'assert_visible', + 'params': { + if (param1 != null) 'key': param1, + } + }; + case 'assert_gone': + return { + 'method': 'assert_gone', + 'params': { + if (param1 != null) 'key': param1, + } + }; + case 'wait_for': + case 'wait_for_element': + return { + 'method': 'wait_for_element', + 'params': { + if (param1 != null) 'key': param1, + if (param2 != null) 'timeout': int.tryParse(param2) ?? 5000, + } + }; + case 'get_text': + return { + 'method': 'get_text', + 'params': { + if (param1 != null) 'key': param1, + } + }; + case 'find_element': + return { + 'method': 'find_element', + 'params': { + if (param1 != null) 'key': param1, + } + }; + case 'hot_reload': + return {'method': 'hot_reload', 'params': {}}; + case 'hot_restart': + return {'method': 'hot_restart', 'params': {}}; + default: + return {'method': action, 'params': {}}; + } +} + +Future _actViaServers( + List serverIds, List actArgs, OutputFormat format) async { + final rpc = _buildRpcCall(actArgs); + final method = rpc['method'] as String; + final params = rpc['params'] as Map; + final action = actArgs.isNotEmpty ? actArgs[0] : method; + + final results = + await callServersParallel(serverIds, method, params, actionLabel: action); + + // Handle screenshot save when --server is used (specific to this action). + if (method == 'screenshot' && actArgs.length > 1) { + final path = actArgs[1]; + for (final r in results) { + if (r.success) { + final image = r.data?['image'] as String?; + if (image != null) { + // For multiple servers, suffix the filename with the server ID. + final serverPath = serverIds.length > 1 + ? _deriveServerPath(path, r.serverId) + : path; + final bytes = base64Decode(image); + await File(serverPath).writeAsBytes(bytes); + } + } + } + } + + if (format == OutputFormat.json) { + if (serverIds.length == 1) { + final r = results.first; + if (r.success) { + print(jsonEncode(r.data ?? {'success': true, 'action': r.action})); + } else { + print(jsonEncode({'success': false, 'error': r.error})); + } + } else { + print(jsonEncode(results.map((r) => r.toJson()).toList())); + } + return; + } + + for (final r in results) { + if (r.success) { + print('[${r.serverId}] ${r.action} completed (${r.durationMs}ms)'); + } else { + print('[${r.serverId}] Error: ${r.error}'); + } + } + + // Exit with error code if any server failed. + if (results.any((r) => !r.success)) exit(1); +} + +/// Derive a per-server output path by inserting the server ID before the +/// file extension. For example: +/// _deriveServerPath('screenshots/login.png', 'app-a') +/// → 'screenshots/login_app-a.png' +String _deriveServerPath(String path, String serverId) { + final dot = path.lastIndexOf('.'); + if (dot == -1) return '${path}_$serverId'; + return '${path.substring(0, dot)}_$serverId${path.substring(dot)}'; +} + +// --------------------------------------------------------------------------- +// Existing helper (unchanged) +// --------------------------------------------------------------------------- + bool _findTarget(List elements, String target) { for (final e in elements) { if (e is! Map) continue; diff --git a/lib/src/cli/connect.dart b/lib/src/cli/connect.dart new file mode 100644 index 00000000..c845d50a --- /dev/null +++ b/lib/src/cli/connect.dart @@ -0,0 +1,129 @@ +import 'dart:async'; +import 'dart:io'; + +import '../drivers/flutter_driver.dart'; +import '../skill_server.dart'; + +/// CLI command: `flutter_skill connect --id= [--port=|--uri=]` +/// +/// Attaches to a running Flutter app (identified by VM Service port or URI), +/// wraps it in a [SkillServer], registers the server in the registry, and +/// keeps running until Ctrl+C. +Future runConnect(List args) async { + String? id; + int? port; + String? uri; + String projectPath = '.'; + String deviceId = ''; + + for (final arg in args) { + if (arg.startsWith('--id=')) { + id = arg.substring('--id='.length); + } else if (arg.startsWith('--port=')) { + port = int.tryParse(arg.substring('--port='.length)); + } else if (arg.startsWith('--uri=')) { + uri = arg.substring('--uri='.length); + } else if (arg.startsWith('--project=')) { + projectPath = arg.substring('--project='.length); + } else if (arg.startsWith('--device=')) { + deviceId = arg.substring('--device='.length); + } + } + + if (id == null) { + print('Usage: flutter_skill connect --id= [--port=|--uri=]'); + print(''); + print('Options:'); + print(' --id= Server name (required)'); + print(' --port= VM Service port (e.g. 50000)'); + print(' --uri= VM Service URI (e.g. ws://127.0.0.1:50000/ws)'); + print(' --project=

Project path (for registry metadata)'); + print(' --device= Device ID (for registry metadata)'); + exit(1); + } + + // Validate id early — before any expensive I/O. + if (!RegExp(r'^[a-zA-Z0-9_\-]+$').hasMatch(id)) { + print('Error: invalid server id "$id". Only letters, numbers, hyphens, and underscores are allowed.'); + exit(1); + } + + // Build the WebSocket URI. + if (uri == null) { + if (port != null) { + uri = 'ws://127.0.0.1:$port/ws'; + } else { + // Fall back to auto-discovery. + try { + uri = await FlutterSkillClient.resolveUri([]); + } catch (e) { + print('Error: $e'); + exit(1); + } + } + } + + // Normalise http:// → ws:// and https:// → wss://. + // Always use the /ws path regardless of any existing path component. + if (uri.startsWith('http://')) { + final parsed = Uri.parse(uri); + uri = 'ws://${parsed.host}:${parsed.port}/ws'; + } else if (uri.startsWith('https://')) { + final parsed = Uri.parse(uri); + uri = 'wss://${parsed.host}:${parsed.port}/ws'; + } + + print('Connecting to Flutter app at $uri...'); + final driver = FlutterSkillClient(uri); + try { + await driver.connect(); + } catch (e) { + print('Failed to connect: $e'); + exit(1); + } + print('Connected.'); + + final server = SkillServer( + id: id, + driver: driver, + projectPath: projectPath, + deviceId: deviceId, + ); + + await server.start(); + print('Skill server "$id" listening on port ${server.port}'); + print('Press Ctrl+C to stop.'); + + // Keep running until the process is interrupted. + var stopping = false; + final shutdown = Completer(); + + Future doShutdown() async { + if (stopping) return; + stopping = true; + await server.stop().catchError((_) {}); + await driver.disconnect().catchError((_) {}); + if (!shutdown.isCompleted) shutdown.complete(); + } + + ProcessSignal.sigint.watch().first.then((_) async { + print('\nShutting down server "$id"...'); + await doShutdown(); + }); + + // Also handle SIGTERM on Unix. + if (!Platform.isWindows) { + ProcessSignal.sigterm.watch().first.then((_) async { + await doShutdown(); + }); + } + + // Listen for server-initiated shutdown (e.g. via `flutter_skill server stop`). + server.onShutdownRequested.listen((_) async { + print('\nServer "$id" received shutdown request.'); + await doShutdown(); + }); + + await shutdown.future; + exit(0); +} diff --git a/lib/src/cli/inspect.dart b/lib/src/cli/inspect.dart index d20c78c2..4f11192b 100644 --- a/lib/src/cli/inspect.dart +++ b/lib/src/cli/inspect.dart @@ -1,13 +1,25 @@ +import 'dart:convert'; import 'dart:io'; import '../drivers/flutter_driver.dart'; +import 'output_format.dart'; Future runInspect(List args) async { - // No initial arg check, let resolveUri handle it - // if (args.isEmpty) ... + // --server=[,,...] — forward to named SkillServer instance(s) + // --output=json|human — output format + final serverIds = parseServerIds(args); + final format = resolveOutputFormat(args); + final cleanArgs = + stripOutputFormatFlag(args).where((a) => !a.startsWith('--server=')).toList(); + if (serverIds.isNotEmpty) { + await _inspectViaServers(serverIds, format); + return; + } + + // Default behaviour: direct VM Service connection. String uri; try { - uri = await FlutterSkillClient.resolveUri(args); + uri = await FlutterSkillClient.resolveUri(cleanArgs); } catch (e) { print(e); exit(1); @@ -19,13 +31,17 @@ Future runInspect(List args) async { await client.connect(); final elements = await client.getInteractiveElements(); - // Print simplified tree for LLM consumption - print('Interactive Elements:'); - if (elements.isEmpty) { - print('(No interactive elements found)'); + if (format == OutputFormat.json) { + print(jsonEncode({'elements': elements})); } else { - for (final e in elements) { - _printElement(e); + // Print simplified tree for LLM consumption + print('Interactive Elements:'); + if (elements.isEmpty) { + print('(No interactive elements found)'); + } else { + for (final e in elements) { + _printElement(e); + } } } } catch (e) { @@ -36,7 +52,46 @@ Future runInspect(List args) async { } } -void _printElement(dynamic element, [String prefix = '']) { +/// Forward the inspect action to one or more named servers concurrently. +Future _inspectViaServers( + List serverIds, OutputFormat format) async { + final results = await callServersParallel(serverIds, 'inspect', {}); + + if (format == OutputFormat.json) { + if (serverIds.length == 1) { + // Single server: unwrap to match direct-VM schema {"elements": [...]} + final r = results.first; + if (r.success) { + print(jsonEncode(r.data ?? {'elements': []})); + } else { + print(jsonEncode({'error': r.error})); + } + } else { + print(jsonEncode(results.map((r) => r.toJson()).toList())); + } + return; + } + + // Human output: always show server prefix for clarity + for (final r in results) { + if (!r.success) { + print('[${r.serverId}] Error: ${r.error}'); + continue; + } + final elements = (r.data?['elements'] as List?) ?? []; + final prefix = serverIds.length > 1 ? '[${r.serverId}] ' : ''; + print('${prefix}Interactive Elements (${r.durationMs}ms):'); + if (elements.isEmpty) { + print(' (No interactive elements found)'); + } else { + for (final e in elements) { + _printElement(e, prefix: ' '); + } + } + } +} + +void _printElement(dynamic element, {String prefix = ''}) { if (element is! Map) return; // Try to extract useful info @@ -59,7 +114,8 @@ void _printElement(dynamic element, [String prefix = '']) { // Recursively print children if any if (element.containsKey('children') && element['children'] is List) { for (final child in element['children']) { - _printElement(child, '$prefix '); + _printElement(child, prefix: '$prefix '); } } } + diff --git a/lib/src/cli/launch.dart b/lib/src/cli/launch.dart index eff1989e..a39a69a8 100644 --- a/lib/src/cli/launch.dart +++ b/lib/src/cli/launch.dart @@ -1,23 +1,32 @@ import 'dart:convert'; import 'dart:io'; import 'setup.dart'; // Import setup logic +import '../drivers/flutter_driver.dart'; +import '../skill_server.dart'; Future runLaunch(List args) async { - // Extract project path. Everything else is passed to flutter run. - // We assume: flutter_skill launch [project_path] [flutter_args...] - // But wait, standard args might be tricky. - // Let's say: first arg is project path if it doesn't start with -? + // Extract project path and new flags before passing the rest to flutter run. + // + // New flags (consumed here, not forwarded to flutter): + // --id= Register the attached skill server under this name. + // --detach Spawn a detached child process that keeps the server alive; + // the parent process exits after handing off. String projectPath = '.'; + String? serverId; + bool detach = false; List flutterArgs = []; - if (args.isNotEmpty) { - if (!args[0].startsWith('-')) { - projectPath = args[0]; - flutterArgs = args.sublist(1); + for (int i = 0; i < args.length; i++) { + final arg = args[i]; + if (arg.startsWith('--id=')) { + serverId = arg.substring('--id='.length); + } else if (arg == '--detach') { + detach = true; + } else if (i == 0 && !arg.startsWith('-')) { + projectPath = arg; } else { - // Current dir, all args are for flutter - flutterArgs = args; + flutterArgs.add(arg); } } @@ -30,11 +39,11 @@ Future runLaunch(List args) async { print('Proceeding with launch anyway...'); } - // Auto-add --vm-service-port=50000 if not specified - // This ensures faster discovery (recommended but not required) + // Auto-add --vm-service-port=50000 if not specified. + // This ensures faster discovery (recommended but not required). if (!flutterArgs.any((arg) => arg.contains('--vm-service-port'))) { flutterArgs.add('--vm-service-port=50000'); - print('💡 Auto-adding --vm-service-port=50000 (推荐,可加速发现)'); + print('Auto-adding --vm-service-port=50000 (recommended for faster discovery)'); } print('Launching Flutter app in: $projectPath with args: $flutterArgs'); @@ -49,12 +58,18 @@ Future runLaunch(List args) async { print( 'Flutter process started (PID: ${process.pid}). Waiting for connection URI...'); + String? discoveredUri; + process.stdout .transform(utf8.decoder) .transform(const LineSplitter()) .listen((line) { print('[Flutter]: $line'); - _checkForUri(line); + final uri = _extractUri(line); + if (uri != null && discoveredUri == null) { + discoveredUri = uri; + _onUriDiscovered(uri, serverId, projectPath, detach, process); + } }); process.stderr @@ -74,16 +89,87 @@ Future runLaunch(List args) async { exit(exitCode); } -void _checkForUri(String line) { - if (line.contains('ws://')) { - final uriRegex = RegExp(r'ws://[^\s]+'); - final match = uriRegex.firstMatch(line); - if (match != null) { - final uri = match.group(0)!; - print('\n✅ Flutter Skill: VM Service 已启动'); - print(' URI: $uri'); - print(' 🚀 现在可以直接使用: flutter_skill inspect (自动发现)'); - // Note: No longer saving to .flutter_skill_uri - using auto-discovery instead! +String? _extractUri(String line) { + // Try ws:// first (most common) + final wsMatch = RegExp(r'ws://[^\s]+').firstMatch(line); + if (wsMatch != null) return wsMatch.group(0); + // Also match http:// VM Service URIs (newer Flutter versions) + final httpMatch = RegExp(r'http://127\.0\.0\.1:\d+[^\s]*').firstMatch(line); + return httpMatch?.group(0); +} + +void _onUriDiscovered( + String uri, String? serverId, String projectPath, bool detach, Process process) { + print('\nFlutter Skill: VM Service is ready'); + print(' URI: $uri'); + print(' Run: flutter_skill inspect (auto-discovery)'); + + if (serverId == null) return; + + if (detach) { + // Spawn a detached helper process that owns the SkillServer lifecycle. + // The parent (this process) continues owning `flutter run`. + _spawnDetachedServer(serverId, uri, projectPath); + } else { + // Attach the SkillServer in-process (background isolate via async). + _attachServer(serverId, uri, projectPath, process); + } +} + +/// Attach a SkillServer in the same process (async, non-blocking). +void _attachServer(String id, String uri, String projectPath, Process process) async { + try { + final driver = FlutterSkillClient(uri); + await driver.connect(); + final server = SkillServer(id: id, driver: driver, projectPath: projectPath); + await server.start(); + print('Skill server "$id" listening on port ${server.port}'); + + // Write a convenience file in the project directory. + final marker = File('$projectPath/.flutter_skill_server'); + await marker.writeAsString(id, flush: true); + + // Stop the skill server when flutter run exits. + process.exitCode.then((_) async { + await server.stop().catchError((_) {}); + }); + } catch (e) { + print('Warning: Could not start skill server "$id": $e'); + } +} + +/// Spawn a completely detached child process to host the SkillServer. +void _spawnDetachedServer(String id, String uri, String projectPath) { + // We re-invoke ourselves with the `connect` command so the child process + // manages the server lifecycle independently. + final exe = Platform.executable; // The binary that was actually invoked. + final script = Platform.script.toFilePath(); + + // When running via `dart run` or `dart