Skill: lpigeon/ros-skill (version 1.0.1)

--- name: ros-skill description: "Controls ROS/ROS2 robots via rosbridge WebSocket CLI. Use when the user asks about ROS topics, services, nodes, parameters, actions, robot movement, sensor data, or any ROS/ROS2 robot interaction." license: Apache-2.0 compatibility: "Requires python3, websocket-client pip package, and rosbridge running on the robot (default port 9090)" user-invocable: true metadata: {"openclaw": {"emoji": "🤖", "requires": {"bins": ["python3"], "pip": ["websocket-client"]}, "category": "robotics", "tags": ["ros", "ros2", "robotics", "rosbridge"]}, "author": "lpigeon", "version": "1.0.1"} --- # ROS Skill Controls and monitors ROS/ROS2 robots via rosbridge WebSocket. **Architecture:** Agent → `ros_cli.py` → rosbridge (WebSocket :9090) → ROS/ROS2 Robot All commands output JSON. Errors contain `{"error": "..."}`. For full command reference with arguments, options, and output examples, see [references/COMMANDS.md](references/COMMANDS.md). --- ## Setup ### 1. Install dependency ```bash pip install websocket-client ``` ### 2. Launch rosbridge on the robot **ROS 1:** ```bash sudo apt install ros-${ROS_DISTRO}-rosbridge-server roslaunch rosbridge_server rosbridge_websocket.launch ``` **ROS 2:** ```bash sudo apt install ros-${ROS_DISTRO}-rosbridge-server ros2 launch rosbridge_server rosbridge_websocket_launch.xml ``` --- ## Important: Always Connect First Before any operation, test connectivity: ```bash python {baseDir}/scripts/ros_cli.py connect python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> connect ``` --- ## Global Options | Flag | Default | Description | |------|---------|-------------| | `--ip IP` | `127.0.0.1` | Rosbridge IP address | | `--port PORT` | `9090` | Rosbridge port number | | `--timeout SECONDS` | `5.0` | Connection and request timeout | --- ## Command Quick Reference | Category | Command | Description | |----------|---------|-------------| | Connection | `connect` | Test rosbridge connectivity (ping, port, WebSocket) | | Connection | `version` | Detect ROS version and distro | | Topics | `topics list` | List all active topics with types | | Topics | `topics type <topic>` | Get message type of a topic | | Topics | `topics details <topic>` | Get topic publishers/subscribers | | Topics | `topics message <msg_type>` | Get message field structure | | Topics | `topics subscribe <topic> <msg_type>` | Subscribe and receive messages | | Topics | `topics publish <topic> <msg_type> <json>` | Publish a message to a topic | | Topics | `topics publish-sequence <topic> <msg_type> <msgs> <durs>` | Publish message sequence | | Services | `services list` | List all available services | | Services | `services type <service>` | Get service type | | Services | `services details <service>` | Get service request/response fields | | Services | `services call <service> <type> <json>` | Call a service | | Nodes | `nodes list` | List all active nodes | | Nodes | `nodes details <node>` | Get node topics/services | | Params | `params list <node>` | List node parameters (ROS 2) | | Params | `params get <node:param>` | Get parameter value (ROS 2) | | Params | `params set <node:param> <value>` | Set parameter value (ROS 2) | | Actions | `actions list` | List action servers (ROS 2) | | Actions | `actions details <action>` | Get action goal/result/feedback fields (ROS 2) | | Actions | `actions send <action> <type> <json>` | Send action goal (ROS 2) | --- ## Key Commands ### connect ```bash python {baseDir}/scripts/ros_cli.py connect python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> connect ``` ### version ```bash python {baseDir}/scripts/ros_cli.py version ``` ### topics list / type / details / message ```bash python {baseDir}/scripts/ros_cli.py topics list python {baseDir}/scripts/ros_cli.py topics type /turtle1/cmd_vel python {baseDir}/scripts/ros_cli.py topics details /turtle1/cmd_vel python {baseDir}/scripts/ros_cli.py topics message geometry_msgs/Twist ``` ### topics subscribe Without `--duration`: returns first message. With `--duration`: collects multiple messages. ```bash python {baseDir}/scripts/ros_cli.py topics subscribe /turtle1/pose turtlesim/Pose python {baseDir}/scripts/ros_cli.py topics subscribe /odom nav_msgs/Odometry --duration 10 --max-messages 50 python {baseDir}/scripts/ros_cli.py topics subscribe /scan sensor_msgs/LaserScan --timeout 10 ``` ### topics publish Without `--duration`: single-shot. With `--duration`: publishes repeatedly at `--rate` Hz. **Use `--duration` for velocity commands** — most robot controllers stop if they don't receive continuous `cmd_vel` messages. ```bash # Single-shot python {baseDir}/scripts/ros_cli.py topics publish /trigger std_msgs/Empty '{}' # Move forward 3 seconds (velocity — use --duration) python {baseDir}/scripts/ros_cli.py topics publish /cmd_vel geometry_msgs/Twist \ '{"linear":{"x":1.0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}}' --duration 3 # Rotate left 2 seconds python {baseDir}/scripts/ros_cli.py topics publish /cmd_vel geometry_msgs/Twist \ '{"linear":{"x":0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0.5}}' --duration 2 # Stop python {baseDir}/scripts/ros_cli.py topics publish /cmd_vel geometry_msgs/Twist \ '{"linear":{"x":0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}}' ``` Options: `--duration SECONDS`, `--rate HZ` (default 10) ### topics publish-sequence Publish a sequence of messages, each repeated at `--rate` Hz for its corresponding duration. Arrays must have the same length. ```bash # Forward 3s then stop python {baseDir}/scripts/ros_cli.py topics publish-sequence /cmd_vel geometry_msgs/Twist \ '[{"linear":{"x":1.0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}},{"linear":{"x":0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}}]' \ '[3.0, 0.5]' # Draw a square (turtlesim) python {baseDir}/scripts/ros_cli.py topics publish-sequence /turtle1/cmd_vel geometry_msgs/Twist \ '[{"linear":{"x":2},"angular":{"z":0}},{"linear":{"x":0},"angular":{"z":1.5708}},{"linear":{"x":2},"angular":{"z":0}},{"linear":{"x":0},"angular":{"z":1.5708}},{"linear":{"x":2},"angular":{"z":0}},{"linear":{"x":0},"angular":{"z":1.5708}},{"linear":{"x":2},"angular":{"z":0}},{"linear":{"x":0},"angular":{"z":1.5708}},{"linear":{"x":0},"angular":{"z":0}}]' \ '[1,1,1,1,1,1,1,1,0.5]' ``` Options: `--rate HZ` (default 10) ### services list / type / details ```bash python {baseDir}/scripts/ros_cli.py services list python {baseDir}/scripts/ros_cli.py services type /spawn python {baseDir}/scripts/ros_cli.py services details /spawn ``` ### services call ```bash python {baseDir}/scripts/ros_cli.py services call /reset std_srvs/Empty '{}' python {baseDir}/scripts/ros_cli.py services call /spawn turtlesim/Spawn \ '{"x":3.0,"y":3.0,"theta":0.0,"name":"turtle2"}' python {baseDir}/scripts/ros_cli.py services call /turtle1/set_pen turtlesim/srv/SetPen \ '{"r":255,"g":0,"b":0,"width":3,"off":0}' ``` ### nodes list / details ```bash python {baseDir}/scripts/ros_cli.py nodes list python {baseDir}/scripts/ros_cli.py nodes details /turtlesim ``` ### params list / get / set (ROS 2 only) Uses `node:param_name` format from `params list` output. ```bash python {baseDir}/scripts/ros_cli.py params list /turtlesim python {baseDir}/scripts/ros_cli.py params get /turtlesim:background_r python {baseDir}/scripts/ros_cli.py params set /turtlesim:background_r 255 ``` ### actions list / details / send (ROS 2 only) ```bash python {baseDir}/scripts/ros_cli.py actions list python {baseDir}/scripts/ros_cli.py actions details /turtle1/rotate_absolute python {baseDir}/scripts/ros_cli.py actions send /turtle1/rotate_absolute \ turtlesim/action/RotateAbsolute '{"theta":3.14}' ``` --- ## Workflow Examples ### 1. Explore a Robot System ```bash python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> connect python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> version python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> topics list python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> nodes list python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> services list python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> topics type /cmd_vel python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> topics message geometry_msgs/Twist python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> actions list python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> params list /robot_node ``` ### 2. Move a Robot Always check the message structure first, then publish movement, and always stop after. ```bash python {baseDir}/scripts/ros_cli.py topics message geometry_msgs/Twist python {baseDir}/scripts/ros_cli.py topics publish-sequence /cmd_vel geometry_msgs/Twist \ '[{"linear":{"x":1.0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}},{"linear":{"x":0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}}]' \ '[2.0, 0.5]' ``` ### 3. Read Sensor Data ```bash python {baseDir}/scripts/ros_cli.py topics type /scan python {baseDir}/scripts/ros_cli.py topics message sensor_msgs/LaserScan python {baseDir}/scripts/ros_cli.py topics subscribe /scan sensor_msgs/LaserScan --timeout 3 python {baseDir}/scripts/ros_cli.py topics subscribe /odom nav_msgs/Odometry --duration 10 --max-messages 50 ``` ### 4. Use Services ```bash python {baseDir}/scripts/ros_cli.py services list python {baseDir}/scripts/ros_cli.py services details /spawn python {baseDir}/scripts/ros_cli.py services call /spawn turtlesim/Spawn \ '{"x":3.0,"y":3.0,"theta":0.0,"name":"turtle2"}' ``` ### 5. ROS 2 Actions ```bash python {baseDir}/scripts/ros_cli.py actions list python {baseDir}/scripts/ros_cli.py actions details /turtle1/rotate_absolute python {baseDir}/scripts/ros_cli.py actions send /turtle1/rotate_absolute \ turtlesim/action/RotateAbsolute '{"theta":1.57}' ``` ### 6. Change Parameters (ROS 2) ```bash python {baseDir}/scripts/ros_cli.py params list /turtlesim python {baseDir}/scripts/ros_cli.py params get /turtlesim:background_r python {baseDir}/scripts/ros_cli.py params set /turtlesim:background_r 255 python {baseDir}/scripts/ros_cli.py params set /turtlesim:background_g 0 python {baseDir}/scripts/ros_cli.py params set /turtlesim:background_b 0 ``` --- ## Safety Notes **Destructive commands** (can move the robot or change state): - `topics publish` / `topics publish-sequence` — sends movement or control commands - `services call` — can reset, spawn, kill, or change robot state - `params set` — modifies runtime parameters - `actions send` — triggers robot actions (rotation, navigation, etc.) **Always stop the robot after movement.** The last message in any `publish-sequence` should be all zeros: ```json {"linear":{"x":0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}} ``` **Always check JSON output for errors before proceeding.** --- ## Troubleshooting | Problem | Cause | Solution | |---------|-------|----------| | Connection refused | rosbridge not running | Start rosbridge: `ros2 launch rosbridge_server rosbridge_websocket_launch.xml` | | Timeout errors | Slow network or large data | Increase timeout: `--timeout 10` or `--timeout 30` | | No topics found | ROS nodes not running | Ensure nodes are launched and workspace is sourced | | Empty topic list | rosapi not available | Verify rosbridge includes rosapi (default in standard install) | | Parameter commands fail | Using ROS 1 | `params` commands only work with ROS 2 | | Action commands fail | Using ROS 1 | `actions` commands only work with ROS 2 | | Invalid JSON error | Malformed message | Validate JSON before passing (watch for single vs double quotes) | | Subscribe timeout | No publisher on topic | Check `topics details` to verify publishers exist | | publish-sequence length error | Array mismatch | `messages` and `durations` arrays must have the same length |

path: references/COMMANDS.md
content: # Command Reference

Full reference for all `ros_cli.py` commands with arguments, options, and output examples.

---

## connect

Test connectivity to rosbridge. Performs ping, port check, and WebSocket handshake.

```bash
python {baseDir}/scripts/ros_cli.py connect
python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> connect
```

Output:
```json
{
  "ip": "127.0.0.1",
  "port": 9090,
  "ping": true,
  "port_open": true,
  "websocket": true
}
```

If connection fails:
```json
{
  "ip": "10.0.0.5",
  "port": 9090,
  "ping": true,
  "port_open": false,
  "websocket": false,
  "error": "Connection refused"
}
```

---

## version

Detect the ROS version (1 or 2) and distro name running on the robot.

```bash
python {baseDir}/scripts/ros_cli.py version
```

Output:
```json
{"version": "2", "distro": "humble"}
```

---

## topics list

List all active topics with their message types.

```bash
python {baseDir}/scripts/ros_cli.py topics list
```

Output:
```json
{
  "topics": ["/turtle1/cmd_vel", "/turtle1/pose", "/rosout"],
  "types": ["geometry_msgs/Twist", "turtlesim/Pose", "rcl_interfaces/msg/Log"],
  "count": 3
}
```

---

## topics type `<topic>`

Get the message type of a specific topic.

| Argument | Required | Description |
|----------|----------|-------------|
| `topic` | Yes | Topic name (e.g. `/cmd_vel`, `/turtle1/pose`) |

```bash
python {baseDir}/scripts/ros_cli.py topics type /turtle1/cmd_vel
```

Output:
```json
{"topic": "/turtle1/cmd_vel", "type": "geometry_msgs/Twist"}
```

---

## topics details `<topic>`

Get topic details including message type, publishers, and subscribers.

| Argument | Required | Description |
|----------|----------|-------------|
| `topic` | Yes | Topic name |

```bash
python {baseDir}/scripts/ros_cli.py topics details /turtle1/cmd_vel
```

Output:
```json
{
  "topic": "/turtle1/cmd_vel",
  "type": "geometry_msgs/Twist",
  "publishers": [],
  "subscribers": ["/turtlesim"]
}
```

---

## topics message `<message_type>`

Get the field structure of a message type.

| Argument | Required | Description |
|----------|----------|-------------|
| `message_type` | Yes | Full message type (e.g. `geometry_msgs/Twist`, `sensor_msgs/LaserScan`) |

```bash
python {baseDir}/scripts/ros_cli.py topics message geometry_msgs/Twist
```

Output:
```json
{
  "message_type": "geometry_msgs/Twist",
  "structure": {
    "geometry_msgs/Twist": {"linear": "geometry_msgs/Vector3", "angular": "geometry_msgs/Vector3"},
    "geometry_msgs/Vector3": {"x": "float64", "y": "float64", "z": "float64"}
  }
}
```

---

## topics subscribe `<topic>` `<msg_type>` [options]

Subscribe to a topic and receive messages.

| Argument | Required | Description |
|----------|----------|-------------|
| `topic` | Yes | Topic to subscribe to |
| `msg_type` | Yes | Message type of the topic |

| Option | Default | Description |
|--------|---------|-------------|
| `--duration SECONDS` | _(none)_ | Collect messages for this duration. Without this, returns first message only |
| `--max-messages N` | `100` | Maximum number of messages to collect (only with `--duration`) |

**Single message (default):**
```bash
python {baseDir}/scripts/ros_cli.py topics subscribe /turtle1/pose turtlesim/Pose
```

Output:
```json
{
  "msg": {"x": 5.544, "y": 5.544, "theta": 0.0, "linear_velocity": 0.0, "angular_velocity": 0.0}
}
```

**Collect over time:**
```bash
python {baseDir}/scripts/ros_cli.py topics subscribe /odom nav_msgs/Odometry --duration 10 --max-messages 50
```

Output:
```json
{
  "topic": "/odom",
  "collected_count": 50,
  "messages": [
    {"header": {}, "pose": {"pose": {"position": {"x": 0.1, "y": 0.0, "z": 0.0}}}},
    "..."
  ]
}
```

**With timeout for slow topics:**
```bash
python {baseDir}/scripts/ros_cli.py topics subscribe /scan sensor_msgs/LaserScan --timeout 10
```

---

## topics publish `<topic>` `<msg_type>` `<json_message>` [options]

Publish a message to a topic. Without `--duration`, sends once. With `--duration`, publishes repeatedly at `--rate` Hz — required for velocity commands since most robot controllers stop if they don't receive continuous commands.

| Argument | Required | Description |
|----------|----------|-------------|
| `topic` | Yes | Topic to publish to |
| `msg_type` | Yes | Message type |
| `json_message` | Yes | JSON string of the message payload |

| Option | Default | Description |
|--------|---------|-------------|
| `--duration SECONDS` | _(none)_ | Publish repeatedly for this duration |
| `--rate HZ` | `10` | Publish rate in Hz (used with `--duration`) |

**Single-shot (trigger, one-time command):**
```bash
python {baseDir}/scripts/ros_cli.py topics publish /turtle1/cmd_vel geometry_msgs/Twist \
  '{"linear":{"x":2.0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}}'
```

**Move forward for 3 seconds (recommended for velocity):**
```bash
python {baseDir}/scripts/ros_cli.py topics publish /cmd_vel geometry_msgs/Twist \
  '{"linear":{"x":1.0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}}' --duration 3
```

Output:
```json
{"success": true, "topic": "/cmd_vel", "msg_type": "geometry_msgs/Twist", "duration": 3.0, "rate": 10.0, "published_count": 30}
```

**Rotate left for 2 seconds:**
```bash
python {baseDir}/scripts/ros_cli.py topics publish /cmd_vel geometry_msgs/Twist \
  '{"linear":{"x":0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0.5}}' --duration 2
```

**Stop (single-shot is fine for stop):**
```bash
python {baseDir}/scripts/ros_cli.py topics publish /cmd_vel geometry_msgs/Twist \
  '{"linear":{"x":0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}}'
```

---

## topics publish-sequence `<topic>` `<msg_type>` `<json_messages>` `<json_durations>` [options]

Publish a sequence of messages. Each message is published repeatedly at `--rate` Hz for its corresponding duration. The arrays must have the same length.

| Argument | Required | Description |
|----------|----------|-------------|
| `topic` | Yes | Topic to publish to |
| `msg_type` | Yes | Message type |
| `json_messages` | Yes | JSON array of messages to publish in order |
| `json_durations` | Yes | JSON array of durations (seconds) for each message |

| Option | Default | Description |
|--------|---------|-------------|
| `--rate HZ` | `10` | Publish rate in Hz |

**Move forward 3 seconds then stop:**
```bash
python {baseDir}/scripts/ros_cli.py topics publish-sequence /cmd_vel geometry_msgs/Twist \
  '[{"linear":{"x":1.0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}},{"linear":{"x":0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}}]' \
  '[3.0, 0.5]'
```

Output:
```json
{"success": true, "published_count": 35, "topic": "/cmd_vel", "rate": 10.0}
```

**Draw a square (turtlesim):**
```bash
python {baseDir}/scripts/ros_cli.py topics publish-sequence /turtle1/cmd_vel geometry_msgs/Twist \
  '[{"linear":{"x":2},"angular":{"z":0}},{"linear":{"x":0},"angular":{"z":1.5708}},{"linear":{"x":2},"angular":{"z":0}},{"linear":{"x":0},"angular":{"z":1.5708}},{"linear":{"x":2},"angular":{"z":0}},{"linear":{"x":0},"angular":{"z":1.5708}},{"linear":{"x":2},"angular":{"z":0}},{"linear":{"x":0},"angular":{"z":1.5708}},{"linear":{"x":0},"angular":{"z":0}}]' \
  '[1,1,1,1,1,1,1,1,0.5]'
```

---

## services list

List all available services.

```bash
python {baseDir}/scripts/ros_cli.py services list
```

Output:
```json
{
  "services": ["/clear", "/kill", "/reset", "/spawn", "/turtle1/set_pen", "/turtle1/teleport_absolute"],
  "count": 6
}
```

---

## services type `<service>`

Get the type of a specific service.

| Argument | Required | Description |
|----------|----------|-------------|
| `service` | Yes | Service name (e.g. `/spawn`, `/reset`) |

```bash
python {baseDir}/scripts/ros_cli.py services type /spawn
```

Output:
```json
{"service": "/spawn", "type": "turtlesim/Spawn"}
```

---

## services details `<service>`

Get service details including type, request fields, and response fields.

| Argument | Required | Description |
|----------|----------|-------------|
| `service` | Yes | Service name |

```bash
python {baseDir}/scripts/ros_cli.py services details /spawn
```

Output:
```json
{
  "service": "/spawn",
  "type": "turtlesim/Spawn",
  "request": {"x": "float32", "y": "float32", "theta": "float32", "name": "string"},
  "response": {"name": "string"}
}
```

---

## services call `<service>` `<service_type>` `<json_request>`

Call a service with a JSON request payload.

| Argument | Required | Description |
|----------|----------|-------------|
| `service` | Yes | Service name |
| `service_type` | Yes | Service type |
| `json_request` | Yes | JSON string of the request arguments |

**Reset turtlesim:**
```bash
python {baseDir}/scripts/ros_cli.py services call /reset std_srvs/Empty '{}'
```

**Spawn a new turtle:**
```bash
python {baseDir}/scripts/ros_cli.py services call /spawn turtlesim/Spawn \
  '{"x":3.0,"y":3.0,"theta":0.0,"name":"turtle2"}'
```

Output:
```json
{"service": "/spawn", "success": true, "result": {"name": "turtle2"}}
```

**Set pen color:**
```bash
python {baseDir}/scripts/ros_cli.py services call /turtle1/set_pen turtlesim/srv/SetPen \
  '{"r":255,"g":0,"b":0,"width":3,"off":0}'
```

---

## nodes list

List all active ROS nodes.

```bash
python {baseDir}/scripts/ros_cli.py nodes list
```

Output:
```json
{
  "nodes": ["/turtlesim", "/rosbridge_websocket"],
  "count": 2
}
```

---

## nodes details `<node>`

Get node details including topics and services.

| Argument | Required | Description |
|----------|----------|-------------|
| `node` | Yes | Node name (e.g. `/turtlesim`) |

```bash
python {baseDir}/scripts/ros_cli.py nodes details /turtlesim
```

Output:
```json
{
  "node": "/turtlesim",
  "publishers": ["/turtle1/color_sensor", "/turtle1/pose", "/rosout"],
  "subscribers": ["/turtle1/cmd_vel"],
  "services": ["/clear", "/kill", "/reset", "/spawn", "/turtle1/set_pen", "/turtle1/teleport_absolute"]
}
```

---

## params list `<node>` (ROS 2 only)

List all parameters for a specific node.

| Argument | Required | Description |
|----------|----------|-------------|
| `node` | Yes | Node name (e.g. `/turtlesim`) |

```bash
python {baseDir}/scripts/ros_cli.py params list /turtlesim
```

Output:
```json
{
  "node": "/turtlesim",
  "parameters": ["/turtlesim:background_r", "/turtlesim:background_g", "/turtlesim:background_b", "/turtlesim:use_sim_time"],
  "count": 4
}
```

---

## params get `<node:param_name>` (ROS 2 only)

Get a parameter value.

| Argument | Required | Description |
|----------|----------|-------------|
| `name` | Yes | Parameter in `/node:param` format |

```bash
python {baseDir}/scripts/ros_cli.py params get /turtlesim:background_r
```

Output:
```json
{"name": "/turtlesim:background_r", "value": "69", "exists": true}
```

---

## params set `<node:param_name>` `<value>` (ROS 2 only)

Set a parameter value.

| Argument | Required | Description |
|----------|----------|-------------|
| `name` | Yes | Parameter in `/node:param` format |
| `value` | Yes | New value to set |

```bash
python {baseDir}/scripts/ros_cli.py params set /turtlesim:background_r 255
```

Output:
```json
{"name": "/turtlesim:background_r", "value": "255", "success": true}
```

---

## actions list (ROS 2 only)

List all available action servers.

```bash
python {baseDir}/scripts/ros_cli.py actions list
```

Output:
```json
{
  "actions": ["/turtle1/rotate_absolute"],
  "count": 1
}
```

---

## actions details `<action>` (ROS 2 only)

Get action details including goal, result, and feedback field structures.

| Argument | Required | Description |
|----------|----------|-------------|
| `action` | Yes | Action server name |

```bash
python {baseDir}/scripts/ros_cli.py actions details /turtle1/rotate_absolute
```

Output:
```json
{
  "action": "/turtle1/rotate_absolute",
  "action_type": "turtlesim/action/RotateAbsolute",
  "goal": {"theta": "float32"},
  "result": {"delta": "float32"},
  "feedback": {"remaining": "float32"}
}
```

---

## actions send `<action>` `<action_type>` `<json_goal>` (ROS 2 only)

Send an action goal and wait for the result.

| Argument | Required | Description |
|----------|----------|-------------|
| `action` | Yes | Action server name |
| `action_type` | Yes | Action type |
| `json_goal` | Yes | JSON string of the goal arguments |

```bash
python {baseDir}/scripts/ros_cli.py actions send /turtle1/rotate_absolute \
  turtlesim/action/RotateAbsolute '{"theta":3.14}'
```

Output:
```json
{
  "action": "/turtle1/rotate_absolute",
  "success": true,
  "goal_id": "goal_1709312000000",
  "result": {"delta": -1.584}
}
```

If timeout:
```json
{
  "action": "/turtle1/rotate_absolute",
  "goal_id": "goal_1709312000000",
  "success": false,
  "error": "Timeout after 5.0s"
}
```


path: tests/test_ros_cli.py
content: #!/usr/bin/env python3
"""Unit tests for ros_cli.py.

Tests cover argument parsing, dispatch table, JSON handling,
utility functions, and command handlers (with mocked WebSocket).
"""

import json
import sys
import os
import unittest
from unittest.mock import patch, MagicMock, call
from io import StringIO

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
import ros_cli


# ---------------------------------------------------------------------------
# Argument parser tests
# ---------------------------------------------------------------------------

class TestBuildParser(unittest.TestCase):
    def setUp(self):
        self.parser = ros_cli.build_parser()

    def test_default_global_options(self):
        args = self.parser.parse_args(["connect"])
        self.assertEqual(args.ip, "127.0.0.1")
        self.assertEqual(args.port, 9090)
        self.assertEqual(args.timeout, 5.0)

    def test_custom_global_options(self):
        args = self.parser.parse_args(["--ip", "10.0.0.5", "--port", "9091", "--timeout", "10", "connect"])
        self.assertEqual(args.ip, "10.0.0.5")
        self.assertEqual(args.port, 9091)
        self.assertEqual(args.timeout, 10.0)

    def test_connect_command(self):
        args = self.parser.parse_args(["connect"])
        self.assertEqual(args.command, "connect")

    def test_version_command(self):
        args = self.parser.parse_args(["version"])
        self.assertEqual(args.command, "version")

    def test_topics_list(self):
        args = self.parser.parse_args(["topics", "list"])
        self.assertEqual(args.command, "topics")
        self.assertEqual(args.subcommand, "list")

    def test_topics_type(self):
        args = self.parser.parse_args(["topics", "type", "/cmd_vel"])
        self.assertEqual(args.subcommand, "type")
        self.assertEqual(args.topic, "/cmd_vel")

    def test_topics_subscribe_defaults(self):
        args = self.parser.parse_args(["topics", "subscribe", "/scan", "sensor_msgs/LaserScan"])
        self.assertEqual(args.topic, "/scan")
        self.assertEqual(args.msg_type, "sensor_msgs/LaserScan")
        self.assertIsNone(args.duration)
        self.assertEqual(args.max_messages, 100)

    def test_topics_subscribe_with_duration(self):
        args = self.parser.parse_args([
            "topics", "subscribe", "/odom", "nav_msgs/Odometry",
            "--duration", "10", "--max-messages", "50"
        ])
        self.assertEqual(args.duration, 10.0)
        self.assertEqual(args.max_messages, 50)

    def test_topics_publish(self):
        msg = '{"linear":{"x":1.0}}'
        args = self.parser.parse_args(["topics", "publish", "/cmd_vel", "geometry_msgs/Twist", msg])
        self.assertEqual(args.topic, "/cmd_vel")
        self.assertEqual(args.msg_type, "geometry_msgs/Twist")
        self.assertEqual(args.msg, msg)
        self.assertIsNone(args.duration)
        self.assertEqual(args.rate, 10.0)

    def test_topics_publish_with_duration_and_rate(self):
        args = self.parser.parse_args([
            "topics", "publish", "/cmd_vel", "geometry_msgs/Twist", '{}',
            "--duration", "3", "--rate", "20"
        ])
        self.assertEqual(args.duration, 3.0)
        self.assertEqual(args.rate, 20.0)

    def test_topics_publish_sequence(self):
        msgs = '[{"linear":{"x":1}},{"linear":{"x":0}}]'
        durs = '[2.0, 0.5]'
        args = self.parser.parse_args([
            "topics", "publish-sequence", "/cmd_vel", "geometry_msgs/Twist", msgs, durs
        ])
        self.assertEqual(args.subcommand, "publish-sequence")
        self.assertEqual(args.messages, msgs)
        self.assertEqual(args.durations, durs)

    def test_services_call(self):
        args = self.parser.parse_args([
            "services", "call", "/spawn", "turtlesim/Spawn",
            '{"x":3.0,"y":3.0}'
        ])
        self.assertEqual(args.command, "services")
        self.assertEqual(args.subcommand, "call")
        self.assertEqual(args.service, "/spawn")
        self.assertEqual(args.service_type, "turtlesim/Spawn")

    def test_nodes_details(self):
        args = self.parser.parse_args(["nodes", "details", "/turtlesim"])
        self.assertEqual(args.subcommand, "details")
        self.assertEqual(args.node, "/turtlesim")

    def test_params_list(self):
        args = self.parser.parse_args(["params", "list", "/turtlesim"])
        self.assertEqual(args.command, "params")
        self.assertEqual(args.node, "/turtlesim")

    def test_params_get(self):
        args = self.parser.parse_args(["params", "get", "/turtlesim:background_r"])
        self.assertEqual(args.name, "/turtlesim:background_r")

    def test_params_set(self):
        args = self.parser.parse_args(["params", "set", "/turtlesim:background_r", "255"])
        self.assertEqual(args.name, "/turtlesim:background_r")
        self.assertEqual(args.value, "255")

    def test_actions_send(self):
        args = self.parser.parse_args([
            "actions", "send", "/turtle1/rotate_absolute",
            "turtlesim/action/RotateAbsolute", '{"theta":3.14}'
        ])
        self.assertEqual(args.action, "/turtle1/rotate_absolute")
        self.assertEqual(args.action_type, "turtlesim/action/RotateAbsolute")
        self.assertEqual(args.goal, '{"theta":3.14}')


# ---------------------------------------------------------------------------
# Dispatch table tests
# ---------------------------------------------------------------------------

class TestDispatchTable(unittest.TestCase):
    def test_all_keys_have_callable_handlers(self):
        for key, handler in ros_cli.DISPATCH.items():
            self.assertTrue(callable(handler), f"{key} handler is not callable")

    def test_expected_keys_exist(self):
        expected_keys = [
            ("connect", None), ("version", None),
            ("topics", "list"), ("topics", "type"), ("topics", "details"),
            ("topics", "message"), ("topics", "subscribe"), ("topics", "publish"),
            ("topics", "publish-sequence"),
            ("services", "list"), ("services", "type"), ("services", "details"),
            ("services", "call"),
            ("nodes", "list"), ("nodes", "details"),
            ("params", "list"), ("params", "get"), ("params", "set"),
            ("actions", "list"), ("actions", "details"), ("actions", "send"),
        ]
        for key in expected_keys:
            self.assertIn(key, ros_cli.DISPATCH, f"Missing dispatch key: {key}")

    def test_dispatch_count(self):
        self.assertEqual(len(ros_cli.DISPATCH), 21)


# ---------------------------------------------------------------------------
# Utility function tests
# ---------------------------------------------------------------------------

class TestOutput(unittest.TestCase):
    def test_output_prints_json(self):
        with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
            ros_cli.output({"key": "value"})
            result = json.loads(mock_stdout.getvalue())
            self.assertEqual(result, {"key": "value"})

    def test_output_unicode(self):
        with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
            ros_cli.output({"msg": "로봇"})
            result = json.loads(mock_stdout.getvalue())
            self.assertEqual(result["msg"], "로봇")

    def test_output_nested(self):
        data = {"a": {"b": [1, 2, 3]}}
        with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
            ros_cli.output(data)
            result = json.loads(mock_stdout.getvalue())
            self.assertEqual(result, data)


class TestCheckPort(unittest.TestCase):
    @patch("socket.create_connection")
    def test_port_open(self, mock_conn):
        mock_conn.return_value.__enter__ = MagicMock()
        mock_conn.return_value.__exit__ = MagicMock(return_value=False)
        self.assertTrue(ros_cli.check_port("127.0.0.1", 9090))

    @patch("socket.create_connection", side_effect=ConnectionRefusedError)
    def test_port_closed(self, mock_conn):
        self.assertFalse(ros_cli.check_port("127.0.0.1", 9090))

    @patch("socket.create_connection", side_effect=OSError("timeout"))
    def test_port_timeout(self, mock_conn):
        self.assertFalse(ros_cli.check_port("127.0.0.1", 9090))


class TestPingIp(unittest.TestCase):
    @patch("subprocess.run")
    def test_ping_success(self, mock_run):
        mock_run.return_value = MagicMock(returncode=0)
        self.assertTrue(ros_cli.ping_ip("127.0.0.1"))

    @patch("subprocess.run")
    def test_ping_failure(self, mock_run):
        mock_run.return_value = MagicMock(returncode=1)
        self.assertFalse(ros_cli.ping_ip("192.168.99.99"))

    @patch("subprocess.run", side_effect=Exception("not found"))
    def test_ping_exception(self, mock_run):
        self.assertFalse(ros_cli.ping_ip("10.0.0.1"))


# ---------------------------------------------------------------------------
# WebSocket helper tests
# ---------------------------------------------------------------------------

class TestWsConnect(unittest.TestCase):
    @patch("websocket.create_connection")
    def test_success(self, mock_create):
        mock_ws = MagicMock()
        mock_create.return_value = mock_ws
        ws, err = ros_cli.ws_connect("127.0.0.1", 9090)
        self.assertIs(ws, mock_ws)
        self.assertIsNone(err)
        mock_create.assert_called_once_with("ws://127.0.0.1:9090", timeout=5.0)

    @patch("websocket.create_connection", side_effect=ConnectionRefusedError("refused"))
    def test_failure(self, mock_create):
        ws, err = ros_cli.ws_connect("127.0.0.1", 9090)
        self.assertIsNone(ws)
        self.assertIn("refused", err)


class TestWsRequest(unittest.TestCase):
    def test_success(self):
        mock_ws = MagicMock()
        mock_ws.recv.return_value = json.dumps({"values": {"topics": ["/cmd_vel"]}})
        result = ros_cli.ws_request(mock_ws, {"op": "call_service"})
        self.assertIn("values", result)
        mock_ws.send.assert_called_once()

    def test_recv_error(self):
        mock_ws = MagicMock()
        mock_ws.recv.side_effect = Exception("connection lost")
        result = ros_cli.ws_request(mock_ws, {"op": "call_service"})
        self.assertIn("error", result)


class TestWsSubscribeOnce(unittest.TestCase):
    def test_receives_message(self):
        mock_ws = MagicMock()
        mock_ws.recv.return_value = json.dumps({
            "op": "publish", "topic": "/pose",
            "msg": {"x": 1.0, "y": 2.0}
        })
        result = ros_cli.ws_subscribe_once(mock_ws, "/pose", "turtlesim/Pose", timeout=1.0)
        self.assertEqual(result["msg"], {"x": 1.0, "y": 2.0})

    def test_error_status(self):
        mock_ws = MagicMock()
        mock_ws.recv.return_value = json.dumps({
            "op": "status", "level": "error", "msg": "bad topic"
        })
        result = ros_cli.ws_subscribe_once(mock_ws, "/bad", "std_msgs/String", timeout=1.0)
        self.assertIn("error", result)

    def test_timeout(self):
        mock_ws = MagicMock()
        import websocket as ws_mod
        mock_ws.recv.side_effect = ws_mod.WebSocketTimeoutException()
        result = ros_cli.ws_subscribe_once(mock_ws, "/slow", "std_msgs/String", timeout=0.5)
        self.assertIn("error", result)
        self.assertIn("Timeout", result["error"])


class TestWsSubscribeDuration(unittest.TestCase):
    def test_collects_messages(self):
        mock_ws = MagicMock()
        msgs = [
            json.dumps({"op": "publish", "topic": "/odom", "msg": {"seq": i}})
            for i in range(3)
        ]
        import websocket as ws_mod
        # After 3 real messages, keep raising timeout until duration expires
        call_count = [0]
        def recv_side_effect():
            idx = call_count[0]
            call_count[0] += 1
            if idx < len(msgs):
                return msgs[idx]
            raise ws_mod.WebSocketTimeoutException()
        mock_ws.recv.side_effect = recv_side_effect
        result = ros_cli.ws_subscribe_duration(mock_ws, "/odom", "nav_msgs/Odometry", duration=0.5)
        self.assertEqual(result["topic"], "/odom")
        self.assertEqual(result["collected_count"], 3)
        self.assertEqual(len(result["messages"]), 3)

    def test_max_messages_limit(self):
        mock_ws = MagicMock()
        msgs = [
            json.dumps({"op": "publish", "topic": "/t", "msg": {"i": i}})
            for i in range(10)
        ]
        mock_ws.recv.side_effect = msgs
        result = ros_cli.ws_subscribe_duration(mock_ws, "/t", "std_msgs/Int32", duration=5.0, max_messages=3)
        self.assertEqual(result["collected_count"], 3)


# ---------------------------------------------------------------------------
# Command handler tests (with mocked WebSocket)
# ---------------------------------------------------------------------------

def _make_args(**kwargs):
    """Create a namespace that mimics parsed arguments."""
    defaults = {"ip": "127.0.0.1", "port": 9090, "timeout": 5.0}
    defaults.update(kwargs)
    return argparse.Namespace(**defaults)


import argparse


class TestCmdConnect(unittest.TestCase):
    @patch("ros_cli.ws_connect", return_value=(MagicMock(), None))
    @patch("ros_cli.check_port", return_value=True)
    @patch("ros_cli.ping_ip", return_value=True)
    def test_all_ok(self, mock_ping, mock_port, mock_ws):
        with patch("sys.stdout", new_callable=StringIO) as out:
            ros_cli.cmd_connect(_make_args())
            result = json.loads(out.getvalue())
        self.assertTrue(result["ping"])
        self.assertTrue(result["port_open"])
        self.assertTrue(result["websocket"])
        self.assertNotIn("error", result)

    @patch("ros_cli.ws_connect", return_value=(None, "Connection refused"))
    @patch("ros_cli.check_port", return_value=False)
    @patch("ros_cli.ping_ip", return_value=True)
    def test_ws_failure(self, mock_ping, mock_port, mock_ws):
        with patch("sys.stdout", new_callable=StringIO) as out:
            ros_cli.cmd_connect(_make_args())
            result = json.loads(out.getvalue())
        self.assertTrue(result["ping"])
        self.assertFalse(result["port_open"])
        self.assertFalse(result["websocket"])
        self.assertEqual(result["error"], "Connection refused")


class TestCmdTopicsList(unittest.TestCase):
    @patch("ros_cli.ws_connect")
    def test_success(self, mock_connect):
        mock_ws = MagicMock()
        mock_connect.return_value = (mock_ws, None)
        mock_ws.recv.return_value = json.dumps({
            "values": {"topics": ["/cmd_vel", "/odom"], "types": ["geometry_msgs/Twist", "nav_msgs/Odometry"]}
        })
        with patch("sys.stdout", new_callable=StringIO) as out:
            ros_cli.cmd_topics_list(_make_args())
            result = json.loads(out.getvalue())
        self.assertEqual(result["count"], 2)
        self.assertIn("/cmd_vel", result["topics"])

    @patch("ros_cli.ws_connect", return_value=(None, "timeout"))
    def test_connection_error(self, mock_connect):
        with patch("sys.stdout", new_callable=StringIO) as out:
            ros_cli.cmd_topics_list(_make_args())
            result = json.loads(out.getvalue())
        self.assertIn("error", result)


class TestCmdTopicsPublish(unittest.TestCase):
    @patch("ros_cli.ws_connect")
    def test_single_shot(self, mock_connect):
        mock_ws = MagicMock()
        mock_connect.return_value = (mock_ws, None)
        args = _make_args(
            topic="/cmd_vel", msg_type="geometry_msgs/Twist",
            msg='{"linear":{"x":1.0}}', duration=None, rate=10.0
        )
        with patch("sys.stdout", new_callable=StringIO) as out:
            ros_cli.cmd_topics_publish(args)
            result = json.loads(out.getvalue())
        self.assertTrue(result["success"])
        self.assertEqual(result["topic"], "/cmd_vel")

    @patch("ros_cli.ws_connect")
    def test_invalid_json(self, mock_connect):
        mock_ws = MagicMock()
        mock_connect.return_value = (mock_ws, None)
        args = _make_args(
            topic="/cmd_vel", msg_type="geometry_msgs/Twist",
            msg="not json", duration=None, rate=10.0
        )
        with patch("sys.stdout", new_callable=StringIO) as out:
            ros_cli.cmd_topics_publish(args)
            result = json.loads(out.getvalue())
        self.assertIn("error", result)
        self.assertIn("Invalid JSON", result["error"])

    @patch("ros_cli.ws_connect", return_value=(None, "refused"))
    def test_connection_error(self, mock_connect):
        args = _make_args(
            topic="/cmd_vel", msg_type="geometry_msgs/Twist",
            msg='{}', duration=None, rate=10.0
        )
        with patch("sys.stdout", new_callable=StringIO) as out:
            ros_cli.cmd_topics_publish(args)
            result = json.loads(out.getvalue())
        self.assertIn("error", result)


class TestCmdTopicsPublishSequence(unittest.TestCase):
    @patch("ros_cli.ws_connect")
    def test_success(self, mock_connect):
        mock_ws = MagicMock()
        mock_connect.return_value = (mock_ws, None)
        args = _make_args(
            topic="/cmd_vel", msg_type="geometry_msgs/Twist",
            messages='[{"linear":{"x":1}},{"linear":{"x":0}}]',
            durations='[0.1, 0.1]', rate=10.0
        )
        with patch("sys.stdout", new_callable=StringIO) as out:
            ros_cli.cmd_topics_publish_sequence(args)
            result = json.loads(out.getvalue())
        self.assertTrue(result["success"])
        self.assertGreater(result["published_count"], 0)

    @patch("ros_cli.ws_connect")
    def test_length_mismatch(self, mock_connect):
        mock_ws = MagicMock()
        mock_connect.return_value = (mock_ws, None)
        args = _make_args(
            topic="/cmd_vel", msg_type="geometry_msgs/Twist",
            messages='[{"linear":{"x":1}}]',
            durations='[1.0, 2.0]', rate=10.0
        )
        with patch("sys.stdout", new_callable=StringIO) as out:
            ros_cli.cmd_topics_publish_sequence(args)
            result = json.loads(out.getvalue())
        self.assertIn("error", result)
        self.assertIn("same length", result["error"])

    @patch("ros_cli.ws_connect")
    def test_invalid_json(self, mock_connect):
        mock_ws = MagicMock()
        mock_connect.return_value = (mock_ws, None)
        args = _make_args(
            topic="/cmd_vel", msg_type="geometry_msgs/Twist",
            messages="bad", durations="[1]", rate=10.0
        )
        with patch("sys.stdout", new_callable=StringIO) as out:
            ros_cli.cmd_topics_publish_sequence(args)
            result = json.loads(out.getvalue())
        self.assertIn("error", result)
        self.assertIn("Invalid JSON", result["error"])


class TestCmdServicesCall(unittest.TestCase):
    @patch("ros_cli.ws_connect")
    def test_success(self, mock_connect):
        mock_ws = MagicMock()
        mock_connect.return_value = (mock_ws, None)
        mock_ws.recv.return_value = json.dumps({
            "values": {"name": "turtle2"}, "result": True
        })
        args = _make_args(
            service="/spawn", service_type="turtlesim/Spawn",
            request='{"x":3.0,"y":3.0}'
        )
        with patch("sys.stdout", new_callable=StringIO) as out:
            ros_cli.cmd_services_call(args)
            result = json.loads(out.getvalue())
        self.assertTrue(result["success"])
        self.assertEqual(result["result"]["name"], "turtle2")

    @patch("ros_cli.ws_connect")
    def test_invalid_request_json(self, mock_connect):
        mock_ws = MagicMock()
        mock_connect.return_value = (mock_ws, None)
        args = _make_args(
            service="/spawn", service_type="turtlesim/Spawn",
            request="not-json"
        )
        with patch("sys.stdout", new_callable=StringIO) as out:
            ros_cli.cmd_services_call(args)
            result = json.loads(out.getvalue())
        self.assertIn("error", result)
        self.assertIn("Invalid JSON", result["error"])


class TestCmdVersion(unittest.TestCase):
    @patch("ros_cli.ws_connect")
    def test_ros2_detected(self, mock_connect):
        mock_ws = MagicMock()
        mock_connect.return_value = (mock_ws, None)
        mock_ws.recv.return_value = json.dumps({
            "values": {"version": "2", "distro": "humble"}
        })
        with patch("sys.stdout", new_callable=StringIO) as out:
            ros_cli.cmd_version(_make_args())
            result = json.loads(out.getvalue())
        self.assertEqual(result["version"], "2")
        self.assertEqual(result["distro"], "humble")

    @patch("ros_cli.ws_connect", return_value=(None, "refused"))
    def test_connection_error(self, mock_connect):
        with patch("sys.stdout", new_callable=StringIO) as out:
            ros_cli.cmd_version(_make_args())
            result = json.loads(out.getvalue())
        self.assertIn("error", result)


if __name__ == "__main__":
    unittest.main()


path: README.md
content: # ROS Skill

![Static Badge](https://img.shields.io/badge/ROS-Available-green)
![Static Badge](https://img.shields.io/badge/ROS2-Available-green)
![Static Badge](https://img.shields.io/badge/Linux-Supported-green)
![Static Badge](https://img.shields.io/badge/macOS-Supported-green)
![Static Badge](https://img.shields.io/badge/Windows-Supported-green)
![Static Badge](https://img.shields.io/badge/License-Apache%202.0-blue)
![Python](https://img.shields.io/badge/python-3.10%2B-blue)

Agent Skill for ROS/ROS2 robot control via rosbridge WebSocket.

```
Agent (LLM) → ros_cli.py → rosbridge (WebSocket :9090) → ROS/ROS2 Robot
```

## What is this?

An [Agent Skill](https://agentskills.io) that lets AI agents control ROS robots through natural language. The agent reads `SKILL.md`, understands available commands, and executes `ros_cli.py` to interact with any ROS/ROS2 system via rosbridge.


## File Structure

```
ros-skill/
├── SKILL.md              # Skill document (loaded into agent's system prompt)
├── scripts/
│   └── ros_cli.py        # Standalone CLI tool (all ROS operations)
├── references/
│   └── COMMANDS.md       # Full command reference with output examples
├── examples/
│   ├── turtlesim.md      # Turtlesim tutorial
│   └── sensor-monitor.md # Sensor monitoring workflows
└── tests/
    └── test_ros_cli.py   # Unit tests (52 tests, no ROS environment needed)
```

## Quick Start

### 1. Install

```bash
pip install websocket-client
```

### 2. Launch rosbridge on the robot

```bash
sudo apt install ros-${ROS_DISTRO}-rosbridge-server

# ROS 1
roslaunch rosbridge_server rosbridge_websocket.launch

# ROS 2
ros2 launch rosbridge_server rosbridge_websocket_launch.xml
```

### 3. Use

```bash
# Connect
python scripts/ros_cli.py connect

# Explore
python scripts/ros_cli.py topics list
python scripts/ros_cli.py nodes list

# Move robot forward for 3 seconds
python scripts/ros_cli.py topics publish /cmd_vel geometry_msgs/Twist \
  '{"linear":{"x":1.0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}}' --duration 3

# Read sensor data
python scripts/ros_cli.py topics subscribe /scan sensor_msgs/LaserScan --timeout 3
```

## Supported Commands

| Category | Commands |
|----------|----------|
| Connection | `connect`, `version` |
| Topics | `list`, `type`, `details`, `message`, `subscribe`, `publish`, `publish-sequence` |
| Services | `list`, `type`, `details`, `call` |
| Nodes | `list`, `details` |
| Parameters | `list`, `get`, `set` (ROS 2 only) |
| Actions | `list`, `details`, `send` (ROS 2 only) |

All commands output JSON. See `SKILL.md` for quick reference and `references/COMMANDS.md` for full details with output examples.

## How It Works

1. The agent platform installs the skill and loads `SKILL.md` into the agent's system prompt
2. `{baseDir}` in commands is replaced with the actual skill installation path
3. User asks something like "move the robot forward"
4. Agent constructs and executes: `python {baseDir}/scripts/ros_cli.py topics publish /cmd_vel ...`
5. `ros_cli.py` connects to rosbridge via WebSocket, sends the command, and returns JSON
6. Agent parses the JSON and responds to the user in natural language

## Requirements

- Python 3
- `websocket-client` (pip package)
- rosbridge running on the robot (default port 9090)

## Testing

```bash
pip install websocket-client
python3 -m pytest tests/ -v
```

All 52 tests run without a ROS environment (WebSocket calls are mocked).

path: examples/sensor-monitor.md
content: # Sensor Monitoring

Workflows for subscribing to and analyzing robot sensor data.

## 1. Discover Available Sensors

```bash
python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> connect
python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> topics list
```

Common sensor topics:
- `/scan` — LiDAR (sensor_msgs/LaserScan)
- `/imu/data` — IMU (sensor_msgs/Imu)
- `/odom` — Odometry (nav_msgs/Odometry)
- `/joint_states` — Joint positions (sensor_msgs/JointState)
- `/camera/image_raw` — Camera (sensor_msgs/Image)

## 2. Read a Single Sensor Value

```bash
python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> topics type /scan
python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> topics message sensor_msgs/LaserScan
python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> topics subscribe /scan sensor_msgs/LaserScan
```

## 3. Monitor Sensor Over Time

Collect odometry data for 10 seconds:

```bash
python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> topics subscribe /odom nav_msgs/Odometry \
  --duration 10 --max-messages 50
```

Monitor IMU:

```bash
python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> topics subscribe /imu/data sensor_msgs/Imu \
  --duration 5 --max-messages 20
```

## 4. Joint State Monitoring

```bash
python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> topics subscribe /joint_states sensor_msgs/JointState
```

Returns joint names, positions, velocities, and efforts.

## 5. LiDAR Obstacle Detection

```bash
python {baseDir}/scripts/ros_cli.py --ip <ROBOT_IP> topics subscribe /scan sensor_msgs/LaserScan --timeout 3
```

The response includes:
- `ranges[]` — distance measurements per angle
- `angle_min`, `angle_max` — scan range
- `range_min`, `range_max` — valid distance range


path: examples/turtlesim.md
content: # Turtlesim Tutorial

End-to-end workflow for controlling turtlesim with `ros_cli.py`.

## Prerequisites

```bash
# Install dependency
pip install websocket-client

# Launch turtlesim (ROS 2)
ros2 run turtlesim turtlesim_node
ros2 launch rosbridge_server rosbridge_websocket_launch.xml
```

## Step 1: Connect and explore

```bash
python {baseDir}/scripts/ros_cli.py connect
python {baseDir}/scripts/ros_cli.py version
python {baseDir}/scripts/ros_cli.py topics list
python {baseDir}/scripts/ros_cli.py nodes list
python {baseDir}/scripts/ros_cli.py services list
```

## Step 2: Understand message types

```bash
python {baseDir}/scripts/ros_cli.py topics type /turtle1/cmd_vel
python {baseDir}/scripts/ros_cli.py topics message geometry_msgs/Twist
```

## Step 3: Read turtle position

```bash
python {baseDir}/scripts/ros_cli.py topics subscribe /turtle1/pose turtlesim/Pose
```

## Step 4: Move the turtle

Move forward for 2 seconds (use `--duration` to keep the velocity command active):

```bash
python {baseDir}/scripts/ros_cli.py topics publish /turtle1/cmd_vel geometry_msgs/Twist \
  '{"linear":{"x":2.0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}}' --duration 2
```

## Step 5: Draw a square

```bash
python {baseDir}/scripts/ros_cli.py topics publish-sequence /turtle1/cmd_vel geometry_msgs/Twist \
  '[{"linear":{"x":2},"angular":{"z":0}},{"linear":{"x":0},"angular":{"z":1.5708}},{"linear":{"x":2},"angular":{"z":0}},{"linear":{"x":0},"angular":{"z":1.5708}},{"linear":{"x":2},"angular":{"z":0}},{"linear":{"x":0},"angular":{"z":1.5708}},{"linear":{"x":2},"angular":{"z":0}},{"linear":{"x":0},"angular":{"z":1.5708}},{"linear":{"x":0},"angular":{"z":0}}]' \
  '[1,1,1,1,1,1,1,1,0.5]'
```

## Step 6: Use services

```bash
# Reset turtle position
python {baseDir}/scripts/ros_cli.py services call /reset std_srvs/Empty '{}'

# Spawn a new turtle
python {baseDir}/scripts/ros_cli.py services call /spawn turtlesim/Spawn \
  '{"x":3.0,"y":3.0,"theta":0.0,"name":"turtle2"}'
```

## Step 7: Change background color (ROS 2)

```bash
python {baseDir}/scripts/ros_cli.py params set /turtlesim:background_r 255
python {baseDir}/scripts/ros_cli.py params set /turtlesim:background_g 0
python {baseDir}/scripts/ros_cli.py params set /turtlesim:background_b 0
```

## Step 8: Use actions (ROS 2)

```bash
python {baseDir}/scripts/ros_cli.py actions list
python {baseDir}/scripts/ros_cli.py actions details /turtle1/rotate_absolute
python {baseDir}/scripts/ros_cli.py actions send /turtle1/rotate_absolute \
  turtlesim/action/RotateAbsolute '{"theta":3.14}'
```


path: scripts/ros_cli.py
content: #!/usr/bin/env python3
"""ROS Skill - Standalone CLI tool for controlling ROS robots via rosbridge WebSocket.

Requires: pip install websocket-client

Global Options:
    --ip IP            Rosbridge IP address (default: 127.0.0.1)
    --port PORT        Rosbridge port number (default: 9090)
    --timeout SECONDS  Connection/request timeout (default: 5.0)

Commands:

  connect
    Test connectivity to rosbridge (ping, port check, WebSocket handshake).
    $ python ros_cli.py connect
    $ python ros_cli.py --ip 10.0.0.5 --port 9090 connect

  version
    Detect ROS version and distro running on the robot.
    $ python ros_cli.py version

  topics list
    List all active topics with their message types.
    $ python ros_cli.py topics list

  topics type <topic>
    Get the message type of a specific topic.
    $ python ros_cli.py topics type /cmd_vel
    $ python ros_cli.py topics type /turtle1/pose

  topics details <topic>
    Get topic details including type, publishers, and subscribers.
    $ python ros_cli.py topics details /cmd_vel

  topics message <message_type>
    Get the field structure of a message type.
    $ python ros_cli.py topics message geometry_msgs/Twist
    $ python ros_cli.py topics message sensor_msgs/LaserScan

  topics subscribe <topic> <msg_type> [--duration SEC] [--max-messages N]
    Subscribe to a topic. Without --duration, returns the first message and exits.
    With --duration, collects messages for the specified time.
    --duration SECONDS   Collect messages for this duration (default: single message)
    --max-messages N     Max messages to collect during duration (default: 100)
    $ python ros_cli.py topics subscribe /turtle1/pose turtlesim/Pose
    $ python ros_cli.py topics subscribe /odom nav_msgs/Odometry --duration 10 --max-messages 50
    $ python ros_cli.py topics subscribe /scan sensor_msgs/LaserScan --timeout 3
    $ python ros_cli.py topics subscribe /imu/data sensor_msgs/Imu --duration 5 --max-messages 20

  topics publish <topic> <msg_type> <json_message> [--duration SEC] [--rate HZ]
    Publish a message to a topic.
    Without --duration: sends once (single-shot).
    With --duration: publishes repeatedly at --rate Hz for the specified seconds.
    --duration SECONDS   Publish repeatedly for this duration
    --rate HZ            Publish rate (default: 10 Hz)
    $ python ros_cli.py topics publish /turtle1/cmd_vel geometry_msgs/Twist \
        '{"linear":{"x":2.0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}}'
    $ python ros_cli.py topics publish /cmd_vel geometry_msgs/Twist \
        '{"linear":{"x":1.0,"y":0,"z":0},"angular":{"x":0,"y":0,"z":0}}' --duration 3

  topics publish-sequence <topic> <msg_type> <json_messages> <json_durations> [--rate HZ]
    Publish a sequence of messages. Each message is repeatedly published at --rate Hz
    for its corresponding duration. This keeps velocity commands active for the full duration.
    <json_messages>   JSON array of messages to publish in order
    <json_durations>  JSON array of durations (seconds) for each message (must match length)
    --rate HZ         Publish rate (default: 10 Hz)
    $ python ros_cli.py topics publish-sequence /turtle1/cmd_vel geometry_msgs/Twist \
        '[{"linear":{"x":1.0},"angular":{"z":0}},{"linear":{"x":0},"angular":{"z":0}}]' \
        '[3.0, 0.5]'

  services list
    List all available services.
    $ python ros_cli.py services list

  services type <service>
    Get the type of a specific service.
    $ python ros_cli.py services type /rosapi/topics

  services details <service>
    Get service details including type, request fields, and response fields.
    $ python ros_cli.py services details /rosapi/topics

  services call <service> <service_type> <json_request>
    Call a service with a JSON request payload.
    $ python ros_cli.py services call /reset std_srvs/Empty '{}'
    $ python ros_cli.py services call /spawn turtlesim/Spawn \
        '{"x":3.0,"y":3.0,"theta":0.0,"name":"turtle2"}'

  nodes list
    List all active nodes.
    $ python ros_cli.py nodes list

  nodes details <node>
    Get node details including publishers, subscribers, and services.
    $ python ros_cli.py nodes details /turtlesim

  params list <node>                                          (ROS 2 only)
    List all parameters for a node.
    $ python ros_cli.py params list /turtlesim

  params get <node:param_name>                                (ROS 2 only)
    Get a parameter value. Format: /node_name:parameter_name
    $ python ros_cli.py params get /turtlesim:background_r
    $ python ros_cli.py params get /turtlesim:background_b

  params set <node:param_name> <value>                        (ROS 2 only)
    Set a parameter value. Format: /node_name:parameter_name
    $ python ros_cli.py params set /turtlesim:background_r 255
    $ python ros_cli.py params set /turtlesim:background_g 0

  actions list                                                (ROS 2 only)
    List all available action servers.
    $ python ros_cli.py actions list

  actions details <action>                                    (ROS 2 only)
    Get action details including goal, result, and feedback fields.
    $ python ros_cli.py actions details /turtle1/rotate_absolute

  actions send <action> <action_type> <json_goal>             (ROS 2 only)
    Send an action goal and wait for the result.
    $ python ros_cli.py actions send /turtle1/rotate_absolute \
        turtlesim/action/RotateAbsolute '{"theta":3.14}'

Output:
    All commands output JSON to stdout.
    Successful results contain relevant data fields.
    Errors contain an "error" field: {"error": "description"}

Examples:
    # Connect to a remote robot and explore
    python ros_cli.py --ip 10.0.0.5 connect
    python ros_cli.py --ip 10.0.0.5 topics list
    python ros_cli.py --ip 10.0.0.5 nodes list

    # Move turtlesim forward 2 sec then stop
    python ros_cli.py topics publish-sequence /turtle1/cmd_vel geometry_msgs/Twist \
      '[{"linear":{"x":2},"angular":{"z":0}},{"linear":{"x":0},"angular":{"z":0}}]' \
      '[2.0, 0.5]'

    # Read LiDAR data
    python ros_cli.py topics subscribe /scan sensor_msgs/LaserScan --timeout 3

    # Change turtlesim background to red (ROS 2)
    python ros_cli.py params set /turtlesim:background_r 255
    python ros_cli.py params set /turtlesim:background_g 0
    python ros_cli.py params set /turtlesim:background_b 0
"""

import argparse
import json
import socket
import subprocess
import sys
import time
import platform

try:
    import websocket
except ImportError:
    print(json.dumps({"error": "websocket-client not installed. Run: pip install websocket-client"}))
    sys.exit(1)


# ---------------------------------------------------------------------------
# WebSocket helpers
# ---------------------------------------------------------------------------

def ws_connect(ip, port, timeout=5.0):
    """Create a WebSocket connection to rosbridge."""
    try:
        url = f"ws://{ip}:{port}"
        ws = websocket.create_connection(url, timeout=timeout)
        return ws, None
    except Exception as e:
        return None, str(e)


def ws_request(ws, message, timeout=5.0):
    """Send a request and wait for a response."""
    try:
        ws.send(json.dumps(message))
        ws.settimeout(timeout)
        raw = ws.recv()
        return json.loads(raw)
    except Exception as e:
        return {"error": str(e)}


def ws_subscribe_once(ws, topic, msg_type, timeout=5.0):
    """Subscribe to a topic and return the first message."""
    sub_msg = {"op": "subscribe", "topic": topic, "type": msg_type}
    try:
        ws.send(json.dumps(sub_msg))
        end_time = time.time() + timeout
        while time.time() < end_time:
            ws.settimeout(max(0.5, end_time - time.time()))
            try:
                raw = ws.recv()
                data = json.loads(raw)
                if data.get("op") == "status" and data.get("level") == "error":
                    return {"error": data.get("msg", "Unknown error")}
                if data.get("op") == "publish" and data.get("topic") == topic:
                    ws.send(json.dumps({"op": "unsubscribe", "topic": topic}))
                    return {"msg": data.get("msg", {})}
            except websocket.WebSocketTimeoutException:
                continue
        ws.send(json.dumps({"op": "unsubscribe", "topic": topic}))
        return {"error": "Timeout waiting for message"}
    except Exception as e:
        return {"error": str(e)}


def ws_subscribe_duration(ws, topic, msg_type, duration=5.0, max_messages=100):
    """Subscribe to a topic for a duration and collect messages."""
    sub_msg = {"op": "subscribe", "topic": topic, "type": msg_type}
    try:
        ws.send(json.dumps(sub_msg))
        messages = []
        end_time = time.time() + duration
        while time.time() < end_time and len(messages) < max_messages:
            ws.settimeout(max(0.5, end_time - time.time()))
            try:
                raw = ws.recv()
                data = json.loads(raw)
                if data.get("op") == "publish" and data.get("topic") == topic:
                    messages.append(data.get("msg", {}))
            except websocket.WebSocketTimeoutException:
                continue
        ws.send(json.dumps({"op": "unsubscribe", "topic": topic}))
        return {"topic": topic, "collected_count": len(messages), "messages": messages}
    except Exception as e:
        return {"error": str(e)}


def ping_ip(ip, timeout=2.0):
    """Ping an IP address."""
    param = "-n" if platform.system().lower() == "windows" else "-c"
    timeout_param = "-w" if platform.system().lower() == "windows" else "-W"
    try:
        result = subprocess.run(
            ["ping", param, "1", timeout_param, str(int(timeout)), ip],
            capture_output=True, text=True, timeout=timeout + 2
        )
        return result.returncode == 0
    except Exception:
        return False


def check_port(ip, port, timeout=2.0):
    """Check if a port is open."""
    try:
        with socket.create_connection((ip, port), timeout=timeout):
            return True
    except Exception:
        return False


def output(data):
    """Print JSON output to stdout."""
    print(json.dumps(data, indent=2, ensure_ascii=False))


# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------

def cmd_connect(args):
    ip, port = args.ip, args.port
    ping_ok = ping_ip(ip)
    port_ok = check_port(ip, port)
    ws, err = ws_connect(ip, port, timeout=args.timeout)
    result = {
        "ip": ip,
        "port": port,
        "ping": ping_ok,
        "port_open": port_ok,
        "websocket": err is None,
    }
    if err:
        result["error"] = err
    if ws:
        ws.close()
    output(result)


def cmd_topics_list(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    resp = ws_request(ws, {
        "op": "call_service", "service": "/rosapi/topics",
        "type": "rosapi/Topics", "args": {}, "id": "topics"
    }, args.timeout)
    ws.close()
    if "values" in resp:
        v = resp["values"]
        output({"topics": v.get("topics", []), "types": v.get("types", []),
                "count": len(v.get("topics", []))})
    else:
        output(resp)


def cmd_topics_type(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    resp = ws_request(ws, {
        "op": "call_service", "service": "/rosapi/topic_type",
        "type": "rosapi/TopicType", "args": {"topic": args.topic}, "id": "tt"
    }, args.timeout)
    ws.close()
    if "values" in resp:
        output({"topic": args.topic, "type": resp["values"].get("type", "")})
    else:
        output(resp)


def cmd_topics_details(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    result = {"topic": args.topic, "type": "unknown", "publishers": [], "subscribers": []}
    # type
    r = ws_request(ws, {"op": "call_service", "service": "/rosapi/topic_type",
        "type": "rosapi/TopicType", "args": {"topic": args.topic}, "id": "t1"}, args.timeout)
    if "values" in r:
        result["type"] = r["values"].get("type", "unknown")
    # publishers
    r = ws_request(ws, {"op": "call_service", "service": "/rosapi/publishers",
        "type": "rosapi/Publishers", "args": {"topic": args.topic}, "id": "t2"}, args.timeout)
    if "values" in r:
        result["publishers"] = r["values"].get("publishers", [])
    # subscribers
    r = ws_request(ws, {"op": "call_service", "service": "/rosapi/subscribers",
        "type": "rosapi/Subscribers", "args": {"topic": args.topic}, "id": "t3"}, args.timeout)
    if "values" in r:
        result["subscribers"] = r["values"].get("subscribers", [])
    ws.close()
    output(result)


def cmd_topics_message(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    resp = ws_request(ws, {
        "op": "call_service", "service": "/rosapi/message_details",
        "type": "rosapi/MessageDetails", "args": {"type": args.message_type}, "id": "md"
    }, args.timeout)
    ws.close()
    if "values" in resp:
        typedefs = resp["values"].get("typedefs", [])
        structure = {}
        for td in typedefs:
            fields = dict(zip(td.get("fieldnames", []), td.get("fieldtypes", [])))
            structure[td.get("type", args.message_type)] = fields
        output({"message_type": args.message_type, "structure": structure})
    else:
        output(resp)


def cmd_topics_subscribe(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    if args.duration:
        result = ws_subscribe_duration(ws, args.topic, args.msg_type,
                                       duration=args.duration, max_messages=args.max_messages or 100)
    else:
        result = ws_subscribe_once(ws, args.topic, args.msg_type, timeout=args.timeout)
    ws.close()
    output(result)


def cmd_topics_publish(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    try:
        msg = json.loads(args.msg)
    except json.JSONDecodeError as e:
        return output({"error": f"Invalid JSON message: {e}"})

    rate = getattr(args, "rate", None) or 10.0
    duration = getattr(args, "duration", None)
    interval = 1.0 / rate

    ws.send(json.dumps({"op": "advertise", "topic": args.topic, "type": args.msg_type}))
    time.sleep(0.1)

    if duration and duration > 0:
        # Repeatedly publish at the given rate for the duration
        published = 0
        end_time = time.time() + duration
        while time.time() < end_time:
            ws.send(json.dumps({"op": "publish", "topic": args.topic, "msg": msg}))
            published += 1
            remaining = end_time - time.time()
            if remaining > 0:
                time.sleep(min(interval, remaining))
        ws.send(json.dumps({"op": "unadvertise", "topic": args.topic}))
        ws.close()
        output({"success": True, "topic": args.topic, "msg_type": args.msg_type,
                "duration": duration, "rate": rate, "published_count": published})
    else:
        # Single-shot publish
        ws.send(json.dumps({"op": "publish", "topic": args.topic, "msg": msg}))
        time.sleep(0.1)
        ws.send(json.dumps({"op": "unadvertise", "topic": args.topic}))
        ws.close()
        output({"success": True, "topic": args.topic, "msg_type": args.msg_type})


def cmd_topics_publish_sequence(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    try:
        messages = json.loads(args.messages)
        durations = json.loads(args.durations)
    except json.JSONDecodeError as e:
        return output({"error": f"Invalid JSON: {e}"})
    if len(messages) != len(durations):
        return output({"error": "messages and durations must have same length"})

    rate = getattr(args, "rate", None) or 10.0
    interval = 1.0 / rate

    ws.send(json.dumps({"op": "advertise", "topic": args.topic, "type": args.msg_type}))
    time.sleep(0.1)
    total_published = 0
    for msg, dur in zip(messages, durations):
        if dur > 0:
            # Repeatedly publish at the given rate for the duration
            end_time = time.time() + dur
            while time.time() < end_time:
                ws.send(json.dumps({"op": "publish", "topic": args.topic, "msg": msg}))
                total_published += 1
                remaining = end_time - time.time()
                if remaining > 0:
                    time.sleep(min(interval, remaining))
        else:
            # duration 0: single-shot
            ws.send(json.dumps({"op": "publish", "topic": args.topic, "msg": msg}))
            total_published += 1
    ws.send(json.dumps({"op": "unadvertise", "topic": args.topic}))
    ws.close()
    output({"success": True, "published_count": total_published, "topic": args.topic,
            "rate": rate})


def cmd_services_list(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    resp = ws_request(ws, {
        "op": "call_service", "service": "/rosapi/services",
        "type": "rosapi_msgs/srv/Services", "args": {}, "id": "svc"
    }, args.timeout)
    ws.close()
    if "values" in resp:
        services = resp["values"].get("services", [])
        output({"services": services, "count": len(services)})
    else:
        output(resp)


def cmd_services_type(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    resp = ws_request(ws, {
        "op": "call_service", "service": "/rosapi/service_type",
        "type": "rosapi_msgs/srv/ServiceType", "args": {"service": args.service}, "id": "st"
    }, args.timeout)
    ws.close()
    if "values" in resp:
        output({"service": args.service, "type": resp["values"].get("type", "")})
    else:
        output(resp)


def cmd_services_details(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    result = {"service": args.service, "type": "", "request": {}, "response": {}}

    r = ws_request(ws, {"op": "call_service", "service": "/rosapi/service_type",
        "type": "rosapi_msgs/srv/ServiceType", "args": {"service": args.service}, "id": "sd1"}, args.timeout)
    if "values" in r:
        result["type"] = r["values"].get("type", "")

    if result["type"]:
        r = ws_request(ws, {"op": "call_service", "service": "/rosapi/service_request_details",
            "type": "rosapi_msgs/srv/ServiceRequestDetails",
            "args": {"type": result["type"]}, "id": "sd2"}, args.timeout)
        if "values" in r:
            for td in r["values"].get("typedefs", []):
                result["request"] = dict(zip(td.get("fieldnames", []), td.get("fieldtypes", [])))

        r = ws_request(ws, {"op": "call_service", "service": "/rosapi/service_response_details",
            "type": "rosapi_msgs/srv/ServiceResponseDetails",
            "args": {"type": result["type"]}, "id": "sd3"}, args.timeout)
        if "values" in r:
            for td in r["values"].get("typedefs", []):
                result["response"] = dict(zip(td.get("fieldnames", []), td.get("fieldtypes", [])))
    ws.close()
    output(result)


def cmd_services_call(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    try:
        request = json.loads(args.request)
    except json.JSONDecodeError as e:
        return output({"error": f"Invalid JSON request: {e}"})
    resp = ws_request(ws, {
        "op": "call_service", "service": args.service, "type": args.service_type,
        "args": request, "id": "call"
    }, args.timeout)
    ws.close()
    if "values" in resp:
        output({"service": args.service, "success": resp.get("result", True),
                "result": resp["values"]})
    else:
        output(resp)


def cmd_nodes_list(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    resp = ws_request(ws, {
        "op": "call_service", "service": "/rosapi/nodes",
        "type": "rosapi/Nodes", "args": {}, "id": "nodes"
    }, args.timeout)
    ws.close()
    if "values" in resp:
        nodes = resp["values"].get("nodes", [])
        output({"nodes": nodes, "count": len(nodes)})
    else:
        output(resp)


def cmd_nodes_details(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    resp = ws_request(ws, {
        "op": "call_service", "service": "/rosapi/node_details",
        "type": "rosapi/NodeDetails", "args": {"node": args.node}, "id": "nd"
    }, args.timeout)
    ws.close()
    if "values" in resp:
        v = resp["values"]
        output({"node": args.node, "publishers": v.get("publishing", []),
                "subscribers": v.get("subscribing", []), "services": v.get("services", [])})
    else:
        output(resp)


def cmd_params_list(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    node = args.node if args.node.startswith("/") else f"/{args.node}"
    resp = ws_request(ws, {
        "op": "call_service", "service": f"{node}/list_parameters",
        "type": "rcl_interfaces/srv/ListParameters", "args": {}, "id": "pl"
    }, args.timeout)
    ws.close()
    if "values" in resp:
        result_obj = resp["values"].get("result", {})
        names = result_obj.get("names", []) if isinstance(result_obj, dict) else []
        formatted = [f"{node}:{n}" for n in names]
        output({"node": node, "parameters": formatted, "count": len(formatted)})
    else:
        output(resp)


def cmd_params_get(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    resp = ws_request(ws, {
        "op": "call_service", "service": "/rosapi/get_param",
        "type": "rosapi_msgs/srv/GetParam", "args": {"name": args.name}, "id": "pg"
    }, args.timeout)
    ws.close()
    if "values" in resp:
        value = resp["values"].get("value", "")
        output({"name": args.name, "value": value, "exists": bool(value and value.strip('"'))})
    else:
        output(resp)


def cmd_params_set(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    resp = ws_request(ws, {
        "op": "call_service", "service": "/rosapi/set_param",
        "type": "rosapi_msgs/srv/SetParam",
        "args": {"name": args.name, "value": args.value}, "id": "ps"
    }, args.timeout)
    ws.close()
    if "values" in resp:
        output({"name": args.name, "value": args.value, "success": True})
    else:
        output(resp)


def cmd_actions_list(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    resp = ws_request(ws, {
        "op": "call_service", "service": "/rosapi/action_servers",
        "type": "rosapi/ActionServers", "args": {}, "id": "al"
    }, args.timeout)
    ws.close()
    if "values" in resp:
        actions = resp["values"].get("action_servers", [])
        output({"actions": actions, "count": len(actions)})
    else:
        output(resp)


def cmd_actions_details(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    result = {"action": args.action, "action_type": "unknown", "goal": {}, "result": {}, "feedback": {}}

    # Find action type from interfaces
    r = ws_request(ws, {"op": "call_service", "service": "/rosapi/interfaces",
        "type": "rosapi/Interfaces", "args": {}, "id": "ai"}, args.timeout)
    if "values" in r:
        action_name_part = args.action.split("/")[-1]
        for iface in r["values"].get("interfaces", []):
            if "/action/" in iface and action_name_part.lower() in iface.lower():
                result["action_type"] = iface
                break

    atype = result["action_type"]
    if atype != "unknown":
        for part, svc, svc_type in [
            ("goal", "/rosapi/action_goal_details", "rosapi_msgs/srv/ActionGoalDetails"),
            ("result", "/rosapi/action_result_details", "rosapi_msgs/srv/ActionResultDetails"),
            ("feedback", "/rosapi/action_feedback_details", "rosapi_msgs/srv/ActionFeedbackDetails"),
        ]:
            r = ws_request(ws, {"op": "call_service", "service": svc,
                "type": svc_type, "args": {"type": atype}, "id": f"ad_{part}"}, args.timeout)
            if "values" in r:
                for td in r["values"].get("typedefs", []):
                    result[part] = dict(zip(td.get("fieldnames", []), td.get("fieldtypes", [])))
    ws.close()
    output(result)


def cmd_actions_send(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    try:
        goal = json.loads(args.goal)
    except json.JSONDecodeError as e:
        return output({"error": f"Invalid JSON goal: {e}"})

    goal_id = f"goal_{int(time.time() * 1000)}"
    ws.send(json.dumps({
        "op": "send_action_goal", "id": goal_id, "action": args.action,
        "action_type": args.action_type, "args": goal, "feedback": True
    }))

    end_time = time.time() + args.timeout
    while time.time() < end_time:
        ws.settimeout(max(0.5, end_time - time.time()))
        try:
            raw = ws.recv()
            data = json.loads(raw)
            if data.get("op") == "action_result":
                ws.close()
                return output({"action": args.action, "success": True,
                    "goal_id": goal_id, "result": data.get("values", {})})
        except websocket.WebSocketTimeoutException:
            continue
        except Exception:
            break

    ws.close()
    output({"action": args.action, "goal_id": goal_id, "success": False,
            "error": f"Timeout after {args.timeout}s"})


def cmd_version(args):
    ws, err = ws_connect(args.ip, args.port, args.timeout)
    if err:
        return output({"error": err})
    # Try ROS 2
    resp = ws_request(ws, {
        "op": "call_service", "service": "/rosapi/get_ros_version",
        "args": {}, "id": "ver"
    }, args.timeout)
    if "values" in resp and "version" in resp["values"]:
        ws.close()
        return output({"version": resp["values"]["version"], "distro": resp["values"].get("distro", "")})
    # Fallback ROS 1
    resp = ws_request(ws, {
        "op": "call_service", "service": "/rosapi/get_param",
        "args": {"name": "/rosdistro"}, "id": "ver1"
    }, args.timeout)
    ws.close()
    if "values" in resp:
        distro = resp["values"].get("value", "").strip('"').strip()
        output({"version": "1", "distro": distro})
    else:
        output({"error": "Could not detect ROS version"})


# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------

def build_parser():
    parser = argparse.ArgumentParser(description="ROS Skill CLI - Control ROS robots via rosbridge")
    parser.add_argument("--ip", default="127.0.0.1", help="Rosbridge IP (default: 127.0.0.1)")
    parser.add_argument("--port", type=int, default=9090, help="Rosbridge port (default: 9090)")
    parser.add_argument("--timeout", type=float, default=5.0, help="Timeout in seconds (default: 5)")
    sub = parser.add_subparsers(dest="command")

    # connect
    sub.add_parser("connect", help="Test connection to rosbridge")

    # version
    sub.add_parser("version", help="Detect ROS version")

    # topics
    topics = sub.add_parser("topics", help="Topic operations")
    tsub = topics.add_subparsers(dest="subcommand")
    tsub.add_parser("list", help="List all topics")
    p = tsub.add_parser("type", help="Get topic message type")
    p.add_argument("topic")
    p = tsub.add_parser("details", help="Get topic details")
    p.add_argument("topic")
    p = tsub.add_parser("message", help="Get message structure")
    p.add_argument("message_type")
    p = tsub.add_parser("subscribe", help="Subscribe to a topic")
    p.add_argument("topic")
    p.add_argument("msg_type")
    p.add_argument("--duration", type=float, default=None, help="Subscribe for duration (seconds)")
    p.add_argument("--max-messages", type=int, default=100, help="Max messages to collect")
    p = tsub.add_parser("publish", help="Publish a message")
    p.add_argument("topic")
    p.add_argument("msg_type")
    p.add_argument("msg", help="JSON message")
    p.add_argument("--duration", type=float, default=None, help="Publish repeatedly for duration (seconds)")
    p.add_argument("--rate", type=float, default=10.0, help="Publish rate in Hz (default: 10)")
    p = tsub.add_parser("publish-sequence", help="Publish message sequence with delays")
    p.add_argument("topic")
    p.add_argument("msg_type")
    p.add_argument("messages", help="JSON array of messages")
    p.add_argument("durations", help="JSON array of durations in seconds (message is repeated during each)")
    p.add_argument("--rate", type=float, default=10.0, help="Publish rate in Hz (default: 10)")

    # services
    services = sub.add_parser("services", help="Service operations")
    ssub = services.add_subparsers(dest="subcommand")
    ssub.add_parser("list", help="List all services")
    p = ssub.add_parser("type", help="Get service type")
    p.add_argument("service")
    p = ssub.add_parser("details", help="Get service details")
    p.add_argument("service")
    p = ssub.add_parser("call", help="Call a service")
    p.add_argument("service")
    p.add_argument("service_type")
    p.add_argument("request", help="JSON request")

    # nodes
    nodes = sub.add_parser("nodes", help="Node operations")
    nsub = nodes.add_subparsers(dest="subcommand")
    nsub.add_parser("list", help="List all nodes")
    p = nsub.add_parser("details", help="Get node details")
    p.add_argument("node")

    # params (ROS 2 only)
    params = sub.add_parser("params", help="Parameter operations (ROS 2 only)")
    psub = params.add_subparsers(dest="subcommand")
    p = psub.add_parser("list", help="List parameters for a node")
    p.add_argument("node")
    p = psub.add_parser("get", help="Get parameter value")
    p.add_argument("name")
    p = psub.add_parser("set", help="Set parameter value")
    p.add_argument("name")
    p.add_argument("value")

    # actions (ROS 2 only)
    actions = sub.add_parser("actions", help="Action operations (ROS 2 only)")
    asub = actions.add_subparsers(dest="subcommand")
    asub.add_parser("list", help="List all actions")
    p = asub.add_parser("details", help="Get action details")
    p.add_argument("action")
    p = asub.add_parser("send", help="Send action goal")
    p.add_argument("action")
    p.add_argument("action_type")
    p.add_argument("goal", help="JSON goal")

    return parser


# ---------------------------------------------------------------------------
# Main dispatch
# ---------------------------------------------------------------------------

DISPATCH = {
    ("connect", None): cmd_connect,
    ("version", None): cmd_version,
    ("topics", "list"): cmd_topics_list,
    ("topics", "type"): cmd_topics_type,
    ("topics", "details"): cmd_topics_details,
    ("topics", "message"): cmd_topics_message,
    ("topics", "subscribe"): cmd_topics_subscribe,
    ("topics", "publish"): cmd_topics_publish,
    ("topics", "publish-sequence"): cmd_topics_publish_sequence,
    ("services", "list"): cmd_services_list,
    ("services", "type"): cmd_services_type,
    ("services", "details"): cmd_services_details,
    ("services", "call"): cmd_services_call,
    ("nodes", "list"): cmd_nodes_list,
    ("nodes", "details"): cmd_nodes_details,
    ("params", "list"): cmd_params_list,
    ("params", "get"): cmd_params_get,
    ("params", "set"): cmd_params_set,
    ("actions", "list"): cmd_actions_list,
    ("actions", "details"): cmd_actions_details,
    ("actions", "send"): cmd_actions_send,
}


def main():
    parser = build_parser()
    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        sys.exit(1)

    key = (args.command, getattr(args, "subcommand", None))
    handler = DISPATCH.get(key)
    if handler:
        handler(args)
    else:
        parser.print_help()
        sys.exit(1)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)