aitextaroo 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.
- aitextaroo/__init__.py +18 -0
- aitextaroo/agents.py +235 -0
- aitextaroo/bridge.py +157 -0
- aitextaroo/cli.py +108 -0
- aitextaroo/client.py +403 -0
- aitextaroo/commands.py +106 -0
- aitextaroo/conversation.py +93 -0
- aitextaroo/formatting.py +55 -0
- aitextaroo/models.py +88 -0
- aitextaroo/setup.py +125 -0
- aitextaroo-0.1.0.dist-info/METADATA +193 -0
- aitextaroo-0.1.0.dist-info/RECORD +16 -0
- aitextaroo-0.1.0.dist-info/WHEEL +5 -0
- aitextaroo-0.1.0.dist-info/entry_points.txt +3 -0
- aitextaroo-0.1.0.dist-info/licenses/LICENSE +21 -0
- aitextaroo-0.1.0.dist-info/top_level.txt +1 -0
aitextaroo/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""AI Text-a-roo — give any AI agent a phone number.
|
|
2
|
+
|
|
3
|
+
Connect to the AI Text-a-roo SMS gateway to receive and send
|
|
4
|
+
text messages through any AI agent framework.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> from aitextaroo import TextarooClient
|
|
8
|
+
>>> client = TextarooClient(api_key="your-key")
|
|
9
|
+
>>> async for message in client.listen():
|
|
10
|
+
... print(f"Got: {message.text}")
|
|
11
|
+
... await client.send(f"You said: {message.text}")
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from aitextaroo.client import TextarooClient
|
|
15
|
+
from aitextaroo.models import InboundMessage, StreamEvent
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
__all__ = ["TextarooClient", "InboundMessage", "StreamEvent"]
|
aitextaroo/agents.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Agent abstraction for the AI Text-a-roo bridge.
|
|
2
|
+
|
|
3
|
+
Defines how the bridge communicates with AI agents. Each agent
|
|
4
|
+
takes a text message in and produces a text response.
|
|
5
|
+
|
|
6
|
+
The only implementation today is CliAgent — a one-shot subprocess
|
|
7
|
+
invocation per message. New agent types (HTTP API, long-running
|
|
8
|
+
process, etc.) can be added by implementing the Agent protocol.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
import shutil
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Maximum time to wait for an agent to respond (seconds).
|
|
22
|
+
DEFAULT_TIMEOUT = 120
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Agent(ABC):
|
|
26
|
+
"""Interface for an AI agent that processes text messages.
|
|
27
|
+
|
|
28
|
+
Implementations must be able to:
|
|
29
|
+
1. Accept a text prompt and return a text response.
|
|
30
|
+
2. Provide a display name and optional system prompt.
|
|
31
|
+
3. Release any held resources on close.
|
|
32
|
+
|
|
33
|
+
The bridge reads system_prompt to build conversation context.
|
|
34
|
+
The agent receives the fully-assembled prompt — it does not
|
|
35
|
+
need to manage conversation state or system instructions.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def name(self) -> str:
|
|
41
|
+
"""Human-readable name for display and logging."""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def system_prompt(self) -> str:
|
|
45
|
+
"""System instructions prepended to every prompt by the bridge.
|
|
46
|
+
|
|
47
|
+
Override to provide agent-specific context (e.g., "respond
|
|
48
|
+
concisely, these are SMS messages"). Default is empty.
|
|
49
|
+
"""
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def ask(self, text: str) -> str:
|
|
54
|
+
"""Send a fully-assembled prompt and return the response.
|
|
55
|
+
|
|
56
|
+
The prompt already includes system instructions and
|
|
57
|
+
conversation history (assembled by the bridge). The agent
|
|
58
|
+
just needs to run it.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
text: The complete prompt to send.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The agent's response text.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
asyncio.TimeoutError: If the agent doesn't respond in time.
|
|
68
|
+
RuntimeError: If the agent process fails.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
async def close(self) -> None:
|
|
72
|
+
"""Release any resources held by this agent.
|
|
73
|
+
|
|
74
|
+
Default implementation does nothing. Override if your agent
|
|
75
|
+
holds open connections or processes.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── CLI Agent ────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class CliAgentConfig:
|
|
84
|
+
"""How to invoke a CLI agent.
|
|
85
|
+
|
|
86
|
+
Attributes:
|
|
87
|
+
name: Human-readable name (for logging and display).
|
|
88
|
+
command: Executable name (must be on PATH).
|
|
89
|
+
args: Arguments placed before the prompt.
|
|
90
|
+
system_prompt: Context the bridge prepends to prompts.
|
|
91
|
+
timeout: Max seconds to wait for a response.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
command: str
|
|
96
|
+
args: list[str] = field(default_factory=list)
|
|
97
|
+
system_prompt: str = ""
|
|
98
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class CliAgent(Agent):
|
|
102
|
+
"""Runs a CLI agent as a one-shot subprocess per message.
|
|
103
|
+
|
|
104
|
+
Each call to ask() spawns a fresh process:
|
|
105
|
+
{command} {args} "{prompt}"
|
|
106
|
+
|
|
107
|
+
Stdout is captured as the response. Stderr is logged on failure.
|
|
108
|
+
This is the simplest and most reliable approach — no process
|
|
109
|
+
lifecycle management, no stdin/stdout protocol, no state leaks.
|
|
110
|
+
|
|
111
|
+
The agent receives the complete prompt from the bridge. It does
|
|
112
|
+
not manage system prompts or conversation context — that's the
|
|
113
|
+
bridge's responsibility.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
config: How to invoke the agent CLI.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, config: CliAgentConfig) -> None:
|
|
120
|
+
self._config = config
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def name(self) -> str:
|
|
124
|
+
return self._config.name
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def system_prompt(self) -> str:
|
|
128
|
+
return self._config.system_prompt
|
|
129
|
+
|
|
130
|
+
async def ask(self, text: str) -> str:
|
|
131
|
+
"""Spawn the agent CLI with the prompt and return its output."""
|
|
132
|
+
cmd = [self._config.command, *self._config.args, text]
|
|
133
|
+
|
|
134
|
+
logger.debug("Running: %s %s '%s...'", cmd[0], " ".join(cmd[1:-1]), text[:40])
|
|
135
|
+
|
|
136
|
+
process = await asyncio.create_subprocess_exec(
|
|
137
|
+
*cmd,
|
|
138
|
+
stdout=asyncio.subprocess.PIPE,
|
|
139
|
+
stderr=asyncio.subprocess.PIPE,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
stdout, stderr = await asyncio.wait_for(
|
|
144
|
+
process.communicate(),
|
|
145
|
+
timeout=self._config.timeout,
|
|
146
|
+
)
|
|
147
|
+
except asyncio.TimeoutError:
|
|
148
|
+
process.kill()
|
|
149
|
+
await process.wait()
|
|
150
|
+
raise
|
|
151
|
+
|
|
152
|
+
if process.returncode != 0:
|
|
153
|
+
error_text = stderr.decode(errors="replace")[:200]
|
|
154
|
+
logger.warning(
|
|
155
|
+
"Agent %s exited with code %d: %s",
|
|
156
|
+
self._config.name, process.returncode, error_text,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return stdout.decode(errors="replace").strip()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ── Agent Registry ───────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
# Built-in agent configurations. Each entry defines how to invoke
|
|
165
|
+
# a known CLI agent. The bridge uses these to auto-detect and
|
|
166
|
+
# instantiate agents.
|
|
167
|
+
#
|
|
168
|
+
# To add a new agent: add an entry here and it works automatically.
|
|
169
|
+
# No other code changes needed.
|
|
170
|
+
|
|
171
|
+
BUILTIN_AGENTS: dict[str, CliAgentConfig] = {
|
|
172
|
+
"claude": CliAgentConfig(
|
|
173
|
+
name="Claude Code",
|
|
174
|
+
command="claude",
|
|
175
|
+
args=["-p"],
|
|
176
|
+
system_prompt=(
|
|
177
|
+
"You are receiving SMS text messages from a user. "
|
|
178
|
+
"Respond concisely — these are text messages with a 1600 character limit."
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
"hermes": CliAgentConfig(
|
|
182
|
+
name="Hermes",
|
|
183
|
+
command="hermes",
|
|
184
|
+
args=["--pipe"],
|
|
185
|
+
system_prompt=(
|
|
186
|
+
"You are receiving SMS text messages from a user via AI Text-a-roo. "
|
|
187
|
+
"Respond conversationally. Keep responses concise — they're text messages."
|
|
188
|
+
),
|
|
189
|
+
),
|
|
190
|
+
"openclaw": CliAgentConfig(
|
|
191
|
+
name="OpenClaw",
|
|
192
|
+
command="openclaw",
|
|
193
|
+
args=["--pipe"],
|
|
194
|
+
system_prompt="You are receiving SMS text messages. Respond concisely.",
|
|
195
|
+
),
|
|
196
|
+
"nanoclaw": CliAgentConfig(
|
|
197
|
+
name="NanoClaw",
|
|
198
|
+
command="nanoclaw",
|
|
199
|
+
args=["--pipe"],
|
|
200
|
+
system_prompt="You are receiving SMS text messages. Respond concisely.",
|
|
201
|
+
),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def detect_agents() -> list[str]:
|
|
206
|
+
"""Find which supported agents are installed on this system.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Agent names found on PATH, in preference order.
|
|
210
|
+
"""
|
|
211
|
+
found = []
|
|
212
|
+
for name, config in BUILTIN_AGENTS.items():
|
|
213
|
+
if shutil.which(config.command):
|
|
214
|
+
found.append(name)
|
|
215
|
+
logger.debug("Detected agent: %s (%s)", config.name, config.command)
|
|
216
|
+
return found
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def create_agent(name: str) -> CliAgent:
|
|
220
|
+
"""Create an agent instance by name.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
name: Key in BUILTIN_AGENTS (e.g., "claude", "hermes").
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
A ready-to-use CliAgent.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
ValueError: If the agent name is not recognized.
|
|
230
|
+
"""
|
|
231
|
+
config = BUILTIN_AGENTS.get(name)
|
|
232
|
+
if config is None:
|
|
233
|
+
available = ", ".join(BUILTIN_AGENTS.keys())
|
|
234
|
+
raise ValueError(f"Unknown agent: {name!r}. Available: {available}")
|
|
235
|
+
return CliAgent(config)
|
aitextaroo/bridge.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""SMS-to-agent bridge for AI Text-a-roo.
|
|
2
|
+
|
|
3
|
+
Connects to the AI Text-a-roo SSE stream, forwards inbound SMS
|
|
4
|
+
messages to a local AI agent (with conversation context), and sends
|
|
5
|
+
the agent's formatted responses back as SMS.
|
|
6
|
+
|
|
7
|
+
This module is orchestration — it delegates to:
|
|
8
|
+
- agents.py for agent communication
|
|
9
|
+
- conversation.py for history management
|
|
10
|
+
- commands.py for /slash command handling
|
|
11
|
+
- formatting.py for SMS-friendly output
|
|
12
|
+
- client.py for API communication
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
from aitextaroo.agents import Agent
|
|
20
|
+
from aitextaroo.client import TextarooClient
|
|
21
|
+
from aitextaroo.commands import CommandRouter
|
|
22
|
+
from aitextaroo.conversation import Conversation
|
|
23
|
+
from aitextaroo.formatting import format_for_sms
|
|
24
|
+
from aitextaroo.models import InboundMessage
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# SMS character limit. Responses exceeding this are truncated.
|
|
29
|
+
MAX_SMS_LENGTH = 1600
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Bridge:
|
|
33
|
+
"""Bridges SMS messages between AI Text-a-roo and a local AI agent.
|
|
34
|
+
|
|
35
|
+
The bridge's message flow:
|
|
36
|
+
1. Receive inbound SMS via SSE stream.
|
|
37
|
+
2. If it's a /command, handle locally (no agent call).
|
|
38
|
+
3. Otherwise, build full prompt (system + history + message).
|
|
39
|
+
4. Send to agent, get response.
|
|
40
|
+
5. Record both messages in conversation history.
|
|
41
|
+
6. Format the response for SMS and send it back.
|
|
42
|
+
|
|
43
|
+
The bridge owns prompt construction — the agent just receives
|
|
44
|
+
a complete prompt and returns a response. This keeps system
|
|
45
|
+
prompt and conversation context management in one place.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
client: An authenticated TextarooClient.
|
|
49
|
+
agent: An Agent implementation that processes messages.
|
|
50
|
+
max_history: Max messages to keep in conversation context.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
client: TextarooClient,
|
|
56
|
+
agent: Agent,
|
|
57
|
+
max_history: int = 20,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._client = client
|
|
60
|
+
self._agent = agent
|
|
61
|
+
self._conversation = Conversation(max_messages=max_history)
|
|
62
|
+
self._commands = CommandRouter(
|
|
63
|
+
agent_name=agent.name,
|
|
64
|
+
conversation=self._conversation,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
async def run(self) -> None:
|
|
68
|
+
"""Listen for SMS and bridge them to the agent.
|
|
69
|
+
|
|
70
|
+
Runs indefinitely. The TextarooClient handles SSE reconnection
|
|
71
|
+
automatically. Call this from asyncio.run() or as a task.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
async for message in self._client.listen():
|
|
75
|
+
await self._handle(message)
|
|
76
|
+
finally:
|
|
77
|
+
await self._agent.close()
|
|
78
|
+
await self._client.close()
|
|
79
|
+
|
|
80
|
+
async def _handle(self, message: InboundMessage) -> None:
|
|
81
|
+
"""Route an inbound SMS to commands or the agent."""
|
|
82
|
+
text = message.text.strip()
|
|
83
|
+
|
|
84
|
+
if self._commands.is_command(text):
|
|
85
|
+
response = self._commands.handle(text)
|
|
86
|
+
await self._send(response)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
await self._handle_conversation(message.id, text)
|
|
90
|
+
|
|
91
|
+
async def _handle_conversation(self, message_id: str, text: str) -> None:
|
|
92
|
+
"""Build prompt, call agent, record history, send reply."""
|
|
93
|
+
logger.info("Inbound SMS [%s]: %s", message_id, text[:50])
|
|
94
|
+
|
|
95
|
+
prompt = self._build_prompt(text)
|
|
96
|
+
|
|
97
|
+
# Call the agent
|
|
98
|
+
try:
|
|
99
|
+
response = await self._agent.ask(prompt)
|
|
100
|
+
except TimeoutError:
|
|
101
|
+
logger.error("Agent timed out on message %s", message_id)
|
|
102
|
+
await self._send("Sorry, I took too long to respond. Try again?")
|
|
103
|
+
return
|
|
104
|
+
except Exception:
|
|
105
|
+
logger.exception("Agent error on message %s", message_id)
|
|
106
|
+
await self._send("Sorry, something went wrong. Try again?")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
if not response:
|
|
110
|
+
logger.warning("Agent returned empty response for %s", message_id)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# Record both sides only after a successful response.
|
|
114
|
+
# This keeps history balanced — no orphaned user messages.
|
|
115
|
+
self._conversation.add_user_message(text)
|
|
116
|
+
self._conversation.add_assistant_message(response)
|
|
117
|
+
self._commands.increment_message_count()
|
|
118
|
+
|
|
119
|
+
# Format for SMS and send
|
|
120
|
+
formatted = format_for_sms(response)
|
|
121
|
+
if len(formatted) > MAX_SMS_LENGTH:
|
|
122
|
+
formatted = formatted[: MAX_SMS_LENGTH - 3] + "..."
|
|
123
|
+
logger.warning("Response truncated to %d chars", MAX_SMS_LENGTH)
|
|
124
|
+
|
|
125
|
+
await self._send(formatted)
|
|
126
|
+
logger.info("Reply sent for %s", message_id)
|
|
127
|
+
|
|
128
|
+
def _build_prompt(self, text: str) -> str:
|
|
129
|
+
"""Assemble the complete prompt: system + history + new message.
|
|
130
|
+
|
|
131
|
+
The bridge owns prompt construction so that system instructions
|
|
132
|
+
and conversation context are always included, regardless of
|
|
133
|
+
which agent is being used or how many messages have been sent.
|
|
134
|
+
"""
|
|
135
|
+
parts: list[str] = []
|
|
136
|
+
|
|
137
|
+
# System prompt — always included so the agent stays in character
|
|
138
|
+
system = self._agent.system_prompt
|
|
139
|
+
if system:
|
|
140
|
+
parts.append(f"[System]\n{system}")
|
|
141
|
+
|
|
142
|
+
# Conversation history — provides multi-turn context
|
|
143
|
+
context = self._conversation.format_as_context()
|
|
144
|
+
if context:
|
|
145
|
+
parts.append(context)
|
|
146
|
+
|
|
147
|
+
# The new message
|
|
148
|
+
parts.append(f"[New message]\nUser: {text}")
|
|
149
|
+
|
|
150
|
+
return "\n\n".join(parts)
|
|
151
|
+
|
|
152
|
+
async def _send(self, text: str) -> None:
|
|
153
|
+
"""Send a reply, logging but not raising on failure."""
|
|
154
|
+
try:
|
|
155
|
+
await self._client.send(text)
|
|
156
|
+
except Exception:
|
|
157
|
+
logger.exception("Failed to send reply")
|
aitextaroo/cli.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""CLI entry point for the AI Text-a-roo bridge.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
aitextaroo-bridge --api-key YOUR_KEY
|
|
5
|
+
aitextaroo-bridge --api-key YOUR_KEY --agent claude
|
|
6
|
+
AITEXTAROO_API_KEY=your_key aitextaroo-bridge
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from aitextaroo.agents import BUILTIN_AGENTS, create_agent, detect_agents
|
|
18
|
+
from aitextaroo.bridge import Bridge
|
|
19
|
+
from aitextaroo.client import TextarooClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None:
|
|
23
|
+
"""Parse arguments, wire dependencies, run the bridge."""
|
|
24
|
+
args = _parse_args()
|
|
25
|
+
_configure_logging(args.verbose)
|
|
26
|
+
|
|
27
|
+
api_key = args.api_key
|
|
28
|
+
if not api_key:
|
|
29
|
+
_exit_error("API key required. Use --api-key or set AITEXTAROO_API_KEY.")
|
|
30
|
+
|
|
31
|
+
agent_name = _resolve_agent(args.agent)
|
|
32
|
+
|
|
33
|
+
# Wire dependencies
|
|
34
|
+
client = TextarooClient(api_key=api_key, base_url=args.base_url)
|
|
35
|
+
agent = create_agent(agent_name)
|
|
36
|
+
bridge = Bridge(client=client, agent=agent)
|
|
37
|
+
|
|
38
|
+
print(f"Starting bridge with {agent.name}...")
|
|
39
|
+
print("Listening for SMS messages. Ctrl+C to stop.")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
asyncio.run(bridge.run())
|
|
43
|
+
except KeyboardInterrupt:
|
|
44
|
+
print("\nBridge stopped.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_args() -> argparse.Namespace:
|
|
48
|
+
"""Build and parse CLI arguments."""
|
|
49
|
+
parser = argparse.ArgumentParser(
|
|
50
|
+
prog="aitextaroo-bridge",
|
|
51
|
+
description="Bridge SMS messages to your AI agent. Your agent gets a phone number.",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--api-key",
|
|
55
|
+
default=os.environ.get("AITEXTAROO_API_KEY", ""),
|
|
56
|
+
help="AI Text-a-roo API key (or set AITEXTAROO_API_KEY env var)",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--agent",
|
|
60
|
+
choices=["auto", *BUILTIN_AGENTS.keys()],
|
|
61
|
+
default="auto",
|
|
62
|
+
help="Which agent to use (default: auto-detect)",
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--base-url",
|
|
66
|
+
default=os.environ.get("AITEXTAROO_BASE_URL", "https://api.aitextaroo.com"),
|
|
67
|
+
help="API base URL (default: https://api.aitextaroo.com)",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--verbose", "-v",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="Enable debug logging",
|
|
73
|
+
)
|
|
74
|
+
return parser.parse_args()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _configure_logging(verbose: bool) -> None:
|
|
78
|
+
"""Set up logging for the bridge process."""
|
|
79
|
+
logging.basicConfig(
|
|
80
|
+
level=logging.DEBUG if verbose else logging.INFO,
|
|
81
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
82
|
+
datefmt="%H:%M:%S",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve_agent(choice: str) -> str:
|
|
87
|
+
"""Resolve 'auto' to a detected agent name, or validate the explicit choice."""
|
|
88
|
+
if choice != "auto":
|
|
89
|
+
return choice
|
|
90
|
+
|
|
91
|
+
agents = detect_agents()
|
|
92
|
+
if not agents:
|
|
93
|
+
_exit_error(
|
|
94
|
+
"No supported AI agent found on PATH. Install one of:\n"
|
|
95
|
+
+ "".join(f" - {name}\n" for name in BUILTIN_AGENTS)
|
|
96
|
+
)
|
|
97
|
+
print(f"Auto-detected agent: {agents[0]}")
|
|
98
|
+
return agents[0]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _exit_error(message: str) -> None:
|
|
102
|
+
"""Print error to stderr and exit."""
|
|
103
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|