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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,9 @@ All examples below assume the corresponding CLI already runs standalone on your
| [Cursor](https://cursor.com) | `clawteam spawn subprocess cursor --team ...` | 🔮 Experimental |
| Custom scripts | `clawteam spawn subprocess python --team ...` | ✅ Full support |

OpenClaw commands are normalized to `openclaw agent --message ...` automatically.
For headless/report-style workflows, prefer `subprocess` or a dedicated `profile`.

For provider-aware setups such as Claude Code via Moonshot Kimi or Gemini via
Vertex, use `profile` + `preset` and then spawn with `--profile`.

Expand Down
9 changes: 8 additions & 1 deletion clawteam/spawn/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class PreparedCommand:


class NativeCliAdapter:
"""Adapter for direct CLI runtimes such as claude, codex, gemini, kimi, nanobot, qwen, opencode."""
"""Adapter for direct CLI runtimes such as claude, codex, gemini, kimi, nanobot, openclaw, qwen, opencode."""

def prepare_command(
self,
Expand Down Expand Up @@ -60,6 +60,8 @@ def prepare_command(
post_launch_prompt = prompt
elif is_codex_command(normalized_command):
final_command.append(prompt)
elif is_openclaw_command(normalized_command):
final_command.extend(["--message", prompt])
else:
final_command.extend(["-p", prompt])

Expand Down Expand Up @@ -107,6 +109,11 @@ def is_qwen_command(command: list[str]) -> bool:
return command_basename(command) in ("qwen", "qwen-code")


def is_openclaw_command(command: list[str]) -> bool:
"""Check if the command is an OpenClaw CLI invocation."""
return command_basename(command) == "openclaw"


def is_opencode_command(command: list[str]) -> bool:
"""Check if the command is an OpenCode CLI invocation."""
return command_basename(command) == "opencode"
Expand Down
7 changes: 7 additions & 0 deletions clawteam/spawn/command_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def normalize_spawn_command(command: list[str]) -> list[str]:
executable = Path(command[0]).name
if executable == "nanobot" and len(command) == 1:
return [command[0], "agent"]
if executable == "openclaw" and len(command) == 1:
return [command[0], "agent"]

return list(command)

Expand Down Expand Up @@ -92,6 +94,11 @@ def is_qwen_command(command: list[str]) -> bool:
return _cmd_basename(command) in ("qwen", "qwen-code")


def is_openclaw_command(command: list[str]) -> bool:
"""Check if the command is an OpenClaw CLI invocation."""
return _cmd_basename(command) == "openclaw"


def is_opencode_command(command: list[str]) -> bool:
"""Check if the command is an OpenCode CLI invocation."""
return _cmd_basename(command) == "opencode"
Expand Down
6 changes: 5 additions & 1 deletion docs/skills/clawteam/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Requires Python 3.10+. For P2P transport support: `pip install clawteam[p2p]`.
## Prerequisites

- `tmux` installed (default spawn backend)
- A CLI coding agent such as `claude`, `codex`, `gemini`, `kimi`, or `nanobot`
- A CLI coding agent such as `claude`, `codex`, `gemini`, `kimi`, `nanobot`, or `openclaw`
- A git repository for worktree isolation and context features
- Default dependencies installed if you want the TUI wizard (`clawteam profile wizard`)

Expand Down Expand Up @@ -187,6 +187,10 @@ Common validated CLIs include:
- `gemini`
- `kimi`
- `nanobot`
- `openclaw`

OpenClaw commands are normalized to `openclaw agent --message ...` automatically.
For headless/report-style workflows, prefer `subprocess` or a dedicated `profile`.

Configure non-default providers through `profile` + `preset` instead of hardcoding env vars into prompts.

Expand Down
17 changes: 17 additions & 0 deletions tests/test_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
NativeCliAdapter,
command_basename,
is_interactive_cli,
is_openclaw_command,
is_opencode_command,
is_qwen_command,
)
Expand All @@ -27,6 +28,12 @@ def test_is_opencode_command(self):
assert not is_opencode_command(["openai"])
assert not is_opencode_command([])

def test_is_openclaw_command(self):
assert is_openclaw_command(["openclaw"])
assert is_openclaw_command(["/usr/local/bin/openclaw"])
assert not is_openclaw_command(["opencode"])
assert not is_openclaw_command([])

def test_is_interactive_cli_covers_all_known(self):
for cmd in ["claude", "codex", "nanobot", "gemini", "kimi", "qwen", "opencode"]:
assert is_interactive_cli([cmd]), f"{cmd} should be interactive"
Expand Down Expand Up @@ -85,6 +92,16 @@ def test_opencode_prompt_via_flag(self):
assert "analyse this" in result.final_command
assert result.post_launch_prompt is None

def test_openclaw_prompt_uses_message_flag_and_normalizes_agent(self):
result = self.adapter.prepare_command(
["openclaw"], prompt="analyse this",
)
assert result.final_command[:2] == ["openclaw", "agent"]
assert "--message" in result.final_command
assert "analyse this" in result.final_command
assert "-p" not in result.final_command
assert result.post_launch_prompt is None

def test_claude_interactive_gets_post_launch_prompt(self):
result = self.adapter.prepare_command(
["claude"], prompt="hello", interactive=True,
Expand Down
90 changes: 90 additions & 0 deletions tests/test_spawn_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,96 @@ def fake_popen(cmd, **kwargs):
assert "nanobot agent -w /tmp/demo -m 'do work'" in captured["cmd"]


def test_tmux_backend_normalizes_bare_openclaw_and_uses_message_flag(monkeypatch, tmp_path):
monkeypatch.setenv("PATH", "/usr/bin:/bin")
clawteam_bin = tmp_path / "venv" / "bin" / "clawteam"
clawteam_bin.parent.mkdir(parents=True)
clawteam_bin.write_text("#!/bin/sh\n")
monkeypatch.setattr(sys, "argv", [str(clawteam_bin)])

run_calls: list[list[str]] = []

class Result:
def __init__(self, returncode: int = 0, stdout: str = ""):
self.returncode = returncode
self.stdout = stdout
self.stderr = ""

def fake_run(args, **kwargs):
run_calls.append(args)
if args[:3] == ["tmux", "has-session", "-t"]:
return Result(returncode=1)
if args[:3] == ["tmux", "list-panes", "-t"]:
return Result(returncode=0, stdout="9876\n")
return Result(returncode=0)

def fake_which(name, path=None):
if name == "tmux":
return "/usr/bin/tmux"
if name == "openclaw":
return "/usr/bin/openclaw"
return None

monkeypatch.setattr("clawteam.spawn.tmux_backend.shutil.which", fake_which)
monkeypatch.setattr("clawteam.spawn.command_validation.shutil.which", fake_which)
monkeypatch.setattr("clawteam.spawn.tmux_backend.subprocess.run", fake_run)
monkeypatch.setattr("clawteam.spawn.tmux_backend.time.sleep", lambda *_: None)
monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None)

backend = TmuxBackend()
backend.spawn(
command=["openclaw"],
agent_name="worker1",
agent_id="agent-1",
agent_type="general-purpose",
team_name="demo-team",
prompt="do work",
cwd="/tmp/demo",
skip_permissions=True,
)

new_session = next(call for call in run_calls if call[:3] == ["tmux", "new-session", "-d"])
full_cmd = new_session[-1]
assert " openclaw agent --message 'do work';" in full_cmd
assert " openclaw agent -p 'do work';" not in full_cmd


def test_subprocess_backend_normalizes_openclaw_and_uses_message_flag(monkeypatch, tmp_path):
monkeypatch.setenv("PATH", "/usr/bin:/bin")
clawteam_bin = tmp_path / "venv" / "bin" / "clawteam"
clawteam_bin.parent.mkdir(parents=True)
clawteam_bin.write_text("#!/bin/sh\n")
monkeypatch.setattr(sys, "argv", [str(clawteam_bin)])

captured: dict[str, object] = {}

def fake_popen(cmd, **kwargs):
captured["cmd"] = cmd
return DummyProcess()

monkeypatch.setattr(
"clawteam.spawn.command_validation.shutil.which",
lambda name, path=None: "/usr/bin/openclaw" if name == "openclaw" else None,
)
monkeypatch.setattr("clawteam.spawn.subprocess_backend.subprocess.Popen", fake_popen)
monkeypatch.setattr("clawteam.spawn.registry.register_agent", lambda **_: None)

backend = SubprocessBackend()
backend.spawn(
command=["openclaw"],
agent_name="worker1",
agent_id="agent-1",
agent_type="general-purpose",
team_name="demo-team",
prompt="do work",
cwd="/tmp/demo",
skip_permissions=True,
)

assert "openclaw agent --message 'do work'" in captured["cmd"]
assert "openclaw agent -p 'do work'" not in captured["cmd"]


def test_tmux_backend_gemini_skip_permissions_and_prompt(monkeypatch, tmp_path):
"""Gemini gets --yolo for permissions and -p for prompt."""
monkeypatch.setenv("PATH", "/usr/bin:/bin")
Expand Down