openab 0.3.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.
openab/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # OpenAB — Open Agent Bridge
2
+ __version__ = "0.2.0"
openab/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Entry point: python -m openab"""
2
+ from openab.cli.main import app
3
+
4
+ if __name__ == "__main__":
5
+ app()
@@ -0,0 +1,71 @@
1
+ """Agent backends: Cursor, Codex, Gemini, Claude, OpenClaw. 由 agent_config 或环境变量指定后端与选项。"""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ from openab.core.i18n import t
10
+
11
+ from . import claude, codex, cursor, gemini, openclaw
12
+
13
+
14
+ def get_backend(agent_config: dict[str, Any] | None = None) -> str:
15
+ if agent_config:
16
+ agent = agent_config.get("agent") or {}
17
+ b = agent.get("backend") or "cursor"
18
+ return str(b).strip().lower()
19
+ return (os.environ.get("OPENAB_AGENT") or "cursor").strip().lower()
20
+
21
+
22
+ def run_agent(
23
+ prompt: str,
24
+ *,
25
+ workspace: Optional[Path] = None,
26
+ timeout: Optional[int] = None,
27
+ agent_config: Optional[dict[str, Any]] = None,
28
+ ) -> str:
29
+ """同步执行当前配置的 agent,返回完整 stdout 文本。"""
30
+ return asyncio.get_event_loop().run_until_complete(
31
+ run_agent_async(
32
+ prompt,
33
+ workspace=workspace,
34
+ timeout=timeout,
35
+ agent_config=agent_config,
36
+ )
37
+ )
38
+
39
+
40
+ async def run_agent_async(
41
+ prompt: str,
42
+ *,
43
+ workspace: Optional[Path] = None,
44
+ timeout: int = 300,
45
+ lang: str = "en",
46
+ agent_config: Optional[dict[str, Any]] = None,
47
+ ) -> str:
48
+ """异步执行 agent;backend 与各后端选项来自 agent_config,缺省时回退到环境变量。"""
49
+ backend = get_backend(agent_config)
50
+ if backend == "codex":
51
+ return await codex.run_async(
52
+ prompt, workspace=workspace, timeout=timeout, lang=lang, agent_config=agent_config
53
+ )
54
+ if backend == "gemini":
55
+ return await gemini.run_async(
56
+ prompt, workspace=workspace, timeout=timeout, lang=lang, agent_config=agent_config
57
+ )
58
+ if backend == "claude":
59
+ return await claude.run_async(
60
+ prompt, workspace=workspace, timeout=timeout, lang=lang, agent_config=agent_config
61
+ )
62
+ if backend == "openclaw":
63
+ return await openclaw.run_async(
64
+ prompt, workspace=workspace, timeout=timeout, lang=lang, agent_config=agent_config
65
+ )
66
+ return await cursor.run_async(
67
+ prompt, workspace=workspace, timeout=timeout, lang=lang, agent_config=agent_config
68
+ )
69
+
70
+
71
+ __all__ = ["run_agent", "run_agent_async", "get_backend"]
@@ -0,0 +1,100 @@
1
+ """Claude Code CLI backend (https://code.claude.com/docs/en/cli-reference).
2
+
3
+ Print mode: claude -p "query" — response to stdout, then exit.
4
+ Flags used: --output-format text, --no-session-persistence; optional: --model, --max-turns, --add-dir.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import os
10
+ import shutil
11
+ from pathlib import Path
12
+ from typing import Any, Optional
13
+
14
+ from openab.core.i18n import t
15
+
16
+
17
+ def _find_cmd(agent_config: dict[str, Any] | None = None) -> str:
18
+ cmd = "claude"
19
+ if agent_config:
20
+ c = (agent_config.get("claude") or {}).get("cmd")
21
+ if c:
22
+ cmd = str(c)
23
+ if not cmd:
24
+ cmd = os.environ.get("CLAUDE_CLI_CMD", "claude")
25
+ if os.path.isabs(cmd):
26
+ return cmd
27
+ exe = shutil.which(cmd)
28
+ return exe or cmd
29
+
30
+
31
+ def _build_args(
32
+ prompt: str,
33
+ workspace: Optional[Path],
34
+ agent_config: dict[str, Any] | None = None,
35
+ ) -> list[str]:
36
+ cmd = _find_cmd(agent_config)
37
+ args = [
38
+ cmd,
39
+ "--print",
40
+ "--output-format", "text",
41
+ "--no-session-persistence",
42
+ ]
43
+ model = ""
44
+ max_turns = ""
45
+ add_dirs: list[str] = []
46
+ if agent_config:
47
+ c = agent_config.get("claude") or {}
48
+ if c.get("model"):
49
+ model = str(c["model"]).strip()
50
+ if c.get("max_turns") is not None:
51
+ max_turns = str(c["max_turns"]).strip()
52
+ ad = c.get("add_dir")
53
+ if isinstance(ad, str) and ad:
54
+ add_dirs = [d.strip() for d in ad.split(os.pathsep) if d.strip()]
55
+ elif isinstance(ad, list):
56
+ add_dirs = [str(x).strip() for x in ad if str(x).strip()]
57
+ if not model:
58
+ model = os.environ.get("CLAUDE_CLI_MODEL", "").strip()
59
+ if not max_turns:
60
+ max_turns = os.environ.get("CLAUDE_CLI_MAX_TURNS", "").strip()
61
+ if not add_dirs:
62
+ raw = os.environ.get("CLAUDE_CLI_ADD_DIR", "").strip()
63
+ if raw:
64
+ add_dirs = [d.strip() for d in raw.split(os.pathsep) if d.strip()]
65
+ if model:
66
+ args.extend(["--model", model])
67
+ if max_turns and max_turns.isdigit():
68
+ args.extend(["--max-turns", max_turns])
69
+ for d in add_dirs:
70
+ args.extend(["--add-dir", d])
71
+ args.append(prompt)
72
+ return args
73
+
74
+
75
+ async def run_async(
76
+ prompt: str,
77
+ *,
78
+ workspace: Optional[Path] = None,
79
+ timeout: int = 300,
80
+ lang: str = "en",
81
+ agent_config: Optional[dict[str, Any]] = None,
82
+ ) -> str:
83
+ """Run Claude Code CLI in print mode; return stdout as reply."""
84
+ args = _build_args(prompt, workspace, agent_config)
85
+ cwd = str(workspace) if workspace else None
86
+ proc = await asyncio.create_subprocess_exec(
87
+ *args,
88
+ stdout=asyncio.subprocess.PIPE,
89
+ stderr=asyncio.subprocess.PIPE,
90
+ env=os.environ.copy(),
91
+ cwd=cwd,
92
+ )
93
+ try:
94
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
95
+ except asyncio.TimeoutError:
96
+ proc.kill()
97
+ await proc.wait()
98
+ return t(lang, "agent_timeout")
99
+ text = (stdout or b"").decode("utf-8", errors="replace").strip()
100
+ return text or t(lang, "agent_no_output")
openab/agents/codex.py ADDED
@@ -0,0 +1,64 @@
1
+ """OpenAI Codex CLI backend."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import os
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ from openab.core.i18n import t
11
+
12
+
13
+ def _find_cmd(agent_config: dict[str, Any] | None = None) -> str:
14
+ cmd = "codex"
15
+ if agent_config:
16
+ c = (agent_config.get("codex") or {}).get("cmd")
17
+ if c:
18
+ cmd = str(c)
19
+ if not cmd:
20
+ cmd = os.environ.get("CODEX_CMD", "codex")
21
+ if os.path.isabs(cmd):
22
+ return cmd
23
+ exe = shutil.which(cmd)
24
+ return exe or cmd
25
+
26
+
27
+ def _skip_git_check(agent_config: dict[str, Any] | None = None) -> bool:
28
+ if agent_config:
29
+ v = (agent_config.get("codex") or {}).get("skip_git_check")
30
+ if v in (True, "true", "1", 1):
31
+ return True
32
+ return os.environ.get("CODEX_SKIP_GIT_CHECK", "").strip().lower() in ("1", "true", "yes")
33
+
34
+
35
+ async def run_async(
36
+ prompt: str,
37
+ *,
38
+ workspace: Optional[Path] = None,
39
+ timeout: int = 300,
40
+ lang: str = "en",
41
+ agent_config: Optional[dict[str, Any]] = None,
42
+ ) -> str:
43
+ """Codex CLI: codex exec --ephemeral, final message on stdout."""
44
+ cmd = _find_cmd(agent_config)
45
+ args = [cmd, "exec", "--ephemeral"]
46
+ if _skip_git_check(agent_config):
47
+ args.append("--skip-git-repo-check")
48
+ args.append(prompt)
49
+ cwd = str(workspace) if workspace else None
50
+ proc = await asyncio.create_subprocess_exec(
51
+ *args,
52
+ stdout=asyncio.subprocess.PIPE,
53
+ stderr=asyncio.subprocess.DEVNULL,
54
+ env=os.environ.copy(),
55
+ cwd=cwd,
56
+ )
57
+ try:
58
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
59
+ except asyncio.TimeoutError:
60
+ proc.kill()
61
+ await proc.wait()
62
+ return t(lang, "agent_timeout")
63
+ text = (stdout or b"").decode("utf-8", errors="replace").strip()
64
+ return text or t(lang, "agent_no_output")
@@ -0,0 +1,62 @@
1
+ """Cursor Agent CLI backend."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import os
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ from openab.core.i18n import t
11
+
12
+
13
+ def _find_cmd(agent_config: dict[str, Any] | None = None) -> str:
14
+ cmd = "agent"
15
+ if agent_config:
16
+ c = (agent_config.get("cursor") or {}).get("cmd")
17
+ if c:
18
+ cmd = str(c)
19
+ if not cmd:
20
+ cmd = os.environ.get("CURSOR_AGENT_CMD", "agent")
21
+ if os.path.isabs(cmd):
22
+ return cmd
23
+ exe = shutil.which(cmd)
24
+ return exe or cmd
25
+
26
+
27
+ async def run_async(
28
+ prompt: str,
29
+ *,
30
+ workspace: Optional[Path] = None,
31
+ timeout: int = 300,
32
+ lang: str = "en",
33
+ agent_config: Optional[dict[str, Any]] = None,
34
+ ) -> str:
35
+ """Cursor Agent CLI: agent --print --trust."""
36
+ cmd = _find_cmd(agent_config)
37
+ base_args = [
38
+ cmd,
39
+ "agent",
40
+ "--print",
41
+ "--output-format", "text",
42
+ "--trust",
43
+ ]
44
+ if workspace is not None:
45
+ base_args.extend(["--workspace", str(workspace)])
46
+ base_args.extend(["--", prompt])
47
+ env = os.environ.copy()
48
+ proc = await asyncio.create_subprocess_exec(
49
+ *base_args,
50
+ stdout=asyncio.subprocess.PIPE,
51
+ stderr=asyncio.subprocess.STDOUT,
52
+ env=env,
53
+ cwd=str(workspace) if workspace else None,
54
+ )
55
+ try:
56
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
57
+ except asyncio.TimeoutError:
58
+ proc.kill()
59
+ await proc.wait()
60
+ return t(lang, "agent_timeout")
61
+ text = (stdout or b"").decode("utf-8", errors="replace").strip()
62
+ return text or t(lang, "agent_no_output")
@@ -0,0 +1,53 @@
1
+ """Gemini CLI backend (google-gemini/gemini-cli)."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import os
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ from openab.core.i18n import t
11
+
12
+
13
+ def _find_cmd(agent_config: dict[str, Any] | None = None) -> str:
14
+ cmd = "gemini"
15
+ if agent_config:
16
+ c = (agent_config.get("gemini") or {}).get("cmd")
17
+ if c:
18
+ cmd = str(c)
19
+ if not cmd:
20
+ cmd = os.environ.get("GEMINI_CLI_CMD", "gemini")
21
+ if os.path.isabs(cmd):
22
+ return cmd
23
+ exe = shutil.which(cmd)
24
+ return exe or cmd
25
+
26
+
27
+ async def run_async(
28
+ prompt: str,
29
+ *,
30
+ workspace: Optional[Path] = None,
31
+ timeout: int = 300,
32
+ lang: str = "en",
33
+ agent_config: Optional[dict[str, Any]] = None,
34
+ ) -> str:
35
+ """Gemini CLI: gemini -p \"prompt\" → stdout."""
36
+ cmd = _find_cmd(agent_config)
37
+ args = [cmd, "-p", prompt]
38
+ cwd = str(workspace) if workspace else None
39
+ proc = await asyncio.create_subprocess_exec(
40
+ *args,
41
+ stdout=asyncio.subprocess.PIPE,
42
+ stderr=asyncio.subprocess.PIPE,
43
+ env=os.environ.copy(),
44
+ cwd=cwd,
45
+ )
46
+ try:
47
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
48
+ except asyncio.TimeoutError:
49
+ proc.kill()
50
+ await proc.wait()
51
+ return t(lang, "agent_timeout")
52
+ text = (stdout or b"").decode("utf-8", errors="replace").strip()
53
+ return text or t(lang, "agent_no_output")
@@ -0,0 +1,77 @@
1
+ """OpenClaw 后端:通过 CLI `openclaw agent --message "..."` 调用,需先运行 OpenClaw Gateway(或使用本地回退)。
2
+
3
+ 参考:https://docs.openclaw.ai/tools/agent-send
4
+ 安装:npm install -g openclaw@latest,并运行 openclaw onboard / openclaw gateway。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import os
10
+ import shutil
11
+ from pathlib import Path
12
+ from typing import Any, Optional
13
+
14
+ from openab.core.i18n import t
15
+
16
+
17
+ def _find_cmd(agent_config: dict[str, Any] | None = None) -> str:
18
+ cmd = "openclaw"
19
+ if agent_config:
20
+ c = (agent_config.get("openclaw") or {}).get("cmd")
21
+ if c:
22
+ cmd = str(c)
23
+ if not cmd:
24
+ cmd = os.environ.get("OPENCLAW_CMD", "openclaw")
25
+ if os.path.isabs(cmd):
26
+ return cmd
27
+ exe = shutil.which(cmd)
28
+ return exe or cmd
29
+
30
+
31
+ def _strip_media_lines(text: str) -> str:
32
+ """去掉 OpenClaw 输出中的 MEDIA: 行,只保留回复正文。"""
33
+ lines = []
34
+ for line in text.splitlines():
35
+ if line.strip().startswith("MEDIA:"):
36
+ continue
37
+ lines.append(line)
38
+ return "\n".join(lines).strip()
39
+
40
+
41
+ async def run_async(
42
+ prompt: str,
43
+ *,
44
+ workspace: Optional[Path] = None,
45
+ timeout: int = 300,
46
+ lang: str = "en",
47
+ agent_config: Optional[dict[str, Any]] = None,
48
+ ) -> str:
49
+ """调用 openclaw agent --message \"<prompt>\",从 stdout 取回复;默认会过滤 MEDIA: 行。"""
50
+ cmd = _find_cmd(agent_config)
51
+ args = [cmd, "agent", "--message", prompt]
52
+ oc_cfg = (agent_config or {}).get("openclaw") or {}
53
+ to = oc_cfg.get("timeout") or timeout
54
+ if to and int(to) > 0:
55
+ args.extend(["--timeout", str(int(to))])
56
+ thinking = (oc_cfg.get("thinking") or "").strip().lower()
57
+ if thinking in ("off", "minimal", "low", "medium", "high", "xhigh"):
58
+ args.extend(["--thinking", thinking])
59
+ cwd = str(workspace) if workspace else None
60
+ proc = await asyncio.create_subprocess_exec(
61
+ *args,
62
+ stdout=asyncio.subprocess.PIPE,
63
+ stderr=asyncio.subprocess.PIPE,
64
+ env=os.environ.copy(),
65
+ cwd=cwd,
66
+ )
67
+ try:
68
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
69
+ except asyncio.TimeoutError:
70
+ proc.kill()
71
+ await proc.wait()
72
+ return t(lang, "agent_timeout")
73
+ text = (stdout or b"").decode("utf-8", errors="replace")
74
+ text = _strip_media_lines(text)
75
+ if not text.strip():
76
+ return t(lang, "agent_no_output")
77
+ return text
@@ -0,0 +1 @@
1
+ # Chat frontends: Telegram, (future: Discord, Slack, …)
@@ -0,0 +1,3 @@
1
+ from .bot import run_bot
2
+
3
+ __all__ = ["run_bot"]
@@ -0,0 +1,167 @@
1
+ """Discord bot: receive messages → call agent → reply (chunked). 配置通过参数传入,不读环境变量。"""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ import discord
10
+ from discord import Intents
11
+
12
+ from openab.agents import run_agent_async
13
+ from openab.core.i18n import t
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ MAX_MESSAGE_LENGTH = 2000
18
+ PREFIX = "!"
19
+
20
+
21
+ def _split_message(text: str, max_len: int = MAX_MESSAGE_LENGTH) -> list[str]:
22
+ chunks = []
23
+ while text:
24
+ if len(text) <= max_len:
25
+ chunks.append(text)
26
+ break
27
+ cut = text.rfind("\n", 0, max_len + 1)
28
+ if cut <= 0:
29
+ cut = text.rfind(" ", 0, max_len + 1)
30
+ if cut <= 0:
31
+ cut = max_len
32
+ chunks.append(text[:cut].rstrip())
33
+ text = text[cut:].lstrip()
34
+ return chunks
35
+
36
+
37
+ def _user_lang(_message: discord.Message) -> str:
38
+ return "en"
39
+
40
+
41
+ async def _typing_until_done(channel: discord.abc.Messageable, done: asyncio.Event) -> None:
42
+ while not done.is_set():
43
+ try:
44
+ async with channel.typing():
45
+ await asyncio.wait_for(done.wait(), timeout=4.0)
46
+ except (asyncio.TimeoutError, discord.DiscordException):
47
+ continue
48
+
49
+
50
+ class OpenABDiscordBot(discord.Client):
51
+ def __init__(
52
+ self,
53
+ *,
54
+ intents: Intents,
55
+ allowed_user_ids: frozenset[int],
56
+ workspace: Path,
57
+ timeout: int = 300,
58
+ agent_config: Optional[dict[str, Any]] = None,
59
+ ) -> None:
60
+ super().__init__(intents=intents)
61
+ self._openab_allowed = allowed_user_ids
62
+ self._openab_workspace = workspace
63
+ self._openab_timeout = timeout
64
+ self._openab_agent_config = agent_config or {}
65
+
66
+ def _is_user_allowed(self, user_id: int) -> bool:
67
+ return user_id in self._openab_allowed
68
+
69
+ def _is_auth_enabled(self) -> bool:
70
+ return len(self._openab_allowed) > 0
71
+
72
+ async def handle_command_start(self, message: discord.Message) -> None:
73
+ lang = _user_lang(message)
74
+ user_id = message.author.id
75
+ if self._is_user_allowed(user_id):
76
+ msg = t(lang, "start_welcome")
77
+ else:
78
+ key = "auth_not_configured" if not self._is_auth_enabled() else "unauthorized"
79
+ msg = t(lang, key) + "\n\n" + t(lang, "your_user_id") + str(user_id)
80
+ await message.reply(msg)
81
+
82
+ async def handle_command_whoami(self, message: discord.Message) -> None:
83
+ lang = _user_lang(message)
84
+ user_id = message.author.id
85
+ name = message.author.display_name or str(message.author)
86
+ status = t(lang, "status_authorized") if self._is_user_allowed(user_id) else t(lang, "status_unauthorized")
87
+ msg = (
88
+ f"{t(lang, 'whoami_id')}{user_id}\n"
89
+ f"{t(lang, 'whoami_username')}{name}\n"
90
+ f"{t(lang, 'whoami_status')}{status}"
91
+ )
92
+ await message.reply(msg)
93
+
94
+ async def handle_agent_message(self, message: discord.Message) -> None:
95
+ user_id = message.author.id
96
+ lang = _user_lang(message)
97
+ if not self._is_user_allowed(user_id):
98
+ key = "auth_not_configured" if not self._is_auth_enabled() else "unauthorized"
99
+ msg = t(lang, key) + "\n\n" + t(lang, "your_user_id") + str(user_id)
100
+ await message.reply(msg)
101
+ return
102
+
103
+ prompt = (message.content or "").strip()
104
+ if not prompt:
105
+ await message.reply(t(lang, "prompt_empty"))
106
+ return
107
+
108
+ done = asyncio.Event()
109
+ typing_task = asyncio.create_task(_typing_until_done(message.channel, done))
110
+
111
+ try:
112
+ reply = await run_agent_async(
113
+ prompt,
114
+ workspace=self._openab_workspace,
115
+ timeout=self._openab_timeout,
116
+ lang=lang,
117
+ agent_config=self._openab_agent_config,
118
+ )
119
+ except Exception as e:
120
+ logger.exception("agent run error")
121
+ reply = t(lang, "agent_error", error=str(e))
122
+ finally:
123
+ done.set()
124
+ typing_task.cancel()
125
+ try:
126
+ await typing_task
127
+ except asyncio.CancelledError:
128
+ pass
129
+
130
+ for chunk in _split_message(reply):
131
+ await message.reply(chunk)
132
+
133
+ async def on_ready(self) -> None:
134
+ logger.info("Discord bot logged in as %s", self.user)
135
+
136
+ async def on_message(self, message: discord.Message) -> None:
137
+ if message.author.bot:
138
+ return
139
+ content = (message.content or "").strip()
140
+ if content == f"{PREFIX}start":
141
+ await self.handle_command_start(message)
142
+ return
143
+ if content == f"{PREFIX}whoami":
144
+ await self.handle_command_whoami(message)
145
+ return
146
+ await self.handle_agent_message(message)
147
+
148
+
149
+ def run_bot(
150
+ token: str,
151
+ *,
152
+ workspace: Path,
153
+ timeout: int = 300,
154
+ allowed_user_ids: Optional[frozenset[int]] = None,
155
+ agent_config: Optional[dict[str, Any]] = None,
156
+ ) -> None:
157
+ ids = allowed_user_ids or frozenset()
158
+ intents = Intents.default()
159
+ intents.message_content = True
160
+ client = OpenABDiscordBot(
161
+ intents=intents,
162
+ allowed_user_ids=ids,
163
+ workspace=workspace,
164
+ timeout=timeout,
165
+ agent_config=agent_config,
166
+ )
167
+ client.run(token)
@@ -0,0 +1,3 @@
1
+ from .bot import build_application, run_bot
2
+
3
+ __all__ = ["build_application", "run_bot"]