From f7a437ebc975be6c0963df59fe0d173701a71998 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:02:22 +0000 Subject: [PATCH 1/2] feat: Implement hierarchical UI inspection - Add `get_ui_tree_hierarchical` to `sid/utils/ui.py` to retrieve raw nested UI tree. - Add `simplify_node` helper in `sid/commands/vision.py` to recursively simplify the tree structure while preserving hierarchy. - Update `inspect_cmd` to default to hierarchical output. - Add `--flat` flag to `inspect` command for backward compatibility. - Update `SPEC.md` documentation. - Add tests for hierarchical inspection and simplify logic. - Fix `test_logs_crash_report` environment issue. Co-authored-by: acrollet <101649+acrollet@users.noreply.github.com> --- SPEC.md | 24 ++++- sid/commands/vision.py | 186 ++++++++++++++++++++++----------- sid/main.py | 3 +- sid/utils/ui.py | 23 ++++ tests/test_commands.py | 7 +- tests/test_vision_hierarchy.py | 130 +++++++++++++++++++++++ 6 files changed, 305 insertions(+), 68 deletions(-) create mode 100644 tests/test_vision_hierarchy.py diff --git a/SPEC.md b/SPEC.md index 1a774f5..2d6045f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -25,14 +25,32 @@ Returns a simplified JSON tree of the current screen's accessibility hierarchy. * **Flag:** `--interactive-only` (Default: `true`). Filters out structural containers (`Window`, `Other`) and keeps actionable elements (`Button`, `TextField`, `Cell`, `Switch`, `StaticText`). * **Flag:** `--depth [n]`. Limits the hierarchy depth to save tokens. -* **Output Schema:** +* **Flag:** `--flat`. Return a flat list of elements instead of a hierarchy (Backward Compatibility). +* **Output Schema (Hierarchical):** ```json { "app": "com.example.myapp", "screen_id": "LoginView", "elements": [ - { "id": "email_field", "label": "Email Address", "type": "TextField", "frame": "20,100,300,40", "value": "" }, - { "id": "login_btn", "label": "Log In", "type": "Button", "enabled": false } + { + "type": "NavigationBar", + "children": [ + { "type": "Button", "label": "Back" } + ] + }, + { + "type": "TextField", + "id": "email_field", + "label": "Email Address", + "value": "", + "frame": "20,100,300,40" + }, + { + "type": "Button", + "id": "login_btn", + "label": "Log In", + "enabled": false + } ] } ``` diff --git a/sid/commands/vision.py b/sid/commands/vision.py index e72d361..3922337 100644 --- a/sid/commands/vision.py +++ b/sid/commands/vision.py @@ -1,37 +1,61 @@ import json import sys from sid.utils.executor import execute_command -from sid.utils.ui import get_ui_tree +from sid.utils.ui import get_ui_tree, get_ui_tree_hierarchical -def inspect_cmd(interactive_only: bool = True, depth: int = None): - if depth is not None: - print("WARNING: Depth filtering is not fully supported yet.", file=sys.stderr) +def simplify_node(node, interactive_only=False, depth=None, current_depth=0): + """Recursively simplify a node, keeping children nested.""" + if depth is not None and current_depth > depth: + return None - try: - elements = get_ui_tree() + role = node.get("role", "Unknown") + label = node.get("AXLabel", "") + identifier = node.get("AXIdentifier", "") + value = node.get("AXValue", "") + frame = node.get("frame", {}) - # Try to detect app and screen - detected_bundle = "unknown" - detected_screen = "unknown" + children = [] + for child in node.get("nodes", []): + simplified = simplify_node(child, interactive_only, depth, current_depth + 1) + if simplified: + children.append(simplified) + + # In interactive_only mode, skip non-interactive nodes that have no + # interactive descendants + if interactive_only: + is_interactive = role.lower().replace("ax", "") in [ + "button", "textfield", "cell", "switch", "statictext", + "link", "image", "searchfield", "slider", "toggle", + ] + # Structural roles worth keeping as containers + is_structural = role.lower().replace("ax", "") in [ + "navigationbar", "tabbar", "table", "scrollview", + "alert", "sheet", "toolbar", "window", "application", + ] + if not is_interactive and not is_structural and not children: + return None + + result = {"type": role} + if identifier: + result["id"] = identifier + if label: + result["label"] = label + if value: + result["value"] = value + if isinstance(frame, dict): + result["frame"] = f"{frame.get('x',0)},{frame.get('y',0)},{frame.get('w',0)},{frame.get('h',0)}" + if children: + result["children"] = children + + return result - # In idb's output, the top-level window often has the bundle_id in its metadata - # or we can look for the 'Window' role. - for el in elements: - role = el.get("role", "") - if role in ["Window", "AXWindow", "AXApplication"]: - # Some idb versions provide bundle_id in the window node or we can infer it - # For now, we'll see if AXIdentifier or AXLabel on Window gives us a screen name - detected_screen = el.get("AXLabel") or el.get("AXIdentifier") or "MainScreen" - break - - if detected_screen == "unknown": - # Try to find a heading as a fallback - for el in elements: - if el.get("role") in ["Heading", "AXHeading"]: - detected_screen = el.get("AXLabel") or "MainScreen" - break - - # If we have a state file, use that as a fallback/source for bundle_id +def inspect_cmd(interactive_only: bool = True, depth: int = None, flat: bool = False): + if depth is not None and flat: + print("WARNING: Depth filtering is not fully supported in flat mode.", file=sys.stderr) + + try: + # Detect app (common) + detected_bundle = "unknown" from sid.commands.verification import STATE_FILE import os if os.path.exists(STATE_FILE): @@ -41,43 +65,81 @@ def inspect_cmd(interactive_only: bool = True, depth: int = None): except IOError: pass - filtered_elements = [] - for el in elements: - # type/role - role = el.get("role", "Unknown") # idb uses 'role' - - if interactive_only: - # Spec list: Button, TextField, Cell, Switch, StaticText - # We should be case-insensitive and handle AX prefixes - valid_roles = [ - "button", "textfield", "cell", "switch", "statictext", "link", "image", "searchfield", - "axbutton", "axtextfield", "axcell", "axswitch", "axstatictext", "axlink", "aximage", "axsearchfield" - ] - if role.lower() not in valid_roles: - continue - - # Helper to format frame - frame = el.get("frame", {}) - if isinstance(frame, dict): - frame_str = f"{frame.get('x',0)},{frame.get('y',0)},{frame.get('w',0)},{frame.get('h',0)}" - else: - frame_str = str(frame) - - mapped = { - "id": el.get("AXIdentifier", ""), - "label": el.get("AXLabel", ""), - "type": role, - "frame": frame_str, - "value": el.get("AXValue", "") - } - - filtered_elements.append(mapped) - - # Attempt to get running app + detected_screen = "unknown" + + if flat: + elements = get_ui_tree() + + # Try to detect screen + for el in elements: + role = el.get("role", "") + if role in ["Window", "AXWindow", "AXApplication"]: + detected_screen = el.get("AXLabel") or el.get("AXIdentifier") or "MainScreen" + break + + if detected_screen == "unknown": + for el in elements: + if el.get("role") in ["Heading", "AXHeading"]: + detected_screen = el.get("AXLabel") or "MainScreen" + break + + filtered_elements = [] + for el in elements: + role = el.get("role", "Unknown") + + if interactive_only: + valid_roles = [ + "button", "textfield", "cell", "switch", "statictext", "link", "image", "searchfield", + "axbutton", "axtextfield", "axcell", "axswitch", "axstatictext", "axlink", "aximage", "axsearchfield" + ] + if role.lower() not in valid_roles: + continue + + frame = el.get("frame", {}) + if isinstance(frame, dict): + frame_str = f"{frame.get('x',0)},{frame.get('y',0)},{frame.get('w',0)},{frame.get('h',0)}" + else: + frame_str = str(frame) + + mapped = { + "id": el.get("AXIdentifier", ""), + "label": el.get("AXLabel", ""), + "type": role, + "frame": frame_str, + "value": el.get("AXValue", "") + } + + filtered_elements.append(mapped) + + final_elements = filtered_elements + + else: + # Hierarchical Mode + raw_tree = get_ui_tree_hierarchical() + + root_nodes = [] + if isinstance(raw_tree, list): + root_nodes = raw_tree + elif isinstance(raw_tree, dict): + root_nodes = [raw_tree] + + # Detect screen + for node in root_nodes: + role = node.get("role", "") + if role in ["Window", "AXWindow", "AXApplication"]: + detected_screen = node.get("AXLabel") or node.get("AXIdentifier") or "MainScreen" + break + + final_elements = [] + for node in root_nodes: + simplified = simplify_node(node, interactive_only, depth) + if simplified: + final_elements.append(simplified) + result = { "app": detected_bundle, - "screen_id": detected_screen, - "elements": filtered_elements + "screen_id": detected_screen if detected_screen != "unknown" else "MainScreen", + "elements": final_elements } print(json.dumps(result, indent=2)) diff --git a/sid/main.py b/sid/main.py index 1e12816..3053ad7 100644 --- a/sid/main.py +++ b/sid/main.py @@ -63,6 +63,7 @@ def main(): inspect_parser.add_argument("--interactive-only", action="store_true", default=True, help="Filter out structural containers and keep actionable elements (Button, TextField, Cell, Switch, StaticText). Default: True") inspect_parser.add_argument("--all", action="store_false", dest="interactive_only", help="Show all elements, disabling the interactive-only filter.") inspect_parser.add_argument("--depth", type=int, help="Limit the hierarchy depth to save tokens. (Note: Partial support)") + inspect_parser.add_argument("--flat", action="store_true", help="Return a flat list of elements instead of a hierarchy. (Backward compatibility)") screenshot_parser = subparsers.add_parser("screenshot", help="Capture the visual state for verification.") screenshot_parser.add_argument("filename", help="The output filename for the screenshot (e.g., screen.png).") @@ -143,7 +144,7 @@ def main(): raise if args.command == "inspect": - inspect_cmd(interactive_only=args.interactive_only, depth=args.depth) + inspect_cmd(interactive_only=args.interactive_only, depth=args.depth, flat=args.flat) elif args.command == "screenshot": screenshot_cmd(args.filename, mask_text=args.mask_text) elif args.command == "tap": diff --git a/sid/utils/ui.py b/sid/utils/ui.py index 1034d4d..201e47c 100644 --- a/sid/utils/ui.py +++ b/sid/utils/ui.py @@ -43,6 +43,29 @@ def ensure_idb_connected(silent=False): print(f"Warning: Failed to ensure idb connection: {e}", file=sys.stderr) return False +def get_ui_tree_hierarchical(silent=False): + """Returns the raw nested tree from idb, with each node's children intact.""" + try: + ensure_idb_connected(silent=silent) + output = execute_command(["idb", "ui", "describe-all"], capture_output=True) + if not output: + return [] + try: + return json.loads(output) # Return as-is, preserving nesting + except json.JSONDecodeError: + return [] + except subprocess.CalledProcessError as e: + if not silent: + msg = f"Error fetching UI tree (exit {e.returncode})" + if e.stderr: + msg += f": {e.stderr.strip()}" + print(msg, file=sys.stderr) + return [] + except Exception as e: + if not silent: + print(f"Error fetching UI tree: {e}", file=sys.stderr) + return [] + def get_ui_tree(silent=False): try: ensure_idb_connected(silent=silent) diff --git a/tests/test_commands.py b/tests/test_commands.py index 2f4a182..3e4ea93 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -27,7 +27,7 @@ def test_inspect_basic(self, mock_file, mock_exists, mock_get_tree): captured_output = StringIO() sys.stdout = captured_output try: - vision.inspect_cmd(interactive_only=True) + vision.inspect_cmd(interactive_only=True, flat=True) finally: sys.stdout = sys.__stdout__ @@ -121,11 +121,14 @@ def test_tap_error_code(self, mock_get_tree): output = captured_output.getvalue() self.assertIn("ERR_ELEMENT_NOT_FOUND", output) + @patch('os.path.expanduser') @patch('os.path.exists') @patch('os.listdir') @patch('os.path.getmtime') @patch('builtins.open', new_callable=unittest.mock.mock_open, read_data="Crash content") - def test_logs_crash_report(self, mock_file, mock_mtime, mock_listdir, mock_exists): + def test_logs_crash_report(self, mock_file, mock_mtime, mock_listdir, mock_exists, mock_expanduser): + mock_expanduser.return_value = "/Users/acrollet/Library/Logs/DiagnosticReports" + # Mock STATE_FILE exists and contains bundle_id # We need to handle multiple open calls mock_exists.side_effect = lambda p: p == "/tmp/sid_last_bundle_id" or p == "/Users/acrollet/Library/Logs/DiagnosticReports" diff --git a/tests/test_vision_hierarchy.py b/tests/test_vision_hierarchy.py new file mode 100644 index 0000000..a759c84 --- /dev/null +++ b/tests/test_vision_hierarchy.py @@ -0,0 +1,130 @@ +import unittest +import json +import sys +from unittest.mock import patch, MagicMock +from io import StringIO +import sid.commands.vision as vision + +class TestVisionHierarchy(unittest.TestCase): + + @patch('sid.commands.vision.get_ui_tree_hierarchical') + @patch('os.path.exists') + @patch('builtins.open', new_callable=unittest.mock.mock_open, read_data="com.dynamic.app") + def test_inspect_hierarchical(self, mock_file, mock_exists, mock_get_tree): + # Mock tree structure + mock_tree = { + "role": "Window", "AXIdentifier": "LoginView", "frame": {"x": 0, "y": 0, "w": 375, "h": 812}, + "nodes": [ + { + "role": "NavigationBar", + "nodes": [ + {"role": "Button", "AXIdentifier": "Back", "AXLabel": "Back", "frame": {"x": 0, "y": 20, "w": 50, "h": 50}} + ] + }, + { + "role": "Table", + "nodes": [ + {"role": "Cell", "AXLabel": "Row 1", "frame": {"x": 0, "y": 100, "w": 375, "h": 50}, "nodes": [ + {"role": "StaticText", "AXLabel": "Row 1 Text"} + ]} + ] + } + ] + } + mock_get_tree.return_value = mock_tree + mock_exists.return_value = True + + captured_output = StringIO() + sys.stdout = captured_output + try: + vision.inspect_cmd(interactive_only=True, flat=False) + finally: + sys.stdout = sys.__stdout__ + + output = captured_output.getvalue() + data = json.loads(output) + + self.assertEqual(data["app"], "com.dynamic.app") + self.assertEqual(data["screen_id"], "LoginView") + + # Check hierarchy + # Window is "structural" and "application" role, so it might be kept or pruned depending on logic + # logic: is_structural = role in [..., "window", "application"] + # so Window is structural. + # it has children (NavigationBar, Table), so it should be kept. + + self.assertEqual(len(data["elements"]), 1) + window = data["elements"][0] + self.assertEqual(window["type"], "Window") + + # Check children of Window + children = window.get("children", []) + self.assertEqual(len(children), 2) + + nav_bar = children[0] + self.assertEqual(nav_bar["type"], "NavigationBar") + self.assertEqual(len(nav_bar["children"]), 1) + self.assertEqual(nav_bar["children"][0]["type"], "Button") + + table = children[1] + self.assertEqual(table["type"], "Table") + self.assertEqual(len(table["children"]), 1) + cell = table["children"][0] + self.assertEqual(cell["type"], "Cell") + + @patch('sid.commands.vision.get_ui_tree') + @patch('os.path.exists') + @patch('builtins.open', new_callable=unittest.mock.mock_open, read_data="com.dynamic.app") + def test_inspect_flat(self, mock_file, mock_exists, mock_get_tree): + # Mock flat list + mock_data = [ + {"role": "Window", "AXIdentifier": "LoginView", "frame": {"x": 0, "y": 0, "w": 375, "h": 812}}, + {"role": "Button", "AXIdentifier": "btn1", "AXLabel": "Login", "frame": {"x": 10, "y": 20, "w": 100, "h": 50}}, + ] + mock_get_tree.return_value = mock_data + mock_exists.return_value = True + + captured_output = StringIO() + sys.stdout = captured_output + try: + vision.inspect_cmd(interactive_only=True, flat=True) + finally: + sys.stdout = sys.__stdout__ + + output = captured_output.getvalue() + data = json.loads(output) + + self.assertEqual(data["app"], "com.dynamic.app") + self.assertIn("elements", data) + # In flat mode, Window is filtered out if interactive_only=True + # because Window is not in valid_roles list for interactive_only in flat mode logic. + # Check flat mode logic: + # valid_roles = ["button", ...] -- Window is not there. + self.assertEqual(len(data["elements"]), 1) + self.assertEqual(data["elements"][0]["type"], "Button") + + def test_simplify_node_logic(self): + # Test pruning + # A Group (Unknown/Other) with no interactive children should be pruned + node = { + "role": "Group", + "nodes": [ + {"role": "Image", "AXIdentifier": "icon"} # Image is interactive? yes + ] + } + simplified = vision.simplify_node(node, interactive_only=True) + self.assertIsNotNone(simplified) + self.assertEqual(simplified["type"], "Group") + self.assertEqual(len(simplified["children"]), 1) + + node2 = { + "role": "Group", + "nodes": [ + {"role": "Other", "nodes": []} + ] + } + simplified2 = vision.simplify_node(node2, interactive_only=True) + self.assertIsNone(simplified2) + +if __name__ == "__main__": + unittest.main() From 863bc12ff635587376d827b46728fd7b26f02cae Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 06:10:58 +0000 Subject: [PATCH 2/2] feat: Rename project to Pippin and implement hierarchical UI inspection - Rename `sid` directory to `pippin`. - Update `pyproject.toml`, `README.md`, `SPEC.md`, and documentation to reflect the name change. - Update all Python imports and references from `sid` to `pippin`. - Add `get_ui_tree_hierarchical` to `pippin/utils/ui.py`. - Add `simplify_node` helper in `pippin/commands/vision.py` for hierarchical output. - Update `inspect_cmd` to default to hierarchical output, adding `--flat` flag for backward compatibility. - Add tests for hierarchical inspection and verify rename integrity. Co-authored-by: acrollet <101649+acrollet@users.noreply.github.com> --- README.md | 38 +++++++------- SPEC.md | 66 ++++++++++++------------ docs/improvements/01-hierarchy.md | 12 ++--- docs/improvements/02-subcommand-help.md | 22 ++++---- docs/improvements/03-context-command.md | 20 +++---- docs/improvements/04-error-model.md | 6 +-- docs/improvements/05-device-targeting.md | 28 +++++----- docs/improvements/06-element-matching.md | 14 ++--- docs/improvements/07-action-feedback.md | 18 +++---- docs/improvements/08-housekeeping.md | 12 ++--- docs/improvements/README.md | 4 +- {sid => pippin}/__init__.py | 0 {sid => pippin}/commands/__init__.py | 0 {sid => pippin}/commands/doctor.py | 6 +-- {sid => pippin}/commands/interaction.py | 4 +- {sid => pippin}/commands/system.py | 12 ++--- {sid => pippin}/commands/verification.py | 10 ++-- {sid => pippin}/commands/vision.py | 6 +-- {sid => pippin}/main.py | 24 ++++----- {sid => pippin}/utils/__init__.py | 0 {sid => pippin}/utils/executor.py | 0 {sid => pippin}/utils/ui.py | 2 +- pyproject.toml | 4 +- tests/test_commands.py | 48 ++++++++--------- tests/test_executor.py | 2 +- tests/test_vision_hierarchy.py | 6 +-- 26 files changed, 182 insertions(+), 182 deletions(-) rename {sid => pippin}/__init__.py (100%) rename {sid => pippin}/commands/__init__.py (100%) rename {sid => pippin}/commands/doctor.py (97%) rename {sid => pippin}/commands/interaction.py (97%) rename {sid => pippin}/commands/system.py (92%) rename {sid => pippin}/commands/verification.py (96%) rename {sid => pippin}/commands/vision.py (96%) rename {sid => pippin}/main.py (93%) rename {sid => pippin}/utils/__init__.py (100%) rename {sid => pippin}/utils/executor.py (100%) rename {sid => pippin}/utils/ui.py (98%) diff --git a/README.md b/README.md index e67fdb2..9e663ff 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Sid: A Token-Efficient CLI for iOS Automation +# Pippin: A Token-Efficient CLI for iOS Automation -**Sid** (Simulator Driver) is a command-line interface designed to bridge the gap between Large Language Models (LLMs) and the iOS Simulator. It provides a set of stateless, atomic commands to inspect, interact with, and verify the state of iOS applications running in the Simulator. +**Pippin** (Simulator Driver) is a command-line interface designed to bridge the gap between Large Language Models (LLMs) and the iOS Simulator. It provides a set of stateless, atomic commands to inspect, interact with, and verify the state of iOS applications running in the Simulator. ## Features @@ -10,86 +10,86 @@ ## Installation -You can run Sid directly using `uvx` (recommended): +You can run Pippin directly using `uvx` (recommended): ```bash -uvx sid --help +uvx pippin --help ``` Or install it via pip: ```bash -pip install sid +pip install pippin ``` *Note: You must have `idb` (iOS Development Bridge) and Xcode command-line tools installed and configured on your machine.* ## Usage -Sid commands follow the structure: `sid [command] [subcommand] [flags]` +Pippin commands follow the structure: `pippin [command] [subcommand] [flags]` ### Vision (Seeing the Screen) * **Inspect UI:** Get a JSON representation of the current screen. ```bash - sid inspect --interactive-only + pippin inspect --interactive-only ``` * **Take Screenshot:** Capture the visual state. ```bash - sid screenshot output.png + pippin screenshot output.png ``` ### Interaction (Acting on the App) * **Tap Element:** Tap by accessibility identifier or label text. ```bash - sid tap "Log In" + pippin tap "Log In" ``` * **Type Text:** Enter text into the focused field. ```bash - sid type "user@example.com" --submit + pippin type "user@example.com" --submit ``` * **Scroll:** Scroll in a direction, optionally until an element is found. ```bash - sid scroll down --until-visible "Submit" + pippin scroll down --until-visible "Submit" ``` * **Gestures:** Perform swipes. ```bash - sid gesture swipe 100,200 100,400 + pippin gesture swipe 100,200 100,400 ``` ### System (Controlling the Environment) * **Launch App:** Launch an app by Bundle ID. ```bash - sid launch com.example.myapp --clean + pippin launch com.example.myapp --clean ``` * **Open URL:** Open a deep link. ```bash - sid open "myapp://settings" + pippin open "myapp://settings" ``` * **Permissions:** Manage privacy permissions. ```bash - sid permission camera grant + pippin permission camera grant ``` * **Location:** Set simulated GPS coordinates. ```bash - sid location 37.7749 -122.4194 + pippin location 37.7749 -122.4194 ``` ### Verification (Checking State) * **Assert:** Verify UI state (exists, visible, hidden, text matches). ```bash - sid assert "Welcome Message" visible + pippin assert "Welcome Message" visible ``` * **Logs:** Fetch recent app logs. ```bash - sid logs --crash-report + pippin logs --crash-report ``` * **File Tree:** List files in the app's sandbox. ```bash - sid tree documents + pippin tree documents ``` ## Contributing diff --git a/SPEC.md b/SPEC.md index 2d6045f..420f478 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,16 +1,16 @@ -# Sid: A Token-Efficient CLI for iOS Automation +# Pippin: A Token-Efficient CLI for iOS Automation ## 1. Philosophy & Goals -**Sid** (Simulator Driver) is a command-line interface designed to bridge the gap between Large Language Models (LLMs) and the iOS Simulator. +**Pippin** (Simulator Driver) is a command-line interface designed to bridge the gap between Large Language Models (LLMs) and the iOS Simulator. -* **Token Efficiency (The "Narrow Context" Principle):** Sid’s primary output is a simplified, text-based JSON representation of the UI. This allows LLMs to "see" the screen using minimal tokens, avoiding the high cost and latency of processing raw screenshots. -* **Stateless Atomic Actions:** Each command is independent. Sid does not maintain a complex session, making it easier for an Agent to reason about the state at any given step. -* **Native Wrapper:** Under the hood, Sid orchestrates `xcrun simctl` (for system tasks) and `idb` (for deep accessibility inspection). +* **Token Efficiency (The "Narrow Context" Principle):** Pippin’s primary output is a simplified, text-based JSON representation of the UI. This allows LLMs to "see" the screen using minimal tokens, avoiding the high cost and latency of processing raw screenshots. +* **Stateless Atomic Actions:** Each command is independent. Pippin does not maintain a complex session, making it easier for an Agent to reason about the state at any given step. +* **Native Wrapper:** Under the hood, Pippin orchestrates `xcrun simctl` (for system tasks) and `idb` (for deep accessibility inspection). --- ## 2. Architecture -* **Interface:** `sid [command] [subcommand] [flags]` +* **Interface:** `pippin [command] [subcommand] [flags]` * **Output Format:** Standard JSON (for machine parsing) or human-readable text. * **Error Handling:** Returns strictly formatted error codes and descriptive messages to help the LLM self-correct (e.g., `ERR_ELEMENT_NOT_FOUND`, `ERR_APP_CRASHED`). @@ -21,7 +21,7 @@ ### 3.1. Vision (The "See" Commands) *These commands generate the context for the LLM to understand the current state.* -#### `sid inspect` +#### `pippin inspect` Returns a simplified JSON tree of the current screen's accessibility hierarchy. * **Flag:** `--interactive-only` (Default: `true`). Filters out structural containers (`Window`, `Other`) and keeps actionable elements (`Button`, `TextField`, `Cell`, `Switch`, `StaticText`). * **Flag:** `--depth [n]`. Limits the hierarchy depth to save tokens. @@ -55,7 +55,7 @@ Returns a simplified JSON tree of the current screen's accessibility hierarchy. } ``` -#### `sid screenshot` +#### `pippin screenshot` Captures the visual state for verification or multimodal fallback. * **Args:** `[filename]` * **Flag:** `--mask-text` (Optional). Redacts text for privacy/security before saving. @@ -65,57 +65,57 @@ Captures the visual state for verification or multimodal fallback. ### 3.2. Interaction (The "Act" Commands) *Direct manipulation of the app UI.* -#### `sid tap` +#### `pippin tap` Taps a UI element. * **Targeting Logic:** Accepts a string query. 1. **Exact Match:** Accessibility Identifier. 2. **Fuzzy Match:** Label text (e.g., "Login" matches "Log In"). 3. **Coordinate Fallback:** `--x [num] --y [num]`. -* **Example:** `sid tap "Sign Up"` +* **Example:** `pippin tap "Sign Up"` -#### `sid type` +#### `pippin type` Inputs text into the currently focused field. * **Args:** `[text_string]` * **Flag:** `--submit` (Default: `false`). Hits "Return/Enter" on the keyboard after typing. -* **Example:** `sid type "user@example.com" --submit` +* **Example:** `pippin type "user@example.com" --submit` -#### `sid scroll` +#### `pippin scroll` * **Args:** `[direction]` (`up`, `down`, `left`, `right`). * **Flag:** `--until-visible [element_label]`. A specialized loop that scrolls until a specific element appears in the `inspect` tree. -#### `sid gesture` -* **Swipe:** `sid gesture swipe [start_x],[start_y] [end_x],[end_y]` -* **Pinch:** `sid gesture pinch [in|out]` +#### `pippin gesture` +* **Swipe:** `pippin gesture swipe [start_x],[start_y] [end_x],[end_y]` +* **Pinch:** `pippin gesture pinch [in|out]` --- ### 3.3. System & Environment (The "God Mode") *Developers need to test how the app behaves under different system conditions.* -#### `sid launch` +#### `pippin launch` * **Args:** `[bundle_id]` * **Flag:** `--clean`. Wipes the app container (simulates a fresh install). * **Flag:** `--args "[key]=[value]"`. Passes Launch Arguments (e.g., `-TakingScreenshots YES`). * **Flag:** `--locale [code]`. Launches the app in a specific language (e.g., `es-MX`). -#### `sid open` +#### `pippin open` Opens a URL scheme or Universal Link to test routing. * **Args:** `[url]` -* **Example:** `sid open "myapp://settings/profile?edit=true"` +* **Example:** `pippin open "myapp://settings/profile?edit=true"` -#### `sid permission` +#### `pippin permission` Manages TCC (Privacy) permissions to test "Happy Path" vs. "Denied Path". * **Args:** `[service] [status]` * **Services:** `camera`, `photos`, `location`, `microphone`, `contacts`, `calendar`. * **Status:** `grant`, `deny`, `reset`. -* **Example:** `sid permission camera deny` +* **Example:** `pippin permission camera deny` -#### `sid location` +#### `pippin location` Simulates GPS coordinates. * **Args:** `[lat] [lon]` -* **Example:** `sid location 37.7749 -122.4194` (San Francisco) +* **Example:** `pippin location 37.7749 -122.4194` (San Francisco) -#### `sid network` (Advanced) +#### `pippin network` (Advanced) * **Args:** `[condition]` * **Options:** `wifi`, `cellular`, `offline`. @@ -124,18 +124,18 @@ Simulates GPS coordinates. ### 3.4. Verification & Debugging (The "Check" Commands) *Tools for the LLM to verify success or diagnose failure.* -#### `sid assert` +#### `pippin assert` Quick boolean check for LLM usage. * **Args:** `[element_query] [state]` * **States:** `exists`, `visible`, `hidden`, `text=[value]`. * **Output:** `PASS` or `FAIL: Element found but text was 'Cancel', expected 'Submit'`. -#### `sid logs` +#### `pippin logs` Fetches the tail of the system log for the target app. * **Flag:** `--crash-report`. Checks if a crash log was generated in the last session and outputs the stack trace. * **Use Case:** "The app closed unexpectedly. Why?" -#### `sid tree` +#### `pippin tree` Lists files in the app's sandbox. * **Args:** `[directory]` (`documents`, `caches`, `tmp`). * **Use Case:** Verifying that a file download or database export actually occurred. @@ -146,11 +146,11 @@ Lists files in the app's sandbox. **Objective:** "Verify that the app handles denied Camera permissions gracefully." -1. `sid launch com.myapp.beta --clean` -2. `sid inspect` -> Finds "Start Scan" button. -3. `sid permission camera deny` (Pre-emptively deny permission). -4. `sid tap "Start Scan"` -5. `sid inspect` +1. `pippin launch com.myapp.beta --clean` +2. `pippin inspect` -> Finds "Start Scan" button. +3. `pippin permission camera deny` (Pre-emptively deny permission). +4. `pippin tap "Start Scan"` +5. `pippin inspect` * **Agent Logic:** Looks for an alert with text "Camera Permission Needed" or "Open Settings". - * **If found:** `sid assert "Open Settings" visible` -> Returns `PASS`. + * **If found:** `pippin assert "Open Settings" visible` -> Returns `PASS`. * **If not found:** Agent marks test as `FAILED` (App likely stalled or crashed). diff --git a/docs/improvements/01-hierarchy.md b/docs/improvements/01-hierarchy.md index fde2e1a..3ad3b81 100644 --- a/docs/improvements/01-hierarchy.md +++ b/docs/improvements/01-hierarchy.md @@ -1,8 +1,8 @@ # 01: Preserve UI Hierarchy in Inspect Output -**Impact:** Critical — this is the single biggest reason AI agents struggle with Sid. +**Impact:** Critical — this is the single biggest reason AI agents struggle with Pippin. **Effort:** Medium -**Files:** `sid/utils/ui.py`, `sid/commands/vision.py` +**Files:** `pippin/utils/ui.py`, `pippin/commands/vision.py` ## Problem @@ -108,10 +108,10 @@ def simplify_node(node, interactive_only=False, depth=None, current_depth=0): ### 3. Update `inspect_cmd` to use hierarchy by default ``` -sid inspect → hierarchical output (new default) -sid inspect --flat → current flat behavior (backward compat) -sid inspect --all → hierarchical, no filtering -sid inspect --depth 3 → limit nesting depth +pippin inspect → hierarchical output (new default) +pippin inspect --flat → current flat behavior (backward compat) +pippin inspect --all → hierarchical, no filtering +pippin inspect --depth 3 → limit nesting depth ``` ### Example: Hierarchical Output diff --git a/docs/improvements/02-subcommand-help.md b/docs/improvements/02-subcommand-help.md index b44ea7a..c980339 100644 --- a/docs/improvements/02-subcommand-help.md +++ b/docs/improvements/02-subcommand-help.md @@ -2,7 +2,7 @@ **Impact:** High — AI agents (and humans) cannot discover argument syntax. **Effort:** Small -**Files:** `sid/main.py` +**Files:** `pippin/main.py` ## Problem @@ -10,12 +10,12 @@ Lines 10-50 of `main.py` intercept `-h` / `--help` before argparse processes the ```python if "-h" in sys.argv or "--help" in sys.argv: - print("Sid: A CLI for iOS Automation") + print("Pippin: A CLI for iOS Automation") print("""...""") sys.exit(0) ``` -This means `sid tap --help`, `sid inspect -h`, `sid launch --help` all print the same top-level overview. The per-subcommand parsers have detailed argument definitions (e.g., `--interactive-only`, `--depth`, `--submit`, `--until-visible`) but they're completely invisible. +This means `pippin tap --help`, `pippin inspect -h`, `pippin launch --help` all print the same top-level overview. The per-subcommand parsers have detailed argument definitions (e.g., `--interactive-only`, `--depth`, `--submit`, `--until-visible`) but they're completely invisible. ## Proposed Fix @@ -33,7 +33,7 @@ Remove the manual help interception entirely and let argparse handle it natively ```python DESCRIPTION = """\ -Sid: A Token-Efficient CLI for iOS Automation +Pippin: A Token-Efficient CLI for iOS Automation Vision: inspect Inspect UI hierarchy and return a simplified JSON tree @@ -66,7 +66,7 @@ Utils: parser = argparse.ArgumentParser( description=DESCRIPTION, formatter_class=argparse.RawDescriptionHelpFormatter, - usage="sid [command] [options]", + usage="pippin [command] [options]", ) ``` @@ -75,10 +75,10 @@ parser = argparse.ArgumentParser( After this change: ``` -$ sid --help → Shows the grouped overview + global options -$ sid tap --help → Shows: "Tap a UI element" + args/flags for tap -$ sid inspect --help → Shows: --interactive-only, --all, --depth flags -$ sid launch --help → Shows: bundle_id, --clean, --args, --locale +$ pippin --help → Shows the grouped overview + global options +$ pippin tap --help → Shows: "Tap a UI element" + args/flags for tap +$ pippin inspect --help → Shows: --interactive-only, --all, --depth flags +$ pippin launch --help → Shows: bundle_id, --clean, --args, --locale ``` ### Also fix the `except SystemExit` block @@ -93,6 +93,6 @@ No try/except needed — argparse will print help and exit cleanly on its own. ## Testing -- Verify `sid -h` still shows the grouped overview. -- Verify `sid -h` shows per-subcommand args. +- Verify `pippin -h` still shows the grouped overview. +- Verify `pippin -h` shows per-subcommand args. - Verify that invalid arguments produce useful error messages (argparse does this by default). diff --git a/docs/improvements/03-context-command.md b/docs/improvements/03-context-command.md index 8cacc48..b6f2420 100644 --- a/docs/improvements/03-context-command.md +++ b/docs/improvements/03-context-command.md @@ -2,15 +2,15 @@ **Impact:** High — reduces multi-call orientation to a single call. **Effort:** Medium -**Files:** new `sid/commands/context.py`, `sid/main.py` +**Files:** new `pippin/commands/context.py`, `pippin/main.py` ## Problem To understand "where am I and what can I do?", an AI agent currently needs: -1. `sid inspect` — what elements are on screen? -2. `sid screenshot` — what does it look like? (if multimodal) -3. `sid logs` — did anything go wrong? +1. `pippin inspect` — what elements are on screen? +2. `pippin screenshot` — what does it look like? (if multimodal) +3. `pippin logs` — did anything go wrong? 4. Manual reasoning about which app is running, what screen this is, etc. That's 2-3 round-trips minimum, each costing latency and tokens. The agent also has to synthesize the results itself, which is error-prone. @@ -18,7 +18,7 @@ That's 2-3 round-trips minimum, each costing latency and tokens. The agent also ## Proposed Command ``` -sid context [--include-logs] [--screenshot ] +pippin context [--include-logs] [--screenshot ] ``` Returns a single JSON blob with everything an AI needs to orient: @@ -64,7 +64,7 @@ Returns a single JSON blob with everything an AI needs to orient: ## Implementation -### `sid/commands/context.py` +### `pippin/commands/context.py` ```python def context_cmd(include_logs=False, screenshot_path=None): @@ -146,10 +146,10 @@ No. `inspect` is for targeted UI queries during a flow. `context` is for orienta ## CLI Integration ``` -sid context → full context, no logs, no screenshot -sid context --include-logs → include last 20 lines of app logs -sid context --screenshot state.png → also capture a screenshot -sid context --brief → just screen metadata, no UI tree +pippin context → full context, no logs, no screenshot +pippin context --include-logs → include last 20 lines of app logs +pippin context --screenshot state.png → also capture a screenshot +pippin context --brief → just screen metadata, no UI tree ``` ## Testing diff --git a/docs/improvements/04-error-model.md b/docs/improvements/04-error-model.md index 749cece..96c5045 100644 --- a/docs/improvements/04-error-model.md +++ b/docs/improvements/04-error-model.md @@ -2,7 +2,7 @@ **Impact:** High — AI agents rely on exit codes to know if a command succeeded. **Effort:** Small -**Files:** all command files, `sid/utils/errors.py` (new) +**Files:** all command files, `pippin/utils/errors.py` (new) ## Problem @@ -18,11 +18,11 @@ Exit code behavior is inconsistent across commands: | `logs` (no target app) | Prints to stderr | `0` | | `launch` (error) | Prints to stderr | `0` | -An AI agent running `sid tap "Nonexistent"` gets exit code 0 and thinks it succeeded. +An AI agent running `pippin tap "Nonexistent"` gets exit code 0 and thinks it succeeded. ## Proposed Error Model -### 1. Define error codes in `sid/utils/errors.py` +### 1. Define error codes in `pippin/utils/errors.py` ```python import sys diff --git a/docs/improvements/05-device-targeting.md b/docs/improvements/05-device-targeting.md index 2805caa..a89c0a5 100644 --- a/docs/improvements/05-device-targeting.md +++ b/docs/improvements/05-device-targeting.md @@ -2,7 +2,7 @@ **Impact:** Medium — tool completely breaks with multiple simulators. **Effort:** Small -**Files:** `sid/main.py`, `sid/utils/ui.py`, `sid/utils/device.py` (new) +**Files:** `pippin/main.py`, `pippin/utils/ui.py`, `pippin/utils/device.py` (new) ## Problem @@ -13,7 +13,7 @@ No udid provided and there are multiple companions to run against dict_keys(['61B8F683-...', 'BB13ECAA-...']) ``` -There's no `--udid` or `--device` flag anywhere in sid. The tool uses `"booted"` for `simctl` commands but `idb` needs explicit targeting when multiple companions are registered. +There's no `--udid` or `--device` flag anywhere in pippin. The tool uses `"booted"` for `simctl` commands but `idb` needs explicit targeting when multiple companions are registered. ## Proposed Changes @@ -25,16 +25,16 @@ In `main.py`, add a global argument before the subparsers: parser.add_argument( "--device", help="Target simulator UDID. Defaults to the booted simulator. " - "Use 'sid doctor' to list available devices.", + "Use 'pippin doctor' to list available devices.", default=None, ) ``` -### 2. Create `sid/utils/device.py` +### 2. Create `pippin/utils/device.py` ```python import json -from sid.utils.executor import execute_command +from pippin.utils.executor import execute_command _target_udid = None @@ -84,7 +84,7 @@ def get_simctl_target(): In `ui.py`, update `ensure_idb_connected()` and all `idb` calls to use the target UDID: ```python -from sid.utils.device import get_target_udid +from pippin.utils.device import get_target_udid def get_ui_tree(silent=False): udid = get_target_udid() @@ -96,9 +96,9 @@ def get_ui_tree(silent=False): ### 4. Enhance `doctor` to list devices ``` -$ sid doctor +$ pippin doctor -Checking Sid dependencies... +Checking Pippin dependencies... ✅ idb found ✅ xcrun found @@ -106,10 +106,10 @@ Simulators: BB13ECAA-... iPhone 16 Pro iOS 18.2 Booted ← active 61B8F683-... iPhone 15 iOS 17.5 Booted -✨ Sid is ready. Using: iPhone 16 Pro (BB13ECAA-...) +✨ Pippin is ready. Using: iPhone 16 Pro (BB13ECAA-...) ``` -This helps the user (and AI) discover available devices and understand which one sid will target. +This helps the user (and AI) discover available devices and understand which one pippin will target. ### 5. Wire it up in `main.py` @@ -117,7 +117,7 @@ This helps the user (and AI) discover available devices and understand which one args = parser.parse_args() if args.device: - from sid.utils.device import set_target_device + from pippin.utils.device import set_target_device set_target_device(args.device) # ... dispatch to command @@ -125,11 +125,11 @@ if args.device: ## Environment Variable Fallback -Also support `SID_DEVICE_UDID` env var so users can set it once per terminal session: +Also support `PIPPIN_DEVICE_UDID` env var so users can set it once per terminal session: ```bash -export SID_DEVICE_UDID=BB13ECAA-05F4-4E3B-A220-235BDBADFAB5 -sid inspect # uses that device +export PIPPIN_DEVICE_UDID=BB13ECAA-05F4-4E3B-A220-235BDBADFAB5 +pippin inspect # uses that device ``` ## Testing diff --git a/docs/improvements/06-element-matching.md b/docs/improvements/06-element-matching.md index 665224c..3de4a2d 100644 --- a/docs/improvements/06-element-matching.md +++ b/docs/improvements/06-element-matching.md @@ -2,7 +2,7 @@ **Impact:** Medium — reduces "tapped the wrong thing" failures. **Effort:** Medium -**Files:** `sid/utils/ui.py` +**Files:** `pippin/utils/ui.py` ## Problem @@ -11,7 +11,7 @@ 1. **Exact match on AXIdentifier** — good, but many elements lack identifiers. 2. **Substring match on AXLabel** — returns the *first* element containing the query. -The substring match is fragile. `sid tap "General"` will match whichever of these comes first in the flat list: +The substring match is fragile. `pippin tap "General"` will match whichever of these comes first in the flat list: - "General" (the cell we want) - "General Settings" (a different cell) - "In General, ..." (a description label) @@ -73,8 +73,8 @@ def find_element(query: str, silent=False, strict=False): ### 2. Add `--strict` flag to `tap` and `assert` ``` -sid tap "General" --strict # Only exact ID or exact label match -sid tap "General" # Current behavior (substring fallback) +pippin tap "General" --strict # Only exact ID or exact label match +pippin tap "General" # Current behavior (substring fallback) ``` ### 3. Ambiguity reporting @@ -94,8 +94,8 @@ The AI sees this warning on stderr and can decide whether to retry with a more s Allow `type:label` syntax to disambiguate: ``` -sid tap "Cell:General" # Only match cells labeled "General" -sid tap "Button:Cancel" # Only match buttons labeled "Cancel" +pippin tap "Cell:General" # Only match cells labeled "General" +pippin tap "Button:Cancel" # Only match buttons labeled "Cancel" ``` Implementation in `find_element`: @@ -119,7 +119,7 @@ if element_type: When inspect shows numbered elements, allow tapping by index: ``` -sid tap --index 3 # Tap the 3rd interactive element on screen +pippin tap --index 3 # Tap the 3rd interactive element on screen ``` This is a last resort but useful when labels are ambiguous or missing. diff --git a/docs/improvements/07-action-feedback.md b/docs/improvements/07-action-feedback.md index dea3453..27c067f 100644 --- a/docs/improvements/07-action-feedback.md +++ b/docs/improvements/07-action-feedback.md @@ -2,17 +2,17 @@ **Impact:** Medium — eliminates the mandatory inspect-after-every-action pattern. **Effort:** Small -**Files:** `sid/commands/interaction.py`, `sid/main.py` +**Files:** `pippin/commands/interaction.py`, `pippin/main.py` ## Problem -The typical AI agent loop with sid looks like: +The typical AI agent loop with pippin looks like: ``` -sid tap "Settings" → "Tapped at 187, 340" -sid inspect → { ... new screen ... } -sid tap "General" → "Tapped at 187, 540" -sid inspect → { ... new screen ... } +pippin tap "Settings" → "Tapped at 187, 340" +pippin inspect → { ... new screen ... } +pippin tap "General" → "Tapped at 187, 540" +pippin inspect → { ... new screen ... } ``` Every action requires a follow-up `inspect` to see what happened. That's 2x the calls needed. @@ -24,7 +24,7 @@ Every action requires a follow-up `inspect` to see what happened. That's 2x the Add a global flag that appends an `inspect` result after any interaction command: ``` -sid tap "Settings" --inspect +pippin tap "Settings" --inspect ``` Output: @@ -86,7 +86,7 @@ else: UI transitions take time. The `--inspect` should include a configurable settle delay: ``` -sid tap "Settings" --inspect --settle 0.5 # Wait 500ms before inspecting +pippin tap "Settings" --inspect --settle 0.5 # Wait 500ms before inspecting ``` Default: 300ms. This avoids capturing mid-animation states. @@ -96,7 +96,7 @@ Default: 300ms. This avoids capturing mid-animation states. Combine with the wait command for transitions: ``` -sid tap "Settings" --wait "General" --inspect +pippin tap "Settings" --wait "General" --inspect ``` This means: tap Settings, wait until "General" appears in the UI, then return the inspect result. Useful for navigation transitions where the AI knows what to expect on the next screen. diff --git a/docs/improvements/08-housekeeping.md b/docs/improvements/08-housekeeping.md index 2c5eb12..6cde6fa 100644 --- a/docs/improvements/08-housekeeping.md +++ b/docs/improvements/08-housekeeping.md @@ -8,19 +8,19 @@ ### 1. Duplicated STATE_FILE constant -`STATE_FILE = "/tmp/sid_last_bundle_id"` is defined in both: -- `sid/commands/system.py:6` -- `sid/commands/verification.py:7` +`STATE_FILE = "/tmp/pippin_last_bundle_id"` is defined in both: +- `pippin/commands/system.py:6` +- `pippin/commands/verification.py:7` -And it's also read inline in `sid/commands/vision.py:35-42`. +And it's also read inline in `pippin/commands/vision.py:35-42`. **Fix:** Move to a shared location: ```python -# sid/utils/state.py +# pippin/utils/state.py import os -STATE_FILE = "/tmp/sid_last_bundle_id" +STATE_FILE = "/tmp/pippin_last_bundle_id" def get_last_bundle_id() -> str | None: if os.path.exists(STATE_FILE): diff --git a/docs/improvements/README.md b/docs/improvements/README.md index 6c73f60..09e5b83 100644 --- a/docs/improvements/README.md +++ b/docs/improvements/README.md @@ -1,6 +1,6 @@ -# Sid Improvement Plan +# Pippin Improvement Plan -Recommendations for making Sid more effective as an AI-agent bridge to the iOS Simulator. Organized into independent workstreams that can be tackled in any order, though the suggested priority reflects impact. +Recommendations for making Pippin more effective as an AI-agent bridge to the iOS Simulator. Organized into independent workstreams that can be tackled in any order, though the suggested priority reflects impact. ## Priority Order diff --git a/sid/__init__.py b/pippin/__init__.py similarity index 100% rename from sid/__init__.py rename to pippin/__init__.py diff --git a/sid/commands/__init__.py b/pippin/commands/__init__.py similarity index 100% rename from sid/commands/__init__.py rename to pippin/commands/__init__.py diff --git a/sid/commands/doctor.py b/pippin/commands/doctor.py similarity index 97% rename from sid/commands/doctor.py rename to pippin/commands/doctor.py index 463111e..56aab94 100644 --- a/sid/commands/doctor.py +++ b/pippin/commands/doctor.py @@ -2,7 +2,7 @@ import sys import os import subprocess -from sid.utils.executor import execute_command +from pippin.utils.executor import execute_command def _install_idb(): print("\nAttempting to install idb dependencies...") @@ -41,7 +41,7 @@ def _install_idb(): return False def doctor_cmd(): - print("Checking Sid dependencies...\n") + print("Checking Pippin dependencies...\n") dependencies = { "idb": "Essential for UI inspection and advanced interactions.", @@ -112,7 +112,7 @@ def doctor_cmd(): all_passed = False if all_passed: - print("\n✨ Sid is ready to go!") + print("\n✨ Pippin is ready to go!") else: print("\n⚠️ Some dependencies are missing or misconfigured.") sys.exit(1) diff --git a/sid/commands/interaction.py b/pippin/commands/interaction.py similarity index 97% rename from sid/commands/interaction.py rename to pippin/commands/interaction.py index 1ca345c..13b49cd 100644 --- a/sid/commands/interaction.py +++ b/pippin/commands/interaction.py @@ -1,7 +1,7 @@ import sys import time -from sid.utils.executor import execute_command -from sid.utils.ui import get_ui_tree, find_element, get_center +from pippin.utils.executor import execute_command +from pippin.utils.ui import get_ui_tree, find_element, get_center def tap_cmd(query: str = None, x: int = None, y: int = None): target_x, target_y = None, None diff --git a/sid/commands/system.py b/pippin/commands/system.py similarity index 92% rename from sid/commands/system.py rename to pippin/commands/system.py index 2513966..45613b7 100644 --- a/sid/commands/system.py +++ b/pippin/commands/system.py @@ -1,9 +1,9 @@ import sys import os import shlex -from sid.utils.executor import execute_command +from pippin.utils.executor import execute_command -STATE_FILE = "/tmp/sid_last_bundle_id" +STATE_FILE = "/tmp/pippin_last_bundle_id" def _get_app_container(bundle_id): try: @@ -22,7 +22,7 @@ def launch_cmd(bundle_id: str, clean: bool = False, args: str = None, locale: st pass if not bundle_id: - print("ERR_NO_TARGET_APP: Could not determine target app. Run 'sid launch' first or provide a bundle ID.", file=sys.stderr) + print("ERR_NO_TARGET_APP: Could not determine target app. Run 'pippin launch' first or provide a bundle ID.", file=sys.stderr) return if clean: @@ -68,7 +68,7 @@ def stop_cmd(bundle_id: str = None): pass if not bundle_id: - print("ERR_NO_TARGET_APP: Could not determine target app. Run 'sid launch' first or provide a bundle ID.", file=sys.stderr) + print("ERR_NO_TARGET_APP: Could not determine target app. Run 'pippin launch' first or provide a bundle ID.", file=sys.stderr) return try: @@ -87,7 +87,7 @@ def relaunch_cmd(bundle_id: str = None, clean: bool = False, args: str = None, l pass if not bundle_id: - print("ERR_NO_TARGET_APP: Could not determine target app. Run 'sid launch' first or provide a bundle ID.", file=sys.stderr) + print("ERR_NO_TARGET_APP: Could not determine target app. Run 'pippin launch' first or provide a bundle ID.", file=sys.stderr) return stop_cmd(bundle_id) @@ -110,7 +110,7 @@ def permission_cmd(service: str, status: str): pass if not bundle_id: - print("ERR_NO_TARGET_APP: Could not determine target app. Run 'sid launch' first.", file=sys.stderr) + print("ERR_NO_TARGET_APP: Could not determine target app. Run 'pippin launch' first.", file=sys.stderr) return try: diff --git a/sid/commands/verification.py b/pippin/commands/verification.py similarity index 96% rename from sid/commands/verification.py rename to pippin/commands/verification.py index 0e1ce64..9b06d00 100644 --- a/sid/commands/verification.py +++ b/pippin/commands/verification.py @@ -1,10 +1,10 @@ import sys import os import time -from sid.utils.executor import execute_command -from sid.utils.ui import find_element +from pippin.utils.executor import execute_command +from pippin.utils.ui import find_element -STATE_FILE = "/tmp/sid_last_bundle_id" +STATE_FILE = "/tmp/pippin_last_bundle_id" def wait_cmd(query: str, timeout: float = 10.0, state: str = "visible"): """Waits for an element to reach a certain state.""" @@ -67,7 +67,7 @@ def logs_cmd(crash_report: bool = False): pass if not bundle_id: - print("ERR_NO_TARGET_APP: Could not determine target app. Run 'sid launch' first.", file=sys.stderr) + print("ERR_NO_TARGET_APP: Could not determine target app. Run 'pippin launch' first.", file=sys.stderr) return if crash_report: @@ -133,7 +133,7 @@ def tree_cmd(directory: str): pass if not bundle_id: - print("ERR_NO_TARGET_APP: Could not determine target app. Run 'sid launch' first.", file=sys.stderr) + print("ERR_NO_TARGET_APP: Could not determine target app. Run 'pippin launch' first.", file=sys.stderr) return subpath = "" diff --git a/sid/commands/vision.py b/pippin/commands/vision.py similarity index 96% rename from sid/commands/vision.py rename to pippin/commands/vision.py index 3922337..a5f62e0 100644 --- a/sid/commands/vision.py +++ b/pippin/commands/vision.py @@ -1,7 +1,7 @@ import json import sys -from sid.utils.executor import execute_command -from sid.utils.ui import get_ui_tree, get_ui_tree_hierarchical +from pippin.utils.executor import execute_command +from pippin.utils.ui import get_ui_tree, get_ui_tree_hierarchical def simplify_node(node, interactive_only=False, depth=None, current_depth=0): """Recursively simplify a node, keeping children nested.""" @@ -56,7 +56,7 @@ def inspect_cmd(interactive_only: bool = True, depth: int = None, flat: bool = F try: # Detect app (common) detected_bundle = "unknown" - from sid.commands.verification import STATE_FILE + from pippin.commands.verification import STATE_FILE import os if os.path.exists(STATE_FILE): try: diff --git a/sid/main.py b/pippin/main.py similarity index 93% rename from sid/main.py rename to pippin/main.py index 3053ad7..b2d533a 100644 --- a/sid/main.py +++ b/pippin/main.py @@ -1,14 +1,14 @@ import argparse import sys -from sid.commands.vision import inspect_cmd, screenshot_cmd -from sid.commands.interaction import tap_cmd, type_cmd, scroll_cmd, gesture_cmd -from sid.commands.system import launch_cmd, stop_cmd, relaunch_cmd, open_cmd, permission_cmd, location_cmd, network_cmd -from sid.commands.verification import assert_cmd, logs_cmd, tree_cmd, wait_cmd -from sid.commands.doctor import doctor_cmd +from pippin.commands.vision import inspect_cmd, screenshot_cmd +from pippin.commands.interaction import tap_cmd, type_cmd, scroll_cmd, gesture_cmd +from pippin.commands.system import launch_cmd, stop_cmd, relaunch_cmd, open_cmd, permission_cmd, location_cmd, network_cmd +from pippin.commands.verification import assert_cmd, logs_cmd, tree_cmd, wait_cmd +from pippin.commands.doctor import doctor_cmd def main(): if "-h" in sys.argv or "--help" in sys.argv: - print("Sid: A CLI for iOS Automation") + print("Pippin: A CLI for iOS Automation") print(""" Vision: inspect Inspect UI hierarchy and return a simplified JSON tree. @@ -42,16 +42,16 @@ def main(): -h, --help Show this help message Examples: - sid launch com.apple.Preferences --clean - sid inspect - sid tap "Settings" - sid assert "General" visible + pippin launch com.apple.Preferences --clean + pippin inspect + pippin tap "Settings" + pippin assert "General" visible """) sys.exit(0) parser = argparse.ArgumentParser( - description="Sid: A CLI for iOS Automation", - usage="sid [command] [options]", + description="Pippin: A CLI for iOS Automation", + usage="pippin [command] [options]", add_help=False ) parser.add_argument('-h', '--help', action='store_true') diff --git a/sid/utils/__init__.py b/pippin/utils/__init__.py similarity index 100% rename from sid/utils/__init__.py rename to pippin/utils/__init__.py diff --git a/sid/utils/executor.py b/pippin/utils/executor.py similarity index 100% rename from sid/utils/executor.py rename to pippin/utils/executor.py diff --git a/sid/utils/ui.py b/pippin/utils/ui.py similarity index 98% rename from sid/utils/ui.py rename to pippin/utils/ui.py index 201e47c..eede7f5 100644 --- a/sid/utils/ui.py +++ b/pippin/utils/ui.py @@ -1,7 +1,7 @@ import sys import json import subprocess -from sid.utils.executor import execute_command +from pippin.utils.executor import execute_command def flatten_tree(nodes): flat_list = [] diff --git a/pyproject.toml b/pyproject.toml index 281bc21..c59b1b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "sid" +name = "pippin" version = "0.1.0" description = "A CLI for iOS Automation" readme = "README.md" @@ -7,7 +7,7 @@ requires-python = ">=3.9" dependencies = [] [project.scripts] -sid = "sid.main:main" +pippin = "pippin.main:main" [build-system] requires = ["hatchling"] diff --git a/tests/test_commands.py b/tests/test_commands.py index 3e4ea93..105fd6d 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -5,14 +5,14 @@ from io import StringIO # We need to import the modules to patch them -import sid.commands.vision as vision -import sid.commands.interaction as interaction -import sid.commands.system as system -import sid.commands.verification as verification +import pippin.commands.vision as vision +import pippin.commands.interaction as interaction +import pippin.commands.system as system +import pippin.commands.verification as verification class TestCommands(unittest.TestCase): - @patch('sid.commands.vision.get_ui_tree') + @patch('pippin.commands.vision.get_ui_tree') @patch('os.path.exists') @patch('builtins.open', new_callable=unittest.mock.mock_open, read_data="com.dynamic.app") def test_inspect_basic(self, mock_file, mock_exists, mock_get_tree): @@ -38,8 +38,8 @@ def test_inspect_basic(self, mock_file, mock_exists, mock_get_tree): self.assertIn('"type": "Button"', output) self.assertNotIn('"type": "Window"', output) # Interactive only filters window - @patch('sid.utils.ui.get_ui_tree') # Patch where find_element looks it up - @patch('sid.commands.interaction.execute_command') + @patch('pippin.utils.ui.get_ui_tree') # Patch where find_element looks it up + @patch('pippin.commands.interaction.execute_command') def test_tap_with_query(self, mock_exec, mock_get_tree): # Mock tree mock_data = [ @@ -52,7 +52,7 @@ def test_tap_with_query(self, mock_exec, mock_get_tree): # Expect tap call with center: 10+50=60, 20+25=45 mock_exec.assert_any_call(["idb", "ui", "tap", "60.0", "45.0"]) - @patch('sid.commands.system.execute_command') + @patch('pippin.commands.system.execute_command') def test_launch(self, mock_exec): system.launch_cmd("com.test.app", clean=True, args="-flag val", locale="en_US") @@ -63,8 +63,8 @@ def test_launch(self, mock_exec): expected_launch = ["xcrun", "simctl", "launch", "booted", "com.test.app", "-AppleLanguages", "(en_US)", "-AppleLocale", "en_US", "-flag", "val"] mock_exec.assert_any_call(expected_launch) - @patch('sid.commands.verification.execute_command') - @patch('sid.utils.ui.get_ui_tree') + @patch('pippin.commands.verification.execute_command') + @patch('pippin.utils.ui.get_ui_tree') def test_assert_exists(self, mock_get_tree, mock_exec): mock_data = [ {"role": "Button", "AXIdentifier": "btn1", "AXLabel": "Login"} @@ -81,7 +81,7 @@ def test_assert_exists(self, mock_get_tree, mock_exec): output = captured_output.getvalue().strip() self.assertEqual(output, "PASS") - @patch('sid.commands.interaction.get_ui_tree') + @patch('pippin.commands.interaction.get_ui_tree') def test_scroll_dynamic_dimensions(self, mock_get_tree): # Mock a large window (e.g. iPad) mock_data = [ @@ -89,7 +89,7 @@ def test_scroll_dynamic_dimensions(self, mock_get_tree): ] mock_get_tree.return_value = mock_data - with patch('sid.commands.interaction.execute_command') as mock_exec: + with patch('pippin.commands.interaction.execute_command') as mock_exec: interaction.scroll_cmd("down") # Get the actual call arguments @@ -107,7 +107,7 @@ def test_scroll_dynamic_dimensions(self, mock_get_tree): self.assertAlmostEqual(float(cmd[5]), 512.0) self.assertAlmostEqual(float(cmd[6]), 409.8) - @patch('sid.utils.ui.get_ui_tree') + @patch('pippin.utils.ui.get_ui_tree') def test_tap_error_code(self, mock_get_tree): mock_get_tree.return_value = [] # No elements @@ -131,7 +131,7 @@ def test_logs_crash_report(self, mock_file, mock_mtime, mock_listdir, mock_exist # Mock STATE_FILE exists and contains bundle_id # We need to handle multiple open calls - mock_exists.side_effect = lambda p: p == "/tmp/sid_last_bundle_id" or p == "/Users/acrollet/Library/Logs/DiagnosticReports" + mock_exists.side_effect = lambda p: p == "/tmp/pippin_last_bundle_id" or p == "/Users/acrollet/Library/Logs/DiagnosticReports" # First call to open is for STATE_FILE # Second call is for the crash report @@ -154,7 +154,7 @@ def test_logs_crash_report(self, mock_file, mock_mtime, mock_listdir, mock_exist self.assertIn("CRASH_REPORT_FOUND", output) self.assertIn("Stack trace line 1", output) - @patch('sid.commands.doctor.execute_command') + @patch('pippin.commands.doctor.execute_command') @patch('shutil.which') @patch('builtins.input', return_value='n') def test_doctor_fail_no_install(self, mock_input, mock_which, mock_exec): @@ -165,7 +165,7 @@ def test_doctor_fail_no_install(self, mock_input, mock_which, mock_exec): sys.stdout = captured_stdout try: with self.assertRaises(SystemExit): - from sid.commands.doctor import doctor_cmd + from pippin.commands.doctor import doctor_cmd doctor_cmd() finally: sys.stdout = sys.__stdout__ @@ -177,10 +177,10 @@ def test_doctor_fail_no_install(self, mock_input, mock_which, mock_exec): self.assertIn("❌ idb NOT FOUND", output) self.assertIn("⚠️ Some dependencies are missing or misconfigured.", output) - @patch('sid.commands.doctor.execute_command') + @patch('pippin.commands.doctor.execute_command') @patch('shutil.which') @patch('builtins.input', return_value='y') - @patch('sid.commands.doctor._install_idb', return_value=True) + @patch('pippin.commands.doctor._install_idb', return_value=True) def test_doctor_install_success(self, mock_install, mock_input, mock_which, mock_exec): # Simulate idb missing initially, then found after install mock_which.side_effect = ["/usr/bin/xcrun", None, "/path/to/idb"] # xcrun checked first? No, idb then xcrun @@ -194,7 +194,7 @@ def test_doctor_install_success(self, mock_install, mock_input, mock_which, mock captured_stdout = StringIO() sys.stdout = captured_stdout try: - from sid.commands.doctor import doctor_cmd + from pippin.commands.doctor import doctor_cmd doctor_cmd() except SystemExit: pass @@ -205,7 +205,7 @@ def test_doctor_install_success(self, mock_install, mock_input, mock_which, mock self.assertTrue(mock_install.called) self.assertIn("✅ idb found at: /path/to/idb", output) - @patch('sid.utils.ui.get_ui_tree') + @patch('pippin.utils.ui.get_ui_tree') def test_wait_success(self, mock_get_tree): # Element found on second try mock_data = [ @@ -224,8 +224,8 @@ def test_wait_success(self, mock_get_tree): output = captured_output.getvalue() self.assertIn("PASS: Element 'btn1' is visible.", output) - @patch('sid.utils.ui.get_ui_tree') - @patch('sid.commands.interaction.execute_command') + @patch('pippin.utils.ui.get_ui_tree') + @patch('pippin.commands.interaction.execute_command') def test_tap_partial_label(self, mock_exec, mock_get_tree): # Mock tree with a long label mock_data = [ @@ -237,7 +237,7 @@ def test_tap_partial_label(self, mock_exec, mock_get_tree): interaction.tap_cmd(query="Welcome") mock_exec.assert_any_call(["idb", "ui", "tap", "50.0", "50.0"]) - @patch('sid.commands.system.execute_command') + @patch('pippin.commands.system.execute_command') @patch('os.path.exists', return_value=True) @patch('builtins.open', new_callable=unittest.mock.mock_open, read_data="com.test.app") def test_stop(self, mock_file, mock_exists, mock_exec): @@ -245,7 +245,7 @@ def test_stop(self, mock_file, mock_exists, mock_exec): # Verify terminate call mock_exec.assert_any_call(["xcrun", "simctl", "terminate", "booted", "com.test.app"]) - @patch('sid.commands.system.execute_command') + @patch('pippin.commands.system.execute_command') @patch('os.path.exists', return_value=True) @patch('builtins.open', new_callable=unittest.mock.mock_open, read_data="com.test.app") def test_relaunch(self, mock_file, mock_exists, mock_exec): diff --git a/tests/test_executor.py b/tests/test_executor.py index 1fc3dc2..9497a02 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -1,5 +1,5 @@ import unittest -from sid.utils.executor import execute_command, set_dry_run, register_mock_response, clear_mock_responses +from pippin.utils.executor import execute_command, set_dry_run, register_mock_response, clear_mock_responses import subprocess class TestExecutor(unittest.TestCase): diff --git a/tests/test_vision_hierarchy.py b/tests/test_vision_hierarchy.py index a759c84..239a9c7 100644 --- a/tests/test_vision_hierarchy.py +++ b/tests/test_vision_hierarchy.py @@ -3,11 +3,11 @@ import sys from unittest.mock import patch, MagicMock from io import StringIO -import sid.commands.vision as vision +import pippin.commands.vision as vision class TestVisionHierarchy(unittest.TestCase): - @patch('sid.commands.vision.get_ui_tree_hierarchical') + @patch('pippin.commands.vision.get_ui_tree_hierarchical') @patch('os.path.exists') @patch('builtins.open', new_callable=unittest.mock.mock_open, read_data="com.dynamic.app") def test_inspect_hierarchical(self, mock_file, mock_exists, mock_get_tree): @@ -72,7 +72,7 @@ def test_inspect_hierarchical(self, mock_file, mock_exists, mock_get_tree): cell = table["children"][0] self.assertEqual(cell["type"], "Cell") - @patch('sid.commands.vision.get_ui_tree') + @patch('pippin.commands.vision.get_ui_tree') @patch('os.path.exists') @patch('builtins.open', new_callable=unittest.mock.mock_open, read_data="com.dynamic.app") def test_inspect_flat(self, mock_file, mock_exists, mock_get_tree):