Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
330 changes: 21 additions & 309 deletions CLAUDE.md

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Installation & Distribution

## One-Command Installer (`install.sh`)

Users install with `curl -fsSL https://github.com/docer1990/visiontest/releases/latest/download/install.sh | bash`. The script:
1. Detects OS (macOS/Linux) and arch (arm64/x86_64)
2. Validates Java 17+ with platform-specific install suggestions
3. Fetches latest release tag from GitHub API, validates format (`v[0-9][0-9A-Za-z._-]*`) and rejects dangerous characters
4. Downloads `visiontest.jar` + SHA-256 checksum, verifies integrity
5. Downloads Android APKs (`automation-server.apk`, `automation-server-test.apk`) + checksums, verifies integrity
6. On macOS arm64: downloads `ios-automation-server.tar.gz` + checksum, extracts pre-built iOS XCUITest bundle to `ios-automation-server/` subdirectory (skipped on Linux and macOS x86_64)
7. Installs JAR, APKs, and iOS bundle to `~/.local/share/visiontest/` (customizable via `VISIONTEST_DIR` env var, must be under `$HOME`)
8. Creates wrapper script at `~/.local/bin/visiontest`, ensures PATH
9. Does not modify Claude Desktop configuration; use `run-visiontest.sh` or manual setup for Claude integration.

**Security hardening:** `umask 077`, explicit `chmod` on all files/dirs, tag validation, checksum verification, install path restricted to `$HOME`.

## Release Workflow (`.github/workflows/release.yaml`)

Triggered by git tags matching `v*`. The workflow runs the test suite, builds the fat JAR via `shadowJar`, Android APKs, and the pre-built iOS XCUITest bundle (on a macOS runner), generates SHA-256 checksums, and creates a GitHub Release with the following assets: `visiontest.jar`, `visiontest.jar.sha256`, `automation-server.apk`, `automation-server.apk.sha256`, `automation-server-test.apk`, `automation-server-test.apk.sha256`, `ios-automation-server.tar.gz`, `ios-automation-server.tar.gz.sha256`, `install.sh`, `run-visiontest.sh`.

All GitHub Actions in both workflows are pinned to commit SHAs for supply-chain security. When updating or adding actions, always use SHA-pinned references instead of floating version tags.

## Launcher Script (`run-visiontest.sh`)

Used for development and Claude Desktop config. JAR resolution order:

1. Repo build: `app/build/libs/visiontest.jar` (sets up `ANDROID_HOME`, APK path, `cd` to project root)
2. Installed JAR: `~/.local/share/visiontest/visiontest.jar` (skips Android SDK setup)
3. Error with build/install instructions

## Prerequisites

- JDK 17+
- macOS or Linux (arm64 or x86_64)
- Android Platform Tools (ADB) in PATH — for Android automation
- Xcode Command Line Tools — for iOS simulator support (macOS only). Pre-built iOS bundle requires the same Xcode major version used in CI (see release notes). For source builds or Intel Macs, the full Xcode IDE is needed.
- Android SDK — only needed for building the automation-server module from source

> **Quick start:** Users who just need the MCP server can run `curl -fsSL https://github.com/docer1990/visiontest/releases/latest/download/install.sh | bash` — only Java 17+ is required.
94 changes: 94 additions & 0 deletions openspec/specs/tool-discovery/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
## ADDED Requirements

### Requirement: ToolDiscovery class encapsulates all path resolution
A `ToolDiscovery` class in the `discovery/` package SHALL encapsulate all asset discovery logic previously embedded in `ToolFactory`. It SHALL accept only a `Logger` as a constructor parameter.

#### Scenario: ToolDiscovery is independently constructable
- **WHEN** `ToolDiscovery(logger)` is constructed
- **THEN** it SHALL be ready to use without requiring `DeviceConfig`, `AutomationClient`, or any other `ToolFactory` dependency

### Requirement: Android APK discovery
`ToolDiscovery` SHALL provide `findAutomationServerApk()` and its testable overload `findAutomationServerApk(envApkPath, searchRoots, installDir)` with identical behavior to the current `ToolFactory` implementation.

#### Scenario: APK found via environment variable
- **WHEN** `VISION_TEST_APK_PATH` environment variable points to an existing file
- **THEN** `findAutomationServerApk()` SHALL return that file's absolute path

#### Scenario: APK found via search roots
- **WHEN** no environment variable is set and the APK exists at `<searchRoot>/automation-server/build/outputs/apk/androidTest/debug/automation-server-debug-androidTest.apk`
- **THEN** `findAutomationServerApk()` SHALL return the first match found across search roots

#### Scenario: APK found in install directory
- **WHEN** no environment variable is set and no search root contains the APK, but `<installDir>/automation-server-test.apk` exists
- **THEN** `findAutomationServerApk()` SHALL return the install directory APK path

#### Scenario: No APK found
- **WHEN** no APK is found in any location
- **THEN** `findAutomationServerApk()` SHALL return null

### Requirement: Main APK resolution from test APK path
`ToolDiscovery` SHALL provide `resolveMainApkPath(testApkPath)` with identical behavior to the current `ToolFactory` implementation.

#### Scenario: Gradle layout derivation
- **WHEN** test APK path contains `androidTest/` and `-androidTest` substrings and the derived main APK exists
- **THEN** `resolveMainApkPath()` SHALL return the derived path

#### Scenario: Install directory sibling lookup
- **WHEN** the test APK is named `automation-server-test.apk` and `automation-server.apk` exists in the same directory
- **THEN** `resolveMainApkPath()` SHALL return the sibling APK path

#### Scenario: No main APK found
- **WHEN** no derivation or sibling lookup succeeds
- **THEN** `resolveMainApkPath()` SHALL return null

### Requirement: Xcode project discovery
`ToolDiscovery` SHALL provide `findXcodeProject()` with identical cascading search behavior: environment variable → CWD → project root → code source root.

#### Scenario: Xcode project from environment variable
- **WHEN** `VISION_TEST_IOS_PROJECT_PATH` environment variable points to a valid `.xcodeproj` directory
- **THEN** `findXcodeProject()` SHALL return its absolute path

#### Scenario: Xcode project from project root
- **WHEN** no environment variable is set and the `.xcodeproj` exists relative to the detected project root
- **THEN** `findXcodeProject()` SHALL return its absolute path

#### Scenario: No Xcode project found
- **WHEN** no `.xcodeproj` is found in any location
- **THEN** `findXcodeProject()` SHALL return null

### Requirement: xctestrun bundle discovery
`ToolDiscovery` SHALL provide `findXctestrun()` and its testable overload `findXctestrun(installDir)` with identical behavior.

#### Scenario: xctestrun found in install directory
- **WHEN** `<installDir>/ios-automation-server/` contains `.xctestrun` files
- **THEN** `findXctestrun()` SHALL return the absolute path of the first file alphabetically

#### Scenario: No xctestrun found
- **WHEN** the bundle directory does not exist or contains no `.xctestrun` files
- **THEN** `findXctestrun()` SHALL return null

### Requirement: Project root discovery
`ToolDiscovery` SHALL provide `findProjectRoot(startFrom)` that walks up the directory tree (max 10 levels) looking for `settings.gradle.kts` or `settings.gradle`.

#### Scenario: Project root found
- **WHEN** a `settings.gradle.kts` or `settings.gradle` file exists within 10 parent directories of `startFrom`
- **THEN** `findProjectRoot()` SHALL return the directory containing it

#### Scenario: Project root not found within depth limit
- **WHEN** no settings file exists within 10 levels up
- **THEN** `findProjectRoot()` SHALL return null

#### Scenario: Trailing dot in path handled
- **WHEN** `startFrom` path ends with `.`
- **THEN** `findProjectRoot()` SHALL resolve the parent correctly and search normally

### Requirement: Install directory resolution
`ToolDiscovery` SHALL provide `resolveInstallDir()` with the cascading resolution: `VISIONTEST_DIR` env var → JAR directory → `~/.local/share/visiontest` default.

#### Scenario: Install dir from environment variable
- **WHEN** `VISIONTEST_DIR` environment variable is set and non-empty
- **THEN** `resolveInstallDir()` SHALL return a `File` pointing to that path

#### Scenario: Install dir default fallback
- **WHEN** no environment variable is set and not running from a JAR
- **THEN** `resolveInstallDir()` SHALL return `~/.local/share/visiontest`
103 changes: 103 additions & 0 deletions openspec/specs/tool-registration-dsl/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
## ADDED Requirements

### Requirement: ToolScope absorbs tool registration boilerplate
The `ToolScope` class SHALL wrap `Server.addTool()` with automatic timeout enforcement, error handling via `ErrorHandler.handleToolError()`, and `CallToolResult` wrapping. Tool handlers SHALL only provide the business logic as a `suspend (CallToolRequest?) -> String` lambda.
Comment thread
docer1990 marked this conversation as resolved.

#### Scenario: Tool registered via ToolScope executes within timeout
- **WHEN** a tool is registered via `ToolScope.tool()` with a 10s timeout and the handler returns in 5s
- **THEN** the tool SHALL return a `CallToolResult` containing a single `TextContent` with the handler's return value

#### Scenario: Tool registered via ToolScope times out
- **WHEN** a tool is registered via `ToolScope.tool()` with a 10s timeout and the handler takes longer than 10s
- **THEN** the tool SHALL return an error `CallToolResult` produced by `ErrorHandler.handleToolError()` with a `TimeoutCancellationException`
Comment thread
docer1990 marked this conversation as resolved.

#### Scenario: Tool registered via ToolScope handles exceptions
- **WHEN** a tool handler throws any `Exception`
- **THEN** the tool SHALL return an error `CallToolResult` produced by `ErrorHandler.handleToolError()` with the thrown exception and the tool name as context

#### Scenario: Tool registered with custom timeout
- **WHEN** a tool is registered with `timeoutMs = 30000`
- **THEN** the tool SHALL use 30s as its timeout instead of the default

### Requirement: ToolRegistrar interface for modular registration
Each platform tool group SHALL implement the `ToolRegistrar` interface with a single `registerTools(scope: ToolScope)` method. `ToolFactory.registerAllTools()` SHALL iterate over all registrars and delegate to each.

#### Scenario: All tools registered via registrars
- **WHEN** `ToolFactory.registerAllTools(server)` is called
- **THEN** all 36 MCP tools SHALL be registered on the server with identical names, descriptions, and input schemas as the current monolithic implementation
Comment thread
docer1990 marked this conversation as resolved.

#### Scenario: Registrar receives ToolScope
- **WHEN** a `ToolRegistrar.registerTools(scope)` is called
- **THEN** the scope SHALL provide the server, logger, and default timeout configured in `ToolFactory`

### Requirement: CallToolRequest parameter extraction helpers
Extension functions on `CallToolRequest?` SHALL provide type-safe parameter extraction: `requireString(key)`, `requireInt(key)`, `optionalString(key)`, `optionalInt(key)`.

#### Scenario: requireString returns value when present
- **WHEN** `request.requireString("packageName")` is called and `packageName` exists in the request arguments
- **THEN** it SHALL return the string value

#### Scenario: requireString throws when missing
- **WHEN** `request.requireString("packageName")` is called and `packageName` is not in the request arguments
- **THEN** it SHALL throw `IllegalArgumentException` with a message containing the key name

#### Scenario: requireInt parses integer from string
- **WHEN** `request.requireInt("x")` is called and the argument value is `"100"`
- **THEN** it SHALL return `100` as an `Int`

#### Scenario: requireInt throws on non-integer
- **WHEN** `request.requireInt("x")` is called and the argument value is `"abc"`
- **THEN** it SHALL throw `IllegalArgumentException` with a message indicating the key must be an integer

#### Scenario: optionalString returns null when missing
- **WHEN** `request.optionalString("text")` is called and `text` is not in the request arguments
- **THEN** it SHALL return `null`

### Requirement: ToolHelpers object for pure utility functions
The functions `extractProperty`, `extractPattern`, and `formatAppInfo` SHALL be moved to a `ToolHelpers` object in the `tools/` package with identical behavior.

#### Scenario: extractProperty finds property value
- **WHEN** `ToolHelpers.extractProperty("[ro.product.model]: [Pixel 6]", "ro.product.model")` is called
- **THEN** it SHALL return `"Pixel 6"`

#### Scenario: extractProperty returns Unknown for missing property
- **WHEN** `ToolHelpers.extractProperty("", "ro.product.model")` is called
- **THEN** it SHALL return `"Unknown"`

#### Scenario: formatAppInfo extracts and formats app information
- **WHEN** `ToolHelpers.formatAppInfo(rawDumpsysOutput, "com.example.app")` is called with valid dumpsys output
- **THEN** it SHALL return a formatted string containing version name, version code, SDK targets, install dates, and up to 10 permissions

### Requirement: Four platform-specific registrars
The system SHALL provide exactly four `ToolRegistrar` implementations:
- `AndroidDeviceToolRegistrar` — registers 4 Android device management tools
- `AndroidAutomationToolRegistrar` — registers 14 Android UI automation tools
- `IOSDeviceToolRegistrar` — registers 4 iOS device management tools
- `IOSAutomationToolRegistrar` — registers 10 iOS UI automation tools plus server lifecycle management
Comment thread
docer1990 marked this conversation as resolved.

#### Scenario: Android device tools registered
- **WHEN** `AndroidDeviceToolRegistrar.registerTools(scope)` is called
- **THEN** tools `available_device_android`, `list_apps_android`, `info_app_android`, `launch_app_android` SHALL be registered

#### Scenario: Android automation tools registered
- **WHEN** `AndroidAutomationToolRegistrar.registerTools(scope)` is called
- **THEN** all 14 Android automation tools SHALL be registered including `install_automation_server`, `start_automation_server`, `get_ui_hierarchy`, `find_element`, `android_tap_by_coordinates`, `android_swipe`, `android_swipe_direction`, `android_swipe_on_element`, `android_press_back`, `android_press_home`, `android_input_text`, `android_get_device_info`, `get_interactive_elements`, and `automation_server_status`

#### Scenario: iOS device tools registered
- **WHEN** `IOSDeviceToolRegistrar.registerTools(scope)` is called
- **THEN** tools `ios_available_device`, `ios_list_apps`, `ios_info_app`, `ios_launch_app` SHALL be registered

#### Scenario: iOS automation tools registered
- **WHEN** `IOSAutomationToolRegistrar.registerTools(scope)` is called
- **THEN** all 10 iOS automation tools SHALL be registered including `ios_start_automation_server`, `ios_automation_server_status`, `ios_get_ui_hierarchy`, `ios_get_interactive_elements`, `ios_tap_by_coordinates`, `ios_swipe`, `ios_swipe_direction`, `ios_find_element`, `ios_get_device_info`, `ios_press_home`, `ios_input_text`, and `ios_stop_automation_server`
Comment thread
docer1990 marked this conversation as resolved.

### Requirement: ToolFactory remains the public entry point
`ToolFactory` SHALL maintain its existing constructor signature and `registerAllTools(server: Server)` method. `Main.kt` SHALL require zero changes.

#### Scenario: ToolFactory constructor compatibility
- **WHEN** `ToolFactory(android, ios, logger)` is constructed (using defaults for optional params)
- **THEN** it SHALL compile and function identically to the pre-refactor version

#### Scenario: registerAllTools delegates to registrars
- **WHEN** `toolFactory.registerAllTools(server)` is called
- **THEN** it SHALL create a `ToolScope` and pass it to each of the four registrars
Loading