phalanx-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phalanx_cli-0.1.0/PKG-INFO +16 -0
- phalanx_cli-0.1.0/README.md +142 -0
- phalanx_cli-0.1.0/phalanx/__init__.py +3 -0
- phalanx_cli-0.1.0/phalanx/artifacts/__init__.py +16 -0
- phalanx_cli-0.1.0/phalanx/artifacts/reader.py +49 -0
- phalanx_cli-0.1.0/phalanx/artifacts/schema.py +31 -0
- phalanx_cli-0.1.0/phalanx/artifacts/writer.py +64 -0
- phalanx_cli-0.1.0/phalanx/backends/__init__.py +14 -0
- phalanx_cli-0.1.0/phalanx/backends/base.py +56 -0
- phalanx_cli-0.1.0/phalanx/backends/claude.py +74 -0
- phalanx_cli-0.1.0/phalanx/backends/codex.py +64 -0
- phalanx_cli-0.1.0/phalanx/backends/cursor.py +72 -0
- phalanx_cli-0.1.0/phalanx/backends/gemini.py +70 -0
- phalanx_cli-0.1.0/phalanx/backends/model_router.py +15 -0
- phalanx_cli-0.1.0/phalanx/backends/registry.py +48 -0
- phalanx_cli-0.1.0/phalanx/cli.py +597 -0
- phalanx_cli-0.1.0/phalanx/comms/__init__.py +1 -0
- phalanx_cli-0.1.0/phalanx/comms/file_lock.py +37 -0
- phalanx_cli-0.1.0/phalanx/comms/messaging.py +50 -0
- phalanx_cli-0.1.0/phalanx/config.py +107 -0
- phalanx_cli-0.1.0/phalanx/db.py +325 -0
- phalanx_cli-0.1.0/phalanx/defaults/config.toml +48 -0
- phalanx_cli-0.1.0/phalanx/init_cmd.py +215 -0
- phalanx_cli-0.1.0/phalanx/monitor/__init__.py +1 -0
- phalanx_cli-0.1.0/phalanx/monitor/gc.py +36 -0
- phalanx_cli-0.1.0/phalanx/monitor/heartbeat.py +41 -0
- phalanx_cli-0.1.0/phalanx/monitor/lifecycle.py +57 -0
- phalanx_cli-0.1.0/phalanx/monitor/stall.py +48 -0
- phalanx_cli-0.1.0/phalanx/process/__init__.py +1 -0
- phalanx_cli-0.1.0/phalanx/process/manager.py +124 -0
- phalanx_cli-0.1.0/phalanx/process/pool.py +77 -0
- phalanx_cli-0.1.0/phalanx/process/worktree.py +94 -0
- phalanx_cli-0.1.0/phalanx/soul/__init__.py +17 -0
- phalanx_cli-0.1.0/phalanx/soul/loader.py +103 -0
- phalanx_cli-0.1.0/phalanx/soul/skill_body.md +50 -0
- phalanx_cli-0.1.0/phalanx/soul/team_lead.md +27 -0
- phalanx_cli-0.1.0/phalanx/soul/worker.md +22 -0
- phalanx_cli-0.1.0/phalanx/team/__init__.py +12 -0
- phalanx_cli-0.1.0/phalanx/team/create.py +105 -0
- phalanx_cli-0.1.0/phalanx/team/orchestrator.py +98 -0
- phalanx_cli-0.1.0/phalanx/team/spawn.py +146 -0
- phalanx_cli-0.1.0/phalanx_cli.egg-info/PKG-INFO +16 -0
- phalanx_cli-0.1.0/phalanx_cli.egg-info/SOURCES.txt +47 -0
- phalanx_cli-0.1.0/phalanx_cli.egg-info/dependency_links.txt +1 -0
- phalanx_cli-0.1.0/phalanx_cli.egg-info/entry_points.txt +2 -0
- phalanx_cli-0.1.0/phalanx_cli.egg-info/requires.txt +13 -0
- phalanx_cli-0.1.0/phalanx_cli.egg-info/top_level.txt +1 -0
- phalanx_cli-0.1.0/pyproject.toml +45 -0
- phalanx_cli-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: phalanx-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open-source, vendor-agnostic multi-agent orchestration CLI
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: click>=8.1
|
|
8
|
+
Requires-Dist: libtmux>=0.37
|
|
9
|
+
Requires-Dist: tomli>=2.0; python_version < "3.12"
|
|
10
|
+
Requires-Dist: tomli-w>=1.0
|
|
11
|
+
Requires-Dist: pydantic>=2.0
|
|
12
|
+
Requires-Dist: rich>=13.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
16
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Phalanx
|
|
2
|
+
|
|
3
|
+
Open-source, vendor-agnostic multi-agent orchestration CLI.
|
|
4
|
+
|
|
5
|
+
Phalanx lets you spin up teams of AI coding agents from any supported backend (Cursor, Claude Code, Gemini CLI, Codex CLI) and orchestrate them through a single unified interface.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e ".[dev]"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires: Python 3.11+, tmux.
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Single agent (proxies to your default backend)
|
|
19
|
+
phalanx run "fix the failing tests"
|
|
20
|
+
|
|
21
|
+
# Create a team of agents
|
|
22
|
+
phalanx create-team --task "refactor auth module" --agents researcher,coder:2,reviewer --json
|
|
23
|
+
|
|
24
|
+
# Check team status
|
|
25
|
+
phalanx team-status <team-id> --json
|
|
26
|
+
|
|
27
|
+
# Read results
|
|
28
|
+
phalanx team-result <team-id> --json
|
|
29
|
+
|
|
30
|
+
# Stop when done
|
|
31
|
+
phalanx stop <team-id>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## IDE Integration
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
phalanx init
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Auto-detects installed IDEs and deploys skill files:
|
|
41
|
+
- **Cursor**: `.cursor/rules/phalanx.mdc`
|
|
42
|
+
- **Claude Code**: `.claude/commands/phalanx.md`
|
|
43
|
+
- **Gemini CLI**: `.gemini/phalanx-policy.md`
|
|
44
|
+
- **Codex CLI**: `AGENTS.md`
|
|
45
|
+
|
|
46
|
+
## Supported Backends
|
|
47
|
+
|
|
48
|
+
| Backend | Binary | Worktree | Model Routing |
|
|
49
|
+
|---------|--------|----------|---------------|
|
|
50
|
+
| Cursor | `agent` | Native | All vendors |
|
|
51
|
+
| Claude Code | `claude` | Native | Anthropic |
|
|
52
|
+
| Gemini CLI | `gemini` | Phalanx-managed | Google |
|
|
53
|
+
| Codex CLI | `codex` | Phalanx-managed | OpenAI |
|
|
54
|
+
|
|
55
|
+
## Model Routing
|
|
56
|
+
|
|
57
|
+
Phalanx automatically selects the best model per agent role and backend. Configurable in `~/.phalanx/config.toml`.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
phalanx models show # view routing table
|
|
61
|
+
phalanx models set cursor.coder opus-4.6 # override
|
|
62
|
+
phalanx models reset # restore defaults
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Agent Roles
|
|
66
|
+
|
|
67
|
+
| Role | Purpose |
|
|
68
|
+
|------|---------|
|
|
69
|
+
| `researcher` | Investigation, large-context analysis |
|
|
70
|
+
| `coder` | Implementation, bug fixes, tests |
|
|
71
|
+
| `reviewer` | Code review, large diffs |
|
|
72
|
+
| `architect` | Design decisions, high-stakes reasoning |
|
|
73
|
+
| `orchestrator` | Team lead (auto-assigned) |
|
|
74
|
+
|
|
75
|
+
## Architecture
|
|
76
|
+
|
|
77
|
+
- **State**: SQLite (WAL mode) at `~/.phalanx/state.db`
|
|
78
|
+
- **Process isolation**: tmux sessions per agent
|
|
79
|
+
- **Artifacts**: Ephemeral JSON (deleted after 24h inactivity)
|
|
80
|
+
- **File locking**: Advisory locks via SQLite
|
|
81
|
+
- **Stall detection**: stream.log monitoring with exponential backoff retry
|
|
82
|
+
- **GC**: Opportunistic, runs on every command
|
|
83
|
+
|
|
84
|
+
## Commands
|
|
85
|
+
|
|
86
|
+
### User-Facing
|
|
87
|
+
| Command | Description |
|
|
88
|
+
|---------|-------------|
|
|
89
|
+
| `phalanx run "prompt"` | Single agent session |
|
|
90
|
+
| `phalanx init` | Deploy IDE skill files |
|
|
91
|
+
| `phalanx create-team` | Create agent team |
|
|
92
|
+
| `phalanx team-status <id>` | Team status |
|
|
93
|
+
| `phalanx team-result <id>` | Read team results |
|
|
94
|
+
| `phalanx message <id> "msg"` | Message team lead |
|
|
95
|
+
| `phalanx stop <id>` | Stop team |
|
|
96
|
+
| `phalanx resume <id>` | Resume team |
|
|
97
|
+
| `phalanx status` | List all teams |
|
|
98
|
+
| `phalanx config show/set` | Configuration |
|
|
99
|
+
| `phalanx models show/set/reset/update` | Model routing |
|
|
100
|
+
|
|
101
|
+
### Agent Tools (used by spawned agents)
|
|
102
|
+
| Command | Description |
|
|
103
|
+
|---------|-------------|
|
|
104
|
+
| `phalanx write-artifact` | Write structured result |
|
|
105
|
+
| `phalanx agent-status` | Check peer status |
|
|
106
|
+
| `phalanx agent-result <id>` | Read peer artifact |
|
|
107
|
+
| `phalanx message-agent <id> "msg"` | Message a worker |
|
|
108
|
+
| `phalanx lock/unlock <path>` | File locking |
|
|
109
|
+
|
|
110
|
+
## Testing
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pytest tests/unit/ # 125 unit tests
|
|
114
|
+
pytest tests/integration/ # 26 integration tests (requires tmux)
|
|
115
|
+
pytest tests/e2e/ # 11 end-to-end tests
|
|
116
|
+
pytest tests/ # all 162 tests
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Configuration
|
|
120
|
+
|
|
121
|
+
Global: `~/.phalanx/config.toml`
|
|
122
|
+
Workspace override: `.phalanx/config.toml`
|
|
123
|
+
|
|
124
|
+
```toml
|
|
125
|
+
[defaults]
|
|
126
|
+
backend = "cursor"
|
|
127
|
+
|
|
128
|
+
[timeouts]
|
|
129
|
+
agent_inactivity_minutes = 30
|
|
130
|
+
team_gc_hours = 24
|
|
131
|
+
stall_seconds = 180
|
|
132
|
+
|
|
133
|
+
[models.cursor]
|
|
134
|
+
orchestrator = "sonnet-4.6"
|
|
135
|
+
coder = "sonnet-4.6"
|
|
136
|
+
researcher = "gemini-3.1-pro"
|
|
137
|
+
default = "gemini-3.1-pro"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Artifact system: schema, writer, reader."""
|
|
2
|
+
|
|
3
|
+
from .schema import Artifact, ArtifactStatus
|
|
4
|
+
from .writer import write_artifact, get_artifact_path, get_stream_log_path
|
|
5
|
+
from .reader import read_artifact, read_team_result, list_artifacts
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Artifact",
|
|
9
|
+
"ArtifactStatus",
|
|
10
|
+
"write_artifact",
|
|
11
|
+
"get_artifact_path",
|
|
12
|
+
"get_stream_log_path",
|
|
13
|
+
"read_artifact",
|
|
14
|
+
"read_team_result",
|
|
15
|
+
"list_artifacts",
|
|
16
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Read artifacts written by agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .schema import Artifact
|
|
10
|
+
from .writer import get_artifact_path, TEAMS_DIR
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read_artifact(team_id: str, agent_id: str) -> Artifact | None:
|
|
14
|
+
"""Read and validate an agent's artifact. Returns None if not found."""
|
|
15
|
+
path = get_artifact_path(team_id, agent_id)
|
|
16
|
+
if not path.exists():
|
|
17
|
+
return None
|
|
18
|
+
data = json.loads(path.read_text())
|
|
19
|
+
return Artifact(**data)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_team_result(team_id: str) -> Artifact | None:
|
|
23
|
+
"""Read the team lead's artifact (the consolidated team result)."""
|
|
24
|
+
team_dir = TEAMS_DIR / team_id / "agents"
|
|
25
|
+
if not team_dir.exists():
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
for agent_dir in team_dir.iterdir():
|
|
29
|
+
artifact_path = agent_dir / "artifact.json"
|
|
30
|
+
if artifact_path.exists():
|
|
31
|
+
data = json.loads(artifact_path.read_text())
|
|
32
|
+
if data.get("agent_id", "").startswith("lead"):
|
|
33
|
+
return Artifact(**data)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def list_artifacts(team_id: str) -> list[Artifact]:
|
|
38
|
+
"""List all artifacts for a team."""
|
|
39
|
+
team_dir = TEAMS_DIR / team_id / "agents"
|
|
40
|
+
if not team_dir.exists():
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
artifacts = []
|
|
44
|
+
for agent_dir in sorted(team_dir.iterdir()):
|
|
45
|
+
artifact_path = agent_dir / "artifact.json"
|
|
46
|
+
if artifact_path.exists():
|
|
47
|
+
data = json.loads(artifact_path.read_text())
|
|
48
|
+
artifacts.append(Artifact(**data))
|
|
49
|
+
return artifacts
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Artifact Pydantic models and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ArtifactStatus(str, Enum):
|
|
13
|
+
SUCCESS = "success"
|
|
14
|
+
FAILURE = "failure"
|
|
15
|
+
ESCALATION = "escalation_required"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TokenUsage(BaseModel):
|
|
19
|
+
input_tokens: int = 0
|
|
20
|
+
output_tokens: int = 0
|
|
21
|
+
total_cost_usd: float = 0.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Artifact(BaseModel):
|
|
25
|
+
status: ArtifactStatus
|
|
26
|
+
agent_id: str
|
|
27
|
+
team_id: str
|
|
28
|
+
output: dict[str, Any] = Field(default_factory=dict)
|
|
29
|
+
warnings: list[str] = Field(default_factory=list)
|
|
30
|
+
token_usage: TokenUsage = Field(default_factory=TokenUsage)
|
|
31
|
+
created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Atomic artifact writer — used by agents via `phalanx write-artifact`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .schema import Artifact, ArtifactStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
TEAMS_DIR = Path.home() / ".phalanx" / "teams"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_artifact_path(team_id: str, agent_id: str) -> Path:
|
|
18
|
+
return TEAMS_DIR / team_id / "agents" / agent_id / "artifact.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_stream_log_path(team_id: str, agent_id: str) -> Path:
|
|
22
|
+
return TEAMS_DIR / team_id / "agents" / agent_id / "stream.log"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_artifact(
|
|
26
|
+
status: str,
|
|
27
|
+
output: dict[str, Any],
|
|
28
|
+
team_id: str | None = None,
|
|
29
|
+
agent_id: str | None = None,
|
|
30
|
+
warnings: list[str] | None = None,
|
|
31
|
+
) -> Artifact:
|
|
32
|
+
"""Validate and atomically write an artifact to disk.
|
|
33
|
+
|
|
34
|
+
Reads PHALANX_TEAM_ID and PHALANX_AGENT_ID from env if not provided.
|
|
35
|
+
"""
|
|
36
|
+
team_id = team_id or os.environ.get("PHALANX_TEAM_ID", "")
|
|
37
|
+
agent_id = agent_id or os.environ.get("PHALANX_AGENT_ID", "")
|
|
38
|
+
|
|
39
|
+
if not team_id or not agent_id:
|
|
40
|
+
raise ValueError("team_id and agent_id are required (set env or pass explicitly)")
|
|
41
|
+
|
|
42
|
+
artifact = Artifact(
|
|
43
|
+
status=ArtifactStatus(status),
|
|
44
|
+
agent_id=agent_id,
|
|
45
|
+
team_id=team_id,
|
|
46
|
+
output=output,
|
|
47
|
+
warnings=warnings or [],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
path = get_artifact_path(team_id, agent_id)
|
|
51
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
# Atomic write: write to temp file then rename
|
|
54
|
+
fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|
55
|
+
try:
|
|
56
|
+
with os.fdopen(fd, "w") as f:
|
|
57
|
+
f.write(artifact.model_dump_json(indent=2))
|
|
58
|
+
os.replace(tmp_path, str(path))
|
|
59
|
+
except Exception:
|
|
60
|
+
if os.path.exists(tmp_path):
|
|
61
|
+
os.unlink(tmp_path)
|
|
62
|
+
raise
|
|
63
|
+
|
|
64
|
+
return artifact
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Backend adapters for agent CLIs."""
|
|
2
|
+
|
|
3
|
+
from .base import AgentBackend
|
|
4
|
+
from .registry import detect_available, detect_default, get_backend, list_backends
|
|
5
|
+
from .model_router import resolve_model
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AgentBackend",
|
|
9
|
+
"detect_available",
|
|
10
|
+
"detect_default",
|
|
11
|
+
"get_backend",
|
|
12
|
+
"list_backends",
|
|
13
|
+
"resolve_model",
|
|
14
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Abstract base class for agent CLI backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AgentBackend(ABC):
|
|
10
|
+
"""Interface every CLI adapter must implement."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def build_interactive_command(
|
|
16
|
+
self,
|
|
17
|
+
prompt: str,
|
|
18
|
+
workspace: Path,
|
|
19
|
+
model: str | None = None,
|
|
20
|
+
worktree: str | None = None,
|
|
21
|
+
soul_file: Path | None = None,
|
|
22
|
+
) -> list[str]:
|
|
23
|
+
"""Command for TUI mode (user-facing agent)."""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def build_headless_command(
|
|
27
|
+
self,
|
|
28
|
+
prompt: str,
|
|
29
|
+
workspace: Path,
|
|
30
|
+
model: str | None = None,
|
|
31
|
+
worktree: str | None = None,
|
|
32
|
+
soul_file: Path | None = None,
|
|
33
|
+
json_output: bool = True,
|
|
34
|
+
auto_approve: bool = True,
|
|
35
|
+
) -> list[str]:
|
|
36
|
+
"""Command for --print / headless mode (team agents in tmux)."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def build_resume_command(
|
|
40
|
+
self,
|
|
41
|
+
chat_id: str,
|
|
42
|
+
message: str | None = None,
|
|
43
|
+
) -> list[str]:
|
|
44
|
+
"""Resume an existing session."""
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def detect(self) -> bool:
|
|
48
|
+
"""Return True if this CLI is installed and available."""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def supports_worktree(self) -> bool:
|
|
52
|
+
"""Whether the CLI has native --worktree support."""
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def binary_name(self) -> str:
|
|
56
|
+
"""The CLI binary name (e.g. 'agent', 'claude')."""
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Claude Code CLI adapter (binary: claude)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .base import AgentBackend
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ClaudeBackend(AgentBackend):
|
|
12
|
+
name = "claude"
|
|
13
|
+
|
|
14
|
+
def binary_name(self) -> str:
|
|
15
|
+
return "claude"
|
|
16
|
+
|
|
17
|
+
def detect(self) -> bool:
|
|
18
|
+
return shutil.which("claude") is not None
|
|
19
|
+
|
|
20
|
+
def supports_worktree(self) -> bool:
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
def build_interactive_command(
|
|
24
|
+
self,
|
|
25
|
+
prompt: str,
|
|
26
|
+
workspace: Path,
|
|
27
|
+
model: str | None = None,
|
|
28
|
+
worktree: str | None = None,
|
|
29
|
+
soul_file: Path | None = None,
|
|
30
|
+
) -> list[str]:
|
|
31
|
+
cmd = ["claude"]
|
|
32
|
+
if model:
|
|
33
|
+
cmd += ["--model", model]
|
|
34
|
+
if worktree:
|
|
35
|
+
cmd += ["--worktree", worktree]
|
|
36
|
+
if soul_file:
|
|
37
|
+
cmd += ["--append-system-prompt", soul_file.read_text()]
|
|
38
|
+
if prompt:
|
|
39
|
+
cmd.append(prompt)
|
|
40
|
+
return cmd
|
|
41
|
+
|
|
42
|
+
def build_headless_command(
|
|
43
|
+
self,
|
|
44
|
+
prompt: str,
|
|
45
|
+
workspace: Path,
|
|
46
|
+
model: str | None = None,
|
|
47
|
+
worktree: str | None = None,
|
|
48
|
+
soul_file: Path | None = None,
|
|
49
|
+
json_output: bool = True,
|
|
50
|
+
auto_approve: bool = True,
|
|
51
|
+
) -> list[str]:
|
|
52
|
+
cmd = ["claude", "--print"]
|
|
53
|
+
if auto_approve:
|
|
54
|
+
cmd += ["--dangerously-skip-permissions"]
|
|
55
|
+
if model:
|
|
56
|
+
cmd += ["--model", model]
|
|
57
|
+
if worktree:
|
|
58
|
+
cmd += ["--worktree", worktree]
|
|
59
|
+
if json_output:
|
|
60
|
+
cmd += ["--output-format", "stream-json"]
|
|
61
|
+
if soul_file:
|
|
62
|
+
cmd += ["--append-system-prompt", soul_file.read_text()]
|
|
63
|
+
cmd.append(prompt)
|
|
64
|
+
return cmd
|
|
65
|
+
|
|
66
|
+
def build_resume_command(
|
|
67
|
+
self,
|
|
68
|
+
chat_id: str,
|
|
69
|
+
message: str | None = None,
|
|
70
|
+
) -> list[str]:
|
|
71
|
+
cmd = ["claude", "--resume", chat_id, "--print", "--dangerously-skip-permissions"]
|
|
72
|
+
if message:
|
|
73
|
+
cmd.append(message)
|
|
74
|
+
return cmd
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Codex CLI adapter (binary: codex)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .base import AgentBackend
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CodexBackend(AgentBackend):
|
|
12
|
+
name = "codex"
|
|
13
|
+
|
|
14
|
+
def binary_name(self) -> str:
|
|
15
|
+
return "codex"
|
|
16
|
+
|
|
17
|
+
def detect(self) -> bool:
|
|
18
|
+
return shutil.which("codex") is not None
|
|
19
|
+
|
|
20
|
+
def supports_worktree(self) -> bool:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
def build_interactive_command(
|
|
24
|
+
self,
|
|
25
|
+
prompt: str,
|
|
26
|
+
workspace: Path,
|
|
27
|
+
model: str | None = None,
|
|
28
|
+
worktree: str | None = None,
|
|
29
|
+
soul_file: Path | None = None,
|
|
30
|
+
) -> list[str]:
|
|
31
|
+
cmd = ["codex"]
|
|
32
|
+
if model:
|
|
33
|
+
cmd += ["--model", model]
|
|
34
|
+
cmd += ["--cd", str(workspace)]
|
|
35
|
+
if prompt:
|
|
36
|
+
cmd.append(prompt)
|
|
37
|
+
return cmd
|
|
38
|
+
|
|
39
|
+
def build_headless_command(
|
|
40
|
+
self,
|
|
41
|
+
prompt: str,
|
|
42
|
+
workspace: Path,
|
|
43
|
+
model: str | None = None,
|
|
44
|
+
worktree: str | None = None,
|
|
45
|
+
soul_file: Path | None = None,
|
|
46
|
+
json_output: bool = True,
|
|
47
|
+
auto_approve: bool = True,
|
|
48
|
+
) -> list[str]:
|
|
49
|
+
cmd = ["codex", "exec"]
|
|
50
|
+
if model:
|
|
51
|
+
cmd += ["--model", model]
|
|
52
|
+
cmd += ["--cd", str(workspace)]
|
|
53
|
+
if auto_approve:
|
|
54
|
+
cmd += ["--sandbox", "workspace-write", "-a", "never"]
|
|
55
|
+
cmd.append(prompt)
|
|
56
|
+
return cmd
|
|
57
|
+
|
|
58
|
+
def build_resume_command(
|
|
59
|
+
self,
|
|
60
|
+
chat_id: str,
|
|
61
|
+
message: str | None = None,
|
|
62
|
+
) -> list[str]:
|
|
63
|
+
cmd = ["codex", "resume", "--last"]
|
|
64
|
+
return cmd
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Cursor CLI adapter (binary: agent)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .base import AgentBackend
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CursorBackend(AgentBackend):
|
|
12
|
+
name = "cursor"
|
|
13
|
+
|
|
14
|
+
def binary_name(self) -> str:
|
|
15
|
+
return "agent"
|
|
16
|
+
|
|
17
|
+
def detect(self) -> bool:
|
|
18
|
+
return shutil.which("agent") is not None
|
|
19
|
+
|
|
20
|
+
def supports_worktree(self) -> bool:
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
def build_interactive_command(
|
|
24
|
+
self,
|
|
25
|
+
prompt: str,
|
|
26
|
+
workspace: Path,
|
|
27
|
+
model: str | None = None,
|
|
28
|
+
worktree: str | None = None,
|
|
29
|
+
soul_file: Path | None = None,
|
|
30
|
+
) -> list[str]:
|
|
31
|
+
cmd = ["agent"]
|
|
32
|
+
if model:
|
|
33
|
+
cmd += ["--model", model]
|
|
34
|
+
if worktree:
|
|
35
|
+
cmd += ["--worktree", worktree]
|
|
36
|
+
cmd += ["--workspace", str(workspace)]
|
|
37
|
+
if prompt:
|
|
38
|
+
cmd.append(prompt)
|
|
39
|
+
return cmd
|
|
40
|
+
|
|
41
|
+
def build_headless_command(
|
|
42
|
+
self,
|
|
43
|
+
prompt: str,
|
|
44
|
+
workspace: Path,
|
|
45
|
+
model: str | None = None,
|
|
46
|
+
worktree: str | None = None,
|
|
47
|
+
soul_file: Path | None = None,
|
|
48
|
+
json_output: bool = True,
|
|
49
|
+
auto_approve: bool = True,
|
|
50
|
+
) -> list[str]:
|
|
51
|
+
cmd = ["agent", "--print"]
|
|
52
|
+
if model:
|
|
53
|
+
cmd += ["--model", model]
|
|
54
|
+
if worktree:
|
|
55
|
+
cmd += ["--worktree", worktree]
|
|
56
|
+
if json_output:
|
|
57
|
+
cmd += ["--output-format", "stream-json"]
|
|
58
|
+
cmd += ["--workspace", str(workspace)]
|
|
59
|
+
if auto_approve:
|
|
60
|
+
cmd += ["--trust", "--force", "--approve-mcps"]
|
|
61
|
+
cmd.append(prompt)
|
|
62
|
+
return cmd
|
|
63
|
+
|
|
64
|
+
def build_resume_command(
|
|
65
|
+
self,
|
|
66
|
+
chat_id: str,
|
|
67
|
+
message: str | None = None,
|
|
68
|
+
) -> list[str]:
|
|
69
|
+
cmd = ["agent", "--resume", chat_id, "--print", "--force", "--trust"]
|
|
70
|
+
if message:
|
|
71
|
+
cmd.append(message)
|
|
72
|
+
return cmd
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Gemini CLI adapter (binary: gemini)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .base import AgentBackend
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GeminiBackend(AgentBackend):
|
|
12
|
+
name = "gemini"
|
|
13
|
+
|
|
14
|
+
def binary_name(self) -> str:
|
|
15
|
+
return "gemini"
|
|
16
|
+
|
|
17
|
+
def detect(self) -> bool:
|
|
18
|
+
return shutil.which("gemini") is not None
|
|
19
|
+
|
|
20
|
+
def supports_worktree(self) -> bool:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
def build_interactive_command(
|
|
24
|
+
self,
|
|
25
|
+
prompt: str,
|
|
26
|
+
workspace: Path,
|
|
27
|
+
model: str | None = None,
|
|
28
|
+
worktree: str | None = None,
|
|
29
|
+
soul_file: Path | None = None,
|
|
30
|
+
) -> list[str]:
|
|
31
|
+
cmd = ["gemini"]
|
|
32
|
+
if model:
|
|
33
|
+
cmd += ["--model", model]
|
|
34
|
+
if soul_file:
|
|
35
|
+
cmd += ["--policy", str(soul_file)]
|
|
36
|
+
if prompt:
|
|
37
|
+
cmd.append(prompt)
|
|
38
|
+
return cmd
|
|
39
|
+
|
|
40
|
+
def build_headless_command(
|
|
41
|
+
self,
|
|
42
|
+
prompt: str,
|
|
43
|
+
workspace: Path,
|
|
44
|
+
model: str | None = None,
|
|
45
|
+
worktree: str | None = None,
|
|
46
|
+
soul_file: Path | None = None,
|
|
47
|
+
json_output: bool = True,
|
|
48
|
+
auto_approve: bool = True,
|
|
49
|
+
) -> list[str]:
|
|
50
|
+
cmd = ["gemini"]
|
|
51
|
+
if model:
|
|
52
|
+
cmd += ["--model", model]
|
|
53
|
+
if json_output:
|
|
54
|
+
cmd += ["-o", "stream-json"]
|
|
55
|
+
if soul_file:
|
|
56
|
+
cmd += ["--policy", str(soul_file)]
|
|
57
|
+
if auto_approve:
|
|
58
|
+
cmd += ["--yolo"]
|
|
59
|
+
cmd += ["-p", prompt]
|
|
60
|
+
return cmd
|
|
61
|
+
|
|
62
|
+
def build_resume_command(
|
|
63
|
+
self,
|
|
64
|
+
chat_id: str,
|
|
65
|
+
message: str | None = None,
|
|
66
|
+
) -> list[str]:
|
|
67
|
+
cmd = ["gemini", "--resume", chat_id, "--yolo"]
|
|
68
|
+
if message:
|
|
69
|
+
cmd += ["-p", message]
|
|
70
|
+
return cmd
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Config-driven model routing: role + backend → model name."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_model(backend: str, role: str, config: dict[str, Any]) -> str:
|
|
9
|
+
"""Look up the model for a given backend and role from config.
|
|
10
|
+
|
|
11
|
+
Fallback: config[backend][role] → config[backend]["default"].
|
|
12
|
+
Raises KeyError if the backend section is missing from config.
|
|
13
|
+
"""
|
|
14
|
+
models = config["models"][backend]
|
|
15
|
+
return models.get(role, models["default"])
|