
Security News
Happy Birthday, Shai-Hulud
It has been one year since Shai-Hulud made its first appearance on npm.
keyboard-ext-ax
Advanced tools
Fast, read-only accessibility snapshots of active third-party iOS keyboard extensions in Simulator.
A custom iOS keyboard runs in a separate app-extension process. When a client app presents that keyboard, ordinary UI automation usually sees the client application's accessibility tree but not the keyboard's useful descendants. The entire keyboard may appear as one opaque element.
That creates a practical automation gap:
KeyboardExtAX fills that gap. It attaches XCTest directly to the already-running keyboard extension by bundle identifier, parses its accessibility hierarchy, and returns plain JSON with absolute logical coordinates. A persistent runner keeps warm snapshots fast, while a small controller owns building, caching, ports, and per-simulator lifecycle.
KeyboardExtAX observes only. Use a separate automation tool, such as XcodeBuildMCP, to launch apps, focus text fields, and perform gestures with the returned coordinates.
keyboard_snapshotkeyboard-ext-axtap_x and tap_y in Simulator logical coordinatesKeyboardExtAX currently supports:
It does not:
The parser reads XCUIApplication.debugDescription. Its text format is not a documented compatibility contract, so each Xcode version must be validated before support is claimed.
If multiple Xcode installations are present, select one with DEVELOPER_DIR or --developer-dir:
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
Clone the repository:
git clone https://github.com/Nikitosina/KeyboardExtAX.git
cd KeyboardExtAX
Create an isolated environment and install the CLI plus MCP support:
python3 -m venv .venv
.venv/bin/python -m pip install -e .
This provides two executables:
.venv/bin/keyboard-ext-ax
.venv/bin/keyboard-ext-ax-mcp
The MCP server can be started with either keyboard-ext-ax-mcp or keyboard-ext-ax mcp.
The bundled Xcode project is ready to use. XcodeGen is needed only when modifying keyboard_ext_ax/harness/project.yml.
Configure keyboard-ext-ax-mcp as a stdio MCP server. Most MCP clients use a configuration shaped like this:
{
"mcpServers": {
"KeyboardExtAX": {
"command": "/absolute/path/to/KeyboardExtAX/.venv/bin/keyboard-ext-ax-mcp",
"env": {
"DEVELOPER_DIR": "/Applications/Xcode.app/Contents/Developer"
}
}
}
}
The server exposes one tool:
keyboard_snapshot(
simulator_id: "<SIMULATOR_UDID>",
extension_bundle_id: "com.example.keyboard.extension",
raw: false
)
Example input:
{
"simulator_id": "2D78332B-A3C3-4CEE-962B-99EF531FD201",
"extension_bundle_id": "com.example.keyboard.extension"
}
The result is returned as structured MCP content. The MCP layer delegates directly to the same controller used by the CLI, so build caching, sessions, errors, and output are identical.
With the client text field focused and the requested keyboard visible:
.venv/bin/keyboard-ext-ax snapshot \
--simulator <SIMULATOR_UDID> \
--extension com.example.keyboard.extension
Write the result to a file:
.venv/bin/keyboard-ext-ax snapshot \
--simulator <SIMULATOR_UDID> \
--extension com.example.keyboard.extension \
--output /tmp/keyboard.json
Use --raw to retain the nested tree and original XCTest descriptions:
.venv/bin/keyboard-ext-ax snapshot \
--simulator <SIMULATOR_UDID> \
--extension com.example.keyboard.extension \
--raw
On attachment failures, raw mode also includes the initial and final XCTest query descriptions and the number of attempts.
The first snapshot for a simulator performs setup automatically:
.xctestrun configuration.Starting XCTest can disturb the client application's keyboard focus. KeyboardExtAX therefore returns this explicit result after creating a runner:
{
"ok": false,
"error": {
"code": "client_refocus_required",
"message": "XCTest runner started; refocus the client text field and retry"
},
"simulator_udid": "…",
"session_reused": false
}
Refocus the client text field and repeat the same MCP or CLI request. Warm calls reuse the runner directly.
KeyboardExtAX also retries extension attachment three times over one second before reporting extension_not_active. Successful first attempts do not pay this delay.
The default response is a compact, flat array:
{
"ok": true,
"snapshot_id": "9543E2AA-C4D9-4281-9645-7792F7953FE3",
"simulator_udid": "2D78332B-A3C3-4CEE-962B-99EF531FD201",
"extension_bundle_id": "com.example.keyboard.extension",
"pid": 12345,
"elapsed_ms": 103.4,
"round_trip_ms": 106.1,
"session_reused": true,
"total_ms": 171.8,
"elements": [
{
"ref": "0.0.3",
"type": "Key",
"identifier": "space",
"label": "Space",
"frame": {
"x": 112,
"y": 746,
"width": 178,
"height": 44
},
"center": {
"x": 201,
"y": 768
},
"tap_x": 201,
"tap_y": 768
}
]
}
| Field | Meaning |
|---|---|
ref | Stable path within this snapshot only. Refresh it after layout changes. |
type | XCTest accessibility element type. |
identifier | Accessibility identifier, when provided by the extension. |
label | Accessibility label, when provided by the extension. |
frame | Absolute logical frame in Simulator coordinates. |
center | Exact floating-point frame center. |
tap_x, tap_y | Rounded integer center, ready for a gesture tool. |
Coordinates are Simulator logical coordinates, not screenshot pixels. Do not rescale them before passing them to XcodeBuildMCP for the same simulator.
A typical automation loop is:
keyboard_snapshot.identifier or label.tap_x and tap_y to XcodeBuildMCP tap.Example pseudocode:
snapshot = keyboard_snapshot(simulator_id, extension_bundle_id)
space = snapshot.elements.first(where: identifier == "space")
XcodeBuildMCP.tap(x: space.tap_x, y: space.tap_y)
Always refresh after Shift, globe, numbers/symbols, Return, vertical navigation, overlays, or any action that can replace keys. KeyboardExtAX intentionally returns coordinates rather than retaining XCTest element handles.
Inspect all cached simulator sessions:
.venv/bin/keyboard-ext-ax status
Inspect one simulator:
.venv/bin/keyboard-ext-ax status --simulator <SIMULATOR_UDID>
Stop one runner:
.venv/bin/keyboard-ext-ax stop --simulator <SIMULATOR_UDID>
Stop every runner:
.venv/bin/keyboard-ext-ax stop --all
One runner is maintained per simulator. Multiple simulators can be queried concurrently; each receives an independent port, state file, log, and result bundle.
The default cache root is:
~/Library/Caches/KeyboardExtAX
It contains:
builds/<build-key>/ Shared XCTest build products
sessions/<simulator-udid>/ Runner state, log, lock, and result bundle
Override the location with either:
keyboard-ext-ax --cache-dir /custom/cache snapshot …
or:
export KEYBOARD_EXT_AX_CACHE_DIR=/custom/cache
Build products are invalidated when the harness source, checked-in Xcode project, Xcode version, or host architecture changes.
Errors are JSON objects with stable codes.
| Code | Meaning | Recovery |
|---|---|---|
client_refocus_required | A new or restarted runner is ready. | Refocus the client text field and repeat the request. |
extension_not_active | XCTest could not attach after three attempts. | Confirm that the requested extension—not the system keyboard—is visible, then retry. |
runner_start_failed | The persistent XCTest runner did not become ready. | Inspect the returned log and log_tail. |
build_failed | The XCTest harness failed to build. | Inspect the returned build log and verify the selected Xcode. |
xcode_unavailable | Xcode could not be located or queried. | Set DEVELOPER_DIR or pass --developer-dir. |
extension_bundle_id_missing | The runner received no extension identifier. | Supply extension_bundle_id. |
snapshot_failed | XCTest raised an unexpected snapshot error. | Retry with raw: true and inspect diagnostics. |
The CLI exits with:
0 for success;2 for a normal snapshot-state error such as inactive extension;1 for lifecycle, build, or controller failures.extension_not_active is returnedConfirm that the visible keyboard belongs to the requested extension. iOS can silently return to the system keyboard when focus changes. Wait for the keyboard transition to finish, then repeat the request without changing the UI again. Use raw: true if the failure persists.
Disable Simulator → I/O → Keyboard → Connect Hardware Keyboard.
Use the coordinates directly with the same simulator. Screenshot files may be scaled and are not the coordinate space used by XCTest.
Take a fresh snapshot. Keyboard layouts and references can change after Shift, locale switches, symbols, suggestions, overlays, and vertical transitions.
Set the desired developer directory:
export DEVELOPER_DIR=/Applications/Xcode-26.5.0.app/Contents/Developer
For a one-off CLI call:
keyboard-ext-ax \
--developer-dir /Applications/Xcode-26.5.0.app/Contents/Developer \
snapshot \
--simulator <SIMULATOR_UDID> \
--extension com.example.keyboard.extension
KeyboardExtAX includes absolute log paths in build_failed and runner_start_failed responses. Start with the returned log rather than manually editing .xctestrun files or selecting ports.
MCP client / CLI
│
▼
KeyboardExtAXController
├─ build cache
├─ per-simulator lock and state
├─ free-port allocation
└─ XCTest runner lifecycle
│ JSON over loopback TCP
▼
PersistentKeyboardExtAXTests
│ XCUIApplication(bundleIdentifier:)
▼
Active keyboard extension process
│
▼
Parsed tree → compact elements → MCP/CLI JSON
The host app included in this repository is XCTest scaffolding. Snapshot mode does not launch it and does not replace the consumer's foreground client app.
Source installation is currently the supported path. The wheel and source distribution bundle the Xcode project, host source, and UI-test source under keyboard_ext_ax/harness, so installed packages do not depend on a repository checkout.
The planned public distribution has two layers:
Planned consumer commands are:
# Run the MCP server without a persistent installation
uvx keyboard-ext-ax mcp
# Or install both executables permanently
uv tool install keyboard-ext-ax
and:
brew install nikitosina/tap/keyboard-ext-ax
These commands will be enabled after the first packages are published.
The Homebrew formula should install the application into an isolated Python virtual environment with all dependencies declared as formula resources. A custom Nikitosina/homebrew-tap can provide the formula immediately; bottles can later make installation fully prebuilt for supported macOS architectures.
The XCTest harness should still be built once on the consumer's machine and cached. Distributing precompiled .xctestrun products is intentionally avoided because they are coupled to Xcode versions, architectures, SDKs, and build paths.
After the PyPI release, the MCP server can be listed in the official MCP Registry under:
io.github.Nikitosina/keyboard-ext-ax
The registry provides discovery metadata; PyPI and Homebrew remain responsible for delivering the software.
Regenerate the checked-in Xcode project after changing the bundled harness specification:
cd keyboard_ext_ax/harness
xcodegen generate
Run Python tests without MCP support:
python3 -m unittest discover -s Tests -v
Run the complete test suite from the development environment:
.venv/bin/python -m unittest discover -s Tests -v
Build the Xcode project with the KeyboardExtAX scheme in keyboard_ext_ax/harness/KeyboardExtAX.xcodeproj.
When changing the parser, validate against every claimed Xcode version and preserve raw XCTest descriptions as fixtures where possible.
Issues and focused pull requests are welcome. Please include:
KeyboardExtAX is available under the MIT License.
FAQs
Accessibility snapshots of active iOS keyboard extensions in Simulator
The pypi package keyboard-ext-ax receives a total of 0 weekly downloads. As such, keyboard-ext-ax popularity was classified as not popular.
We found that keyboard-ext-ax demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
It has been one year since Shai-Hulud made its first appearance on npm.

Research
/Security News
Operators behind PolinRider used a compromised GitHub account to plant malware in four development versions of a Packagist package with 700,000+ downloads.

Security News
GitHub Actions now supports cache-mode, a least-privilege control on the Actions cache aimed at the cache poisoning technique behind recent compromises.