orchex-cli 0.1.0__py3-none-any.whl

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.
orchex/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
orchex/__main__.py ADDED
@@ -0,0 +1,2 @@
1
+ from orchex.cli import main
2
+ main()
File without changes
@@ -0,0 +1,40 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Dict, List, Optional
5
+
6
+
7
+ @dataclass
8
+ class AdapterFeature:
9
+ name: str
10
+ description: str
11
+ enabled: bool = True
12
+
13
+
14
+ class AgentAdapter(ABC):
15
+ name: str = ""
16
+ config_dir: Path = Path()
17
+ instruction_files: Dict[str, str] = {}
18
+
19
+ @abstractmethod
20
+ async def detect(self):
21
+ ...
22
+
23
+ @abstractmethod
24
+ async def send_prompt(self, prompt, working_dir):
25
+ ...
26
+
27
+ @abstractmethod
28
+ async def get_version(self):
29
+ ...
30
+
31
+ async def list_features(self):
32
+ return []
33
+
34
+ async def get_instructions(self, project_dir):
35
+ project_file = self.instruction_files.get("project")
36
+ if project_file:
37
+ path = project_dir / project_file
38
+ if path.exists():
39
+ return path.read_text()
40
+ return None
@@ -0,0 +1,62 @@
1
+ import asyncio
2
+ import json
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import List, Optional
6
+
7
+ from orchex.adapters.base import AdapterFeature, AgentAdapter
8
+
9
+
10
+ class ClaudeAdapter(AgentAdapter):
11
+ name = "claude"
12
+ config_dir = Path.home() / ".claude"
13
+ instruction_files = {
14
+ "project": "CLAUDE.md",
15
+ "global": str(Path.home() / ".claude" / "CLAUDE.md"),
16
+ }
17
+
18
+ async def detect(self):
19
+ return shutil.which("claude") is not None
20
+
21
+ async def get_version(self):
22
+ if not await self.detect():
23
+ return None
24
+ proc = await asyncio.create_subprocess_exec(
25
+ "claude", "--version",
26
+ stdout=asyncio.subprocess.PIPE,
27
+ stderr=asyncio.subprocess.PIPE,
28
+ )
29
+ stdout, _ = await proc.communicate()
30
+ return stdout.decode().strip() if proc.returncode == 0 else None
31
+
32
+ async def send_prompt(self, prompt, working_dir):
33
+ proc = await asyncio.create_subprocess_exec(
34
+ "claude", "-p", prompt, "--output-format", "json",
35
+ cwd=str(working_dir),
36
+ stdout=asyncio.subprocess.PIPE,
37
+ stderr=asyncio.subprocess.PIPE,
38
+ )
39
+ stdout, stderr = await proc.communicate()
40
+
41
+ if proc.returncode != 0:
42
+ raise RuntimeError("Claude CLI failed: %s" % stderr.decode())
43
+
44
+ data = json.loads(stdout.decode())
45
+ return data.get("result", "")
46
+
47
+ async def list_features(self):
48
+ features = []
49
+ settings_path = self.config_dir / "settings.json"
50
+ if settings_path.exists():
51
+ try:
52
+ settings = json.loads(settings_path.read_text())
53
+ mcp_servers = settings.get("mcpServers", {})
54
+ for name in mcp_servers:
55
+ features.append(AdapterFeature(
56
+ name="mcp:%s" % name,
57
+ description="MCP server: %s" % name,
58
+ enabled=True,
59
+ ))
60
+ except (json.JSONDecodeError, KeyError):
61
+ pass
62
+ return features
@@ -0,0 +1,46 @@
1
+ import asyncio
2
+ import shutil
3
+ from pathlib import Path
4
+
5
+ from orchex.adapters.base import AdapterFeature, AgentAdapter
6
+
7
+
8
+ class CodexAdapter(AgentAdapter):
9
+ name = "codex"
10
+ config_dir = Path.home() / ".codex"
11
+ instruction_files = {
12
+ "project": "AGENTS.md",
13
+ "global": str(Path.home() / ".codex" / "instructions.md"),
14
+ }
15
+
16
+ async def detect(self):
17
+ return shutil.which("codex") is not None
18
+
19
+ async def get_version(self):
20
+ if not await self.detect():
21
+ return None
22
+ proc = await asyncio.create_subprocess_exec(
23
+ "codex", "--version",
24
+ stdout=asyncio.subprocess.PIPE,
25
+ stderr=asyncio.subprocess.PIPE,
26
+ )
27
+ stdout, _ = await proc.communicate()
28
+ return stdout.decode().strip() if proc.returncode == 0 else None
29
+
30
+ async def send_prompt(self, prompt, working_dir):
31
+ proc = await asyncio.create_subprocess_exec(
32
+ "codex", "exec", prompt,
33
+ cwd=str(working_dir),
34
+ stdout=asyncio.subprocess.PIPE,
35
+ stderr=asyncio.subprocess.PIPE,
36
+ )
37
+ stdout, stderr = await proc.communicate()
38
+ if proc.returncode != 0:
39
+ raise RuntimeError("Codex CLI failed: %s" % stderr.decode())
40
+ return stdout.decode().strip()
41
+
42
+ async def list_features(self):
43
+ return [
44
+ AdapterFeature(name="sandbox", description="Sandboxed execution mode", enabled=True),
45
+ AdapterFeature(name="auto-edit", description="Automatic file editing", enabled=True),
46
+ ]
@@ -0,0 +1,46 @@
1
+ import asyncio
2
+ import shutil
3
+ from pathlib import Path
4
+
5
+ from orchex.adapters.base import AdapterFeature, AgentAdapter
6
+
7
+
8
+ class GeminiAdapter(AgentAdapter):
9
+ name = "gemini"
10
+ config_dir = Path.home() / ".gemini"
11
+ instruction_files = {
12
+ "project": "GEMINI.md",
13
+ "global": str(Path.home() / ".gemini" / "GEMINI.md"),
14
+ }
15
+
16
+ async def detect(self):
17
+ return shutil.which("gemini") is not None
18
+
19
+ async def get_version(self):
20
+ if not await self.detect():
21
+ return None
22
+ proc = await asyncio.create_subprocess_exec(
23
+ "gemini", "--version",
24
+ stdout=asyncio.subprocess.PIPE,
25
+ stderr=asyncio.subprocess.PIPE,
26
+ )
27
+ stdout, _ = await proc.communicate()
28
+ return stdout.decode().strip() if proc.returncode == 0 else None
29
+
30
+ async def send_prompt(self, prompt, working_dir):
31
+ proc = await asyncio.create_subprocess_exec(
32
+ "gemini", "-p", prompt,
33
+ cwd=str(working_dir),
34
+ stdout=asyncio.subprocess.PIPE,
35
+ stderr=asyncio.subprocess.PIPE,
36
+ )
37
+ stdout, stderr = await proc.communicate()
38
+ if proc.returncode != 0:
39
+ raise RuntimeError("Gemini CLI failed: %s" % stderr.decode())
40
+ return stdout.decode().strip()
41
+
42
+ async def list_features(self):
43
+ return [
44
+ AdapterFeature(name="google-search", description="Google Search integration", enabled=True),
45
+ AdapterFeature(name="multimodal", description="Multimodal input support", enabled=True),
46
+ ]
@@ -0,0 +1,11 @@
1
+ from orchex.adapters.claude import ClaudeAdapter
2
+ from orchex.adapters.codex import CodexAdapter
3
+ from orchex.adapters.gemini import GeminiAdapter
4
+
5
+
6
+ def discover_adapters():
7
+ return [
8
+ ClaudeAdapter(),
9
+ CodexAdapter(),
10
+ GeminiAdapter(),
11
+ ]
orchex/cli.py ADDED
@@ -0,0 +1,286 @@
1
+ import asyncio
2
+ from pathlib import Path
3
+
4
+ import click
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.text import Text
8
+ from rich.table import Table
9
+ from rich.rule import Rule
10
+ from rich.live import Live
11
+
12
+ from orchex.core.engine import Engine
13
+
14
+ console = Console()
15
+
16
+ LOGO = """[bold cyan] ___ ____ ____ _ _ _______ __
17
+ / _ \\| _ \\ / ___| | | | ____\\ \\/ /
18
+ | | | | |_) | | | |_| | _| \\ /
19
+ | |_| | _ <| |___| _ | |___ / \\
20
+ \\___/|_| \\_\\\\____|_| |_|_____/_/\\_\\[/bold cyan]"""
21
+
22
+ COLORS = {"claude": "cyan", "codex": "green", "gemini": "yellow"}
23
+
24
+ _verbose_mode = False
25
+
26
+
27
+ def _run(coro):
28
+ return asyncio.run(coro)
29
+
30
+
31
+ async def _get_engine():
32
+ engine = Engine()
33
+ await engine.init()
34
+ return engine
35
+
36
+
37
+ def _agent_color(name):
38
+ return COLORS.get(name, "white")
39
+
40
+
41
+ def _print_header(engine):
42
+ agents_str = " ".join(
43
+ "[%s]%s[/%s]" % (_agent_color(a), a, _agent_color(a))
44
+ for a in engine.available_agents
45
+ )
46
+ offline = [a.name for a in engine.adapters if a.name not in engine.available_agents]
47
+ if offline:
48
+ agents_str += " " + " ".join("[dim red]%s[/dim red]" % a for a in offline)
49
+
50
+ console.print(LOGO)
51
+ console.print(" [bold]orchex[/bold] v0.1.0")
52
+ console.print(" Multi-Provider Agent Orchestrator")
53
+ console.print(" [dim]%s[/dim]" % Path.cwd())
54
+ console.print()
55
+ console.print(" Agents: %s" % agents_str)
56
+ console.print()
57
+ console.print(Rule(style="dim"))
58
+ console.print()
59
+
60
+
61
+ @click.group(invoke_without_command=True)
62
+ @click.pass_context
63
+ def main(ctx):
64
+ """orchex -- Multi-provider AI coding agent orchestrator.
65
+
66
+ All agents collaborate on every request.
67
+ """
68
+ if ctx.invoked_subcommand is None:
69
+ _run(_interactive())
70
+
71
+
72
+ async def _interactive():
73
+ engine = await _get_engine()
74
+
75
+ if not engine.available_agents:
76
+ console.print("[red]No AI agents found. Install claude, codex, or gemini CLI.[/red]")
77
+ return
78
+
79
+ _print_header(engine)
80
+
81
+ while True:
82
+ try:
83
+ user_input = console.input("[bold]> [/bold]")
84
+ except (EOFError, KeyboardInterrupt):
85
+ console.print("\n[dim]Bye.[/dim]")
86
+ break
87
+
88
+ user_input = user_input.strip()
89
+ if not user_input:
90
+ continue
91
+
92
+ if user_input.startswith("/"):
93
+ handled = await _handle_command(engine, user_input)
94
+ if handled == "quit":
95
+ break
96
+ continue
97
+
98
+ await _orchestrate(engine, user_input)
99
+
100
+
101
+ async def _handle_command(engine, cmd):
102
+ parts = cmd.split(None, 1)
103
+ command = parts[0].lower()
104
+ arg = parts[1] if len(parts) > 1 else ""
105
+
106
+ if command in ("/quit", "/exit", "/q"):
107
+ console.print("[dim]Bye.[/dim]")
108
+ return "quit"
109
+
110
+ elif command == "/agents":
111
+ statuses = await engine.get_agent_status()
112
+ table = Table(show_header=True, expand=True, border_style="dim")
113
+ table.add_column("Agent", style="bold")
114
+ table.add_column("Status")
115
+ table.add_column("Version")
116
+ table.add_column("Features", style="dim")
117
+ for s in statuses:
118
+ status = Text("ONLINE", style="green") if s["detected"] else Text("OFFLINE", style="red")
119
+ ver = s.get("version") or "-"
120
+ feats = ", ".join(f["name"] for f in s.get("features", [])[:3]) or "-"
121
+ table.add_row(s["name"], status, ver, feats)
122
+ console.print(table)
123
+ console.print()
124
+
125
+ elif command == "/verbose":
126
+ global _verbose_mode
127
+ _verbose_mode = not _verbose_mode
128
+ state = "ON" if _verbose_mode else "OFF"
129
+ console.print("[dim]Verbose mode: %s[/dim]" % state)
130
+
131
+ elif command == "/help":
132
+ console.print(Panel(
133
+ "[bold]Commands:[/bold]\n"
134
+ " /agents Show agent status\n"
135
+ " /verbose Toggle detailed output (responses + reviews)\n"
136
+ " /help This help\n"
137
+ " /quit Exit\n"
138
+ "\n"
139
+ "[bold]How it works:[/bold]\n"
140
+ " Type any question or task. All agents work on it\n"
141
+ " independently, then cross-review each other's work,\n"
142
+ " and deliver a consolidated answer.",
143
+ title="Help",
144
+ border_style="dim",
145
+ ))
146
+
147
+ else:
148
+ console.print("[yellow]Unknown command. Type /help[/yellow]")
149
+
150
+ return None
151
+
152
+
153
+ async def _orchestrate(engine, user_input):
154
+ """Full pipeline with live status indicators."""
155
+ import time
156
+
157
+ agent_status = {name: "waiting" for name in engine.available_agents}
158
+ current_phase = ""
159
+ start_time = time.time()
160
+
161
+ def _status_line():
162
+ """Build a dynamic status line showing each agent's state."""
163
+ parts = []
164
+ for name in engine.available_agents:
165
+ c = _agent_color(name)
166
+ st = agent_status[name]
167
+ if st == "waiting":
168
+ parts.append("[dim]%s[/dim]" % name)
169
+ elif st == "working":
170
+ parts.append("[bold %s]%s ...[/bold %s]" % (c, name, c))
171
+ elif st == "done":
172
+ parts.append("[%s]%s OK[/%s]" % (c, name, c))
173
+ elif st == "error":
174
+ parts.append("[red]%s ERR[/red]" % name)
175
+ elapsed = "%.0fs" % (time.time() - start_time)
176
+ return "[dim][%s][/dim] %s [dim]%s[/dim]" % (current_phase, " ".join(parts), elapsed)
177
+
178
+ def on_agent_done(agent_name, result):
179
+ if result.get("ok"):
180
+ agent_status[agent_name] = "done"
181
+ else:
182
+ agent_status[agent_name] = "error"
183
+ # Update live display
184
+ if live:
185
+ live.update(_status_line())
186
+
187
+ def on_phase(phase, data):
188
+ nonlocal current_phase
189
+ if phase == "asking":
190
+ current_phase = "Phase 1/3: Researching"
191
+ for name in engine.available_agents:
192
+ agent_status[name] = "working"
193
+ elif phase == "responses_done":
194
+ pass
195
+ elif phase == "reviewing":
196
+ current_phase = "Phase 2/3: Cross-Review"
197
+ for name in engine.available_agents:
198
+ if agent_status[name] == "done":
199
+ agent_status[name] = "working"
200
+ elif phase == "reviews_done":
201
+ pass
202
+ elif phase == "synthesizing":
203
+ current_phase = "Phase 3/3: Synthesizing"
204
+ for name in engine.available_agents:
205
+ agent_status[name] = "waiting"
206
+ agent_status[engine.available_agents[0]] = "working"
207
+ if live:
208
+ live.update(_status_line())
209
+
210
+ live = Live(_status_line(), console=console, refresh_per_second=4)
211
+ with live:
212
+ result = await engine.orchestrate(
213
+ user_input, on_phase=on_phase, on_agent_done=on_agent_done
214
+ )
215
+
216
+ elapsed_total = "%.0fs" % (time.time() - start_time)
217
+ console.print()
218
+
219
+ # Verbose: show agent responses and reviews
220
+ if _verbose_mode:
221
+ console.print(Rule("Agent Responses", style="dim"))
222
+ console.print()
223
+ for r in result["responses"]:
224
+ c = _agent_color(r["agent"])
225
+ if r["ok"]:
226
+ console.print("[bold %s]%s[/bold %s]" % (c, r["agent"], c))
227
+ console.print(r["response"][:500])
228
+ console.print()
229
+
230
+ if result["reviews"]:
231
+ console.print(Rule("Cross Reviews", style="dim"))
232
+ console.print()
233
+ for r in result["reviews"]:
234
+ if isinstance(r, dict) and r.get("ok"):
235
+ c = _agent_color(r["agent"])
236
+ console.print("[bold %s]%s review[/bold %s]" % (c, r["agent"], c))
237
+ console.print(r["response"][:500])
238
+ console.print()
239
+
240
+ # Always show final answer
241
+ agent_count = len([r for r in result["responses"] if r["ok"]])
242
+ review_count = len([r for r in result.get("reviews", []) if isinstance(r, dict) and r.get("ok")])
243
+ meta = "[dim]%d agents | %d reviews | %s[/dim]" % (agent_count, review_count, elapsed_total)
244
+
245
+ console.print(Panel(
246
+ result["summary"][:3000],
247
+ title="[bold green]orchex[/bold green]",
248
+ subtitle=meta,
249
+ border_style="green",
250
+ ))
251
+ console.print()
252
+
253
+
254
+ # --- Non-interactive subcommands ---
255
+
256
+ @main.command()
257
+ def agents():
258
+ """Show agent status."""
259
+ async def _exec():
260
+ engine = await _get_engine()
261
+ statuses = await engine.get_agent_status()
262
+ table = Table(show_header=True, expand=True, border_style="dim")
263
+ table.add_column("Agent", style="bold")
264
+ table.add_column("Status")
265
+ table.add_column("Version")
266
+ table.add_column("Features", style="dim")
267
+ for s in statuses:
268
+ status = Text("ONLINE", style="green") if s["detected"] else Text("OFFLINE", style="red")
269
+ ver = s.get("version") or "-"
270
+ feats = ", ".join(f["name"] for f in s.get("features", [])[:3]) or "-"
271
+ table.add_row(s["name"], status, ver, feats)
272
+ console.print(table)
273
+ _run(_exec())
274
+
275
+
276
+ @main.command()
277
+ @click.argument("prompt")
278
+ def ask(prompt):
279
+ """Ask all agents a question (non-interactive)."""
280
+ async def _exec():
281
+ engine = await _get_engine()
282
+ if not engine.available_agents:
283
+ console.print("[red]No agents found.[/red]")
284
+ return
285
+ await _orchestrate(engine, prompt)
286
+ _run(_exec())
File without changes
@@ -0,0 +1,58 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ if sys.version_info >= (3, 11):
6
+ import tomllib
7
+ else:
8
+ import tomli as tomllib
9
+
10
+ from orchex.config.schema import OrchexConfig
11
+
12
+
13
+ def _resolve_env(value):
14
+ if isinstance(value, str) and value.startswith("env:"):
15
+ return os.environ.get(value[4:], "")
16
+ return value
17
+
18
+
19
+ def load_config(project_dir):
20
+ config_path = project_dir / ".orchex" / "config.toml"
21
+ cfg = OrchexConfig()
22
+
23
+ if not config_path.exists():
24
+ return cfg
25
+
26
+ with open(config_path, "rb") as f:
27
+ data = tomllib.load(f)
28
+
29
+ ideation = data.get("ideation", {})
30
+ if "max_rounds" in ideation:
31
+ cfg.max_rounds = ideation["max_rounds"]
32
+ if "token_budget" in ideation:
33
+ cfg.token_budget = ideation["token_budget"]
34
+ if "discussion_mode" in ideation:
35
+ cfg.discussion_mode = ideation["discussion_mode"]
36
+
37
+ routing = data.get("routing", {}).get("defaults", {})
38
+ if routing:
39
+ cfg.routing_defaults.update(routing)
40
+
41
+ daemon = data.get("daemon", {})
42
+ if "socket_path" in daemon:
43
+ cfg.socket_path = daemon["socket_path"]
44
+ if "pid_file" in daemon:
45
+ cfg.pid_file = daemon["pid_file"]
46
+
47
+ tg = data.get("notify", {}).get("telegram", {})
48
+ if tg.get("enabled"):
49
+ cfg.telegram_enabled = True
50
+ cfg.telegram_bot_token = _resolve_env(tg.get("bot_token", ""))
51
+ cfg.telegram_chat_id = _resolve_env(tg.get("chat_id", ""))
52
+
53
+ dc = data.get("notify", {}).get("discord", {})
54
+ if dc.get("enabled"):
55
+ cfg.discord_enabled = True
56
+ cfg.discord_webhook_url = _resolve_env(dc.get("webhook_url", ""))
57
+
58
+ return cfg
@@ -0,0 +1,23 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Dict
3
+
4
+
5
+ @dataclass
6
+ class OrchexConfig:
7
+ max_rounds: int = 5
8
+ token_budget: int = 50000
9
+ discussion_mode: str = "free"
10
+ routing_defaults: Dict[str, str] = field(default_factory=lambda: {
11
+ "architecture": "claude",
12
+ "implementation": "codex",
13
+ "research": "gemini",
14
+ "testing": "claude",
15
+ "review": "round-robin",
16
+ })
17
+ socket_path: str = "/tmp/orchex.sock"
18
+ pid_file: str = "/tmp/orchex.pid"
19
+ telegram_enabled: bool = False
20
+ telegram_bot_token: str = ""
21
+ telegram_chat_id: str = ""
22
+ discord_enabled: bool = False
23
+ discord_webhook_url: str = ""
File without changes