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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 53 additions & 6 deletions dispatch_cli/commands/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2515,6 +2515,12 @@ def deploy(
else:
logger.error("Authentication failed after retries")
raise typer.Exit(1)
elif e.response.status_code == 409:
logger.error(
f"A deployment is already in progress for agent '{agent_name}'. "
"Wait for it to finish or cancel it before deploying again."
)
raise typer.Exit(1)
else:
logger.error(f"Failed to push image to production: {e}")
raise typer.Exit(1)
Expand Down Expand Up @@ -2594,6 +2600,9 @@ def deploy(
error = data.get("error", "Unknown error")
logger.error(f"Deployment failed: {error}")
raise typer.Exit(1)
elif job_status == "cancelled":
logger.warning("Deployment was cancelled.")
raise typer.Exit(1)
time.sleep(2)
except typer.Exit:
raise
Expand Down Expand Up @@ -3091,9 +3100,47 @@ def validate(
else:
logger.debug(f"Using existing image: {image_tag}")

# 5. Check handler schemas and typed payloads
# 5. Check dependencies resolve for linux (deployment target)
logger.info("")
logger.info("5. Checking dependencies resolve for linux...")
try:
dep_check = subprocess.run(
[
"uv",
"sync",
"--dry-run",
"--python-platform",
"linux",
"--no-install-project",
],
capture_output=True,
text=True,
cwd=abs_path,
)
if dep_check.returncode != 0:
stderr = dep_check.stderr.strip()
logger.error("Some dependencies don't have linux wheels:")
for line in stderr.splitlines():
if "error:" in line or "hint:" in line:
logger.error(f" {line.strip()}")
logger.info(
"Add tool.uv.required-environments to your pyproject.toml "
"to ensure your dependencies have linux wheels:"
)
logger.info(
" [tool.uv]\n"
" required-environments = [\"sys_platform == 'linux'"
" and platform_machine == 'x86_64'\"]"
)
validation_passed = False
else:
logger.success("All dependencies have linux-compatible packages.")
except FileNotFoundError:
logger.debug("uv not found, skipping linux dependency check.")

# 6. Check handler schemas and typed payloads
logger.info("")
logger.info("5. Checking handler schemas and typed payloads...")
logger.info("6. Checking handler schemas and typed payloads...")
try:
agent_schemas = extract_handler_schemas_from_agent(abs_path)
if not agent_schemas:
Expand All @@ -3112,9 +3159,9 @@ def validate(
logger.error(f"Handler schema validation failed: {e}")
validation_passed = False

# 6. Check schema compatibility
# 7. Check schema compatibility
logger.info("")
logger.info("6. Checking schema compatibility...")
logger.info("7. Checking schema compatibility...")
try:
if not agent_schemas:
agent_schemas = extract_handler_schemas_from_agent(abs_path)
Expand All @@ -3130,9 +3177,9 @@ def validate(
logger.error(f"Schema validation failed: {e}")
validation_passed = False

# 6. Check GitHub integration if agent uses GitHub topics
# 8. Check GitHub integration if agent uses GitHub topics
logger.info("")
logger.info("6. Checking GitHub integration requirements...")
logger.info("8. Checking GitHub integration requirements...")
try:
if agent_schemas:
github_warnings = check_github_integration_if_needed(
Expand Down
186 changes: 131 additions & 55 deletions dispatch_cli/commands/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path
from typing import Annotated

import tomlkit
import typer

from dispatch_cli.auth import get_api_key, get_api_key_from_keychain
Expand All @@ -26,6 +27,7 @@ class RegisterMode(StrEnum):
AUTO = "auto"
CLAUDE = "claude"
CURSOR = "cursor"
CODEX = "codex"


def find_git_root() -> Path | None:
Expand Down Expand Up @@ -68,6 +70,18 @@ def get_cursor_config_paths() -> list[Path]:
return [project_config] if cursor_dir.exists() else []


def get_codex_config_paths() -> list[Path]:
"""Get Codex MCP config file paths (project-level only)."""
git_root = find_git_root()
if git_root:
codex_dir = git_root / ".codex"
else:
codex_dir = Path(".codex")

config_path = codex_dir / "config.toml"
return [config_path] if codex_dir.exists() else []


def find_mcp_config_files() -> list[tuple[str, Path]]:
"""Find all existing MCP config files.

Expand All @@ -85,9 +99,67 @@ def find_mcp_config_files() -> list[tuple[str, Path]]:
if cursor_path.exists():
configs.append(("cursor", cursor_path))

# Check Codex configs (project-level only)
for codex_path in get_codex_config_paths():
if codex_path.exists():
configs.append(("codex", codex_path))

return configs


def write_json_mcp_config(
config_path: Path, server_name: str, server_config: dict
) -> None:
"""Write an MCP server entry to a JSON config file (Claude, Cursor)."""
if config_path.exists():
with open(config_path) as f:
config_data = json.load(f)
else:
config_data = {}

if "mcpServers" not in config_data:
config_data["mcpServers"] = {}

config_data["mcpServers"][server_name] = server_config

config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w") as f:
json.dump(config_data, f, indent=2)
f.write("\n")


def write_toml_mcp_config(
config_path: Path, server_name: str, server_config: dict
) -> None:
"""Write an MCP server entry to a TOML config file (Codex)."""
if config_path.exists():
with open(config_path) as f:
config_data = tomlkit.load(f)
else:
config_data = tomlkit.document()

if "mcp_servers" not in config_data:
config_data["mcp_servers"] = tomlkit.table(is_super_table=True)

mcp_servers = config_data["mcp_servers"]
assert isinstance(mcp_servers, dict)
mcp_servers[server_name] = server_config

config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w") as f:
tomlkit.dump(config_data, f)


def update_mcp_config(
client_name: str, config_path: Path, server_name: str, server_config: dict
) -> None:
"""Write an MCP server entry to the appropriate config file format."""
if client_name == "codex":
write_toml_mcp_config(config_path, server_name, server_config)
else:
write_json_mcp_config(config_path, server_name, server_config)


@serve_app.command("agent")
def serve_agent(
namespace: Annotated[
Expand Down Expand Up @@ -168,43 +240,43 @@ def serve_agent(
get_logger().error("No .cursor directory found")
raise typer.Exit(1)
configs_to_update = [("cursor", cursor_paths[0])]
case RegisterMode.CODEX:
codex_paths = get_codex_config_paths()
if not codex_paths:
# Explicit --register codex: create .codex/ dir
git_root = find_git_root()
base = git_root if git_root else Path(".")
codex_paths = [base / ".codex" / "config.toml"]
configs_to_update = [("codex", codex_paths[0])]
case RegisterMode.AUTO:
configs_to_update = find_mcp_config_files()
if not configs_to_update:
get_logger().error("No MCP config files found")
raise typer.Exit(1)

# Build server config
server_config = {
"command": "dispatch",
"args": [
"mcp",
"serve",
"agent",
"--namespace",
namespace,
"--agent",
agent,
]
+ (["--experimental-tasks"] if experimental_tasks else []),
}

# Update configs
for client_name, config_path in configs_to_update:
if config_path.exists():
with open(config_path) as f:
config_data = json.load(f)
if client_name == "codex":
server_name = f"dispatch_agent_{namespace}_{agent}"
else:
config_data = {}

if "mcpServers" not in config_data:
config_data["mcpServers"] = {}

server_name = f"dispatch-agent-{namespace}-{agent}"
config_data["mcpServers"][server_name] = {
"command": "dispatch",
"args": [
"mcp",
"serve",
"agent",
"--namespace",
namespace,
"--agent",
agent,
]
+ (["--experimental-tasks"] if experimental_tasks else []),
}

config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w") as f:
json.dump(config_data, f, indent=2)
f.write("\n")
server_name = f"dispatch-agent-{namespace}-{agent}"

update_mcp_config(client_name, config_path, server_name, server_config)
get_logger().success(f"Updated {client_name} config: {config_path}")

get_logger().info("")
Expand Down Expand Up @@ -294,43 +366,47 @@ def serve_operator(
get_logger().error("No .cursor directory found")
raise typer.Exit(1)
configs_to_update = [("cursor", cursor_paths[0])]
case RegisterMode.CODEX:
codex_paths = get_codex_config_paths()
if not codex_paths:
# Explicit --register codex: create .codex/ dir
git_root = find_git_root()
base = git_root if git_root else Path(".")
codex_paths = [base / ".codex" / "config.toml"]
configs_to_update = [("codex", codex_paths[0])]
case RegisterMode.AUTO:
configs_to_update = find_mcp_config_files()
if not configs_to_update:
get_logger().error("No MCP config files found")
raise typer.Exit(1)

# Build server config
args = ["mcp", "serve", "operator"]
if namespace:
args.extend(["--namespace", namespace])

server_config = {
"command": "dispatch",
"args": args,
}

# Update configs
for client_name, config_path in configs_to_update:
if config_path.exists():
with open(config_path) as f:
config_data = json.load(f)
if client_name == "codex":
server_name = (
f"dispatch_operator_{namespace}"
if namespace
else "dispatch_operator"
)
else:
config_data = {}

if "mcpServers" not in config_data:
config_data["mcpServers"] = {}

server_name = (
f"dispatch-operator-{namespace}"
if namespace
else "dispatch-operator"
)
args = ["mcp", "serve", "operator"]
if namespace:
args.extend(["--namespace", namespace])

config_data["mcpServers"][server_name] = {
"command": "dispatch",
"args": args,
}

config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w") as f:
json.dump(config_data, f, indent=2)
f.write("\n")

print(f"[green]✓[/green] Updated {client_name} config: {config_path}")
server_name = (
f"dispatch-operator-{namespace}"
if namespace
else "dispatch-operator"
)

update_mcp_config(client_name, config_path, server_name, server_config)
get_logger().success(f"Updated {client_name} config: {config_path}")

get_logger().info("")
get_logger().success("Operator MCP server registered")
Expand Down
Empty file.
Loading
Loading