thwip-cli 1.0.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.
thwip/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """thwip: Universal Coding Agent Multiplexer."""
2
+
3
+ __version__ = "1.0.0"
4
+ __app_name__ = "thwip"
thwip/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m thwip`."""
2
+
3
+ from thwip.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,98 @@
1
+ """
2
+ Agent registry and discovery for thwip.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Type
8
+
9
+ from thwip.agents.base import BaseAgent, Capability, LimitStatus, ModelInfo
10
+ from thwip.agents.claude_agent import ClaudeAgent
11
+ from thwip.agents.google_agent import GoogleAgent
12
+ from thwip.agents.openai_agent import OpenAIAgent
13
+ from thwip.agents.deepseek_agent import DeepSeekAgent
14
+ from thwip.agents.groq_agent import GroqAgent
15
+ from thwip.agents.ollama_agent import OllamaAgent
16
+ from thwip.agents.openrouter_agent import OpenRouterAgent
17
+ from thwip.config import ThwipConfig
18
+
19
+ # List of all available agent classes in order
20
+ ALL_AGENT_CLASSES: list[Type[BaseAgent]] = [
21
+ ClaudeAgent,
22
+ GoogleAgent,
23
+ OpenAIAgent,
24
+ DeepSeekAgent,
25
+ GroqAgent,
26
+ OllamaAgent,
27
+ OpenRouterAgent,
28
+ ]
29
+
30
+
31
+ class AgentRegistry:
32
+ """Manages instantiated agents and dynamic lookup."""
33
+
34
+ def __init__(self, config: ThwipConfig | None = None) -> None:
35
+ self.config = config or ThwipConfig.load()
36
+ self._agents: dict[str, BaseAgent] = {}
37
+ self._initialize_agents()
38
+
39
+ def _initialize_agents(self) -> None:
40
+ """Instantiate all agent adapters with configured keys."""
41
+ for agent_cls in ALL_AGENT_CLASSES:
42
+ name = agent_cls.name
43
+ key = self.config.get_key(name)
44
+ if name == "google" and not key:
45
+ key = self.config.get_key("gemini")
46
+ if name == "claude" and not key:
47
+ key = self.config.get_key("anthropic")
48
+ if name == "openai" and not key:
49
+ key = self.config.get_key("codex")
50
+
51
+ if agent_cls is OllamaAgent:
52
+ inst = OllamaAgent(host=self.config.ollama_host)
53
+ else:
54
+ inst = agent_cls(api_key=key)
55
+
56
+ self._agents[name] = inst
57
+
58
+ def get_agent(self, name: str) -> BaseAgent | None:
59
+ """Get agent by name or alias."""
60
+ alias_map = {
61
+ "claude": "claude",
62
+ "claude-code": "claude",
63
+ "anthropic": "claude",
64
+ "google": "google",
65
+ "gemini": "google",
66
+ "antigravity": "google",
67
+ "agy": "google",
68
+ "openai": "openai",
69
+ "codex": "openai",
70
+ "gpt": "openai",
71
+ "chatgpt": "openai",
72
+ "deepseek": "deepseek",
73
+ "groq": "groq",
74
+ "ollama": "ollama",
75
+ "local": "ollama",
76
+ "openrouter": "openrouter",
77
+ }
78
+ normalized = alias_map.get(name.lower().strip(), name.lower().strip())
79
+ return self._agents.get(normalized)
80
+
81
+ def list_agents(self) -> list[BaseAgent]:
82
+ """Return all instantiated agents."""
83
+ return list(self._agents.values())
84
+
85
+ def get_ready_agents(self) -> list[BaseAgent]:
86
+ """Return all agents that are installed and configured."""
87
+ return [
88
+ a for a in self._agents.values()
89
+ if a.is_installed() and a.is_configured()
90
+ ]
91
+
92
+ def find_fallback_agent(self, current_agent_name: str) -> BaseAgent | None:
93
+ """Find the next best ready agent for fallback."""
94
+ ready = self.get_ready_agents()
95
+ for a in ready:
96
+ if a.name != current_agent_name:
97
+ return a
98
+ return None
thwip/agents/base.py ADDED
@@ -0,0 +1,343 @@
1
+ """
2
+ Base agent adapter: abstract interface that all agent adapters implement.
3
+
4
+ Defines capabilities, events, limit status, and the contract every
5
+ agent (Claude, Antigravity, Codex, etc.) must follow.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from enum import Enum, auto
14
+ from typing import Any, AsyncIterator
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Capabilities: what an agent can do
19
+ # ---------------------------------------------------------------------------
20
+
21
+ class Capability(str, Enum):
22
+ """Things a coding agent can do."""
23
+ CHAT = "chat"
24
+ FILE_EDIT = "file_edit"
25
+ FILE_READ = "file_read"
26
+ CODE_RUN = "code_run"
27
+ TERMINAL = "terminal"
28
+ GIT = "git"
29
+ BROWSER = "browser"
30
+ IMAGE_GEN = "image"
31
+ SEARCH = "search"
32
+
33
+ @property
34
+ def display_name(self) -> str:
35
+ names = {
36
+ "chat": "Chat",
37
+ "file_edit": "File Edit",
38
+ "file_read": "File Read",
39
+ "code_run": "Code Run",
40
+ "terminal": "Terminal",
41
+ "git": "Git",
42
+ "browser": "Browser",
43
+ "image": "Image Gen",
44
+ "search": "Web Search",
45
+ }
46
+ return names.get(self.value, self.value)
47
+
48
+ @property
49
+ def icon(self) -> str:
50
+ return self.display_name
51
+
52
+
53
+ # All possible capabilities for comparison
54
+ ALL_CAPABILITIES = set(Capability)
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Limit / Subscription Status
59
+ # ---------------------------------------------------------------------------
60
+
61
+ class LimitStatus(str, Enum):
62
+ OK = "ok" # All good, can make requests
63
+ RATE_LIMITED = "rate_limited" # Transient: retry after delay
64
+ QUOTA_EXHAUSTED = "quota_exhausted" # Hard limit: switch agent
65
+ INVALID_KEY = "invalid_key" # Bad API key
66
+ NO_KEY = "no_key" # No API key configured
67
+ NO_SUBSCRIPTION = "no_subscription" # Key exists but no active plan
68
+ UNKNOWN = "unknown" # Cannot determine
69
+
70
+
71
+ class SubscriptionTier(str, Enum):
72
+ FREE = "Free"
73
+ PRO = "Pro"
74
+ TEAM = "Team"
75
+ ENTERPRISE = "Enterprise"
76
+ UNLIMITED = "Unlimited" # e.g., Ollama local
77
+ UNKNOWN = "Unknown"
78
+
79
+
80
+ @dataclass
81
+ class SubscriptionInfo:
82
+ """Subscription details for an agent/provider."""
83
+ tier: SubscriptionTier = SubscriptionTier.UNKNOWN
84
+ is_active: bool = False
85
+ usage_percent: float | None = None # 0-100, None if unknown
86
+ requests_remaining: int | None = None
87
+ tokens_remaining: int | None = None
88
+ reset_at: datetime | None = None
89
+ message: str = ""
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Agent Events (streaming response protocol)
94
+ # ---------------------------------------------------------------------------
95
+
96
+ @dataclass
97
+ class TextDelta:
98
+ """A chunk of streamed text."""
99
+ content: str
100
+
101
+
102
+ @dataclass
103
+ class ThinkingDelta:
104
+ """A chunk of reasoning/thinking content (for reasoning models)."""
105
+ content: str
106
+
107
+
108
+ @dataclass
109
+ class ToolUseStart:
110
+ """Agent wants to use a tool."""
111
+ tool_id: str
112
+ tool_name: str
113
+ args: dict[str, Any]
114
+
115
+
116
+ @dataclass
117
+ class ToolResult:
118
+ """Result from a tool execution."""
119
+ tool_id: str
120
+ output: str
121
+ error: str | None = None
122
+
123
+
124
+ @dataclass
125
+ class TokenUsage:
126
+ """Token usage for a response."""
127
+ input_tokens: int = 0
128
+ output_tokens: int = 0
129
+ cache_read_tokens: int = 0
130
+ cache_write_tokens: int = 0
131
+
132
+
133
+ @dataclass
134
+ class LimitHit:
135
+ """Agent hit a rate limit or quota."""
136
+ error_type: LimitStatus
137
+ retry_after: float | None = None # Seconds to wait (for rate limits)
138
+ message: str = ""
139
+
140
+
141
+ @dataclass
142
+ class AgentDone:
143
+ """Agent finished responding."""
144
+ usage: TokenUsage = field(default_factory=TokenUsage)
145
+ stop_reason: str = "end_turn"
146
+
147
+
148
+ # Union of all possible events
149
+ AgentEvent = TextDelta | ThinkingDelta | ToolUseStart | ToolResult | TokenUsage | LimitHit | AgentDone
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Model Info
154
+ # ---------------------------------------------------------------------------
155
+
156
+ @dataclass
157
+ class ModelInfo:
158
+ """Information about a specific model."""
159
+ id: str # "claude-sonnet-4"
160
+ name: str # "Claude Sonnet 4"
161
+ context_window: int = 0 # Max tokens
162
+ max_output: int = 0 # Max output tokens
163
+ supports_tools: bool = True
164
+ supports_streaming: bool = True
165
+ supports_vision: bool = False
166
+ supports_thinking: bool = False # Extended thinking / reasoning
167
+ is_default: bool = False
168
+ pricing_input: float = 0.0 # $ per 1M input tokens
169
+ pricing_output: float = 0.0 # $ per 1M output tokens
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Base Agent Adapter
174
+ # ---------------------------------------------------------------------------
175
+
176
+ class BaseAgent(ABC):
177
+ """
178
+ Abstract base class for all agent adapters.
179
+
180
+ Every coding agent (Claude Code, Antigravity, Codex, Aider, etc.)
181
+ implements this interface. thwip uses it to:
182
+ - Detect if the agent is installed and configured
183
+ - Know what the agent can do (capabilities)
184
+ - Send chat messages and receive streaming responses
185
+ - Check rate limits and subscription status
186
+ """
187
+
188
+ # --- Identity ---
189
+ name: str = "" # "claude" (short identifier)
190
+ display_name: str = "" # "Claude Code"
191
+ company: str = "" # "Anthropic"
192
+ description: str = "" # Brief description
193
+ website: str = "" # "https://claude.ai"
194
+
195
+ # --- Capabilities ---
196
+ capabilities: set[Capability] = set()
197
+ available_models: list[ModelInfo] = []
198
+
199
+ def __init_subclass__(cls, **kwargs: Any) -> None:
200
+ super().__init_subclass__(**kwargs)
201
+
202
+ # --- Detection & Configuration ---
203
+
204
+ @abstractmethod
205
+ def is_installed(self) -> bool:
206
+ """Check if this agent's CLI/app is installed on the system."""
207
+ ...
208
+
209
+ @abstractmethod
210
+ def is_configured(self) -> bool:
211
+ """Check if this agent has a valid API key / credentials."""
212
+ ...
213
+
214
+ @abstractmethod
215
+ def get_install_info(self) -> dict[str, str]:
216
+ """
217
+ Return installation details.
218
+ {
219
+ "method": "npm global", # How it's installed
220
+ "path": "/usr/local/bin/claude", # Where the binary is
221
+ "version": "1.0.20", # Version if detectable
222
+ }
223
+ """
224
+ ...
225
+
226
+ @abstractmethod
227
+ def get_subscription_info(self) -> SubscriptionInfo:
228
+ """
229
+ Check the user's subscription/plan status for this agent.
230
+ Tries to determine tier, usage, and remaining quota.
231
+ """
232
+ ...
233
+
234
+ # --- Chat ---
235
+
236
+ @abstractmethod
237
+ async def chat(
238
+ self,
239
+ messages: list[dict[str, Any]],
240
+ model: str | None = None,
241
+ system_prompt: str | None = None,
242
+ tools: list[dict[str, Any]] | None = None,
243
+ stream: bool = True,
244
+ ) -> AsyncIterator[AgentEvent]:
245
+ """
246
+ Send messages and receive streaming agent events.
247
+
248
+ Args:
249
+ messages: Conversation history in portable format
250
+ [{role: "user"|"assistant", content: "..."}]
251
+ model: Model to use (None = default)
252
+ system_prompt: System instruction
253
+ tools: Tool definitions for function calling
254
+ stream: Whether to stream the response
255
+
256
+ Yields:
257
+ AgentEvent objects (TextDelta, ToolUseStart, etc.)
258
+ """
259
+ ...
260
+
261
+ # --- Limits ---
262
+
263
+ @abstractmethod
264
+ def check_limits(self) -> LimitStatus:
265
+ """Check current rate limit / quota status."""
266
+ ...
267
+
268
+ # --- Helpers ---
269
+
270
+ def get_default_model(self) -> str:
271
+ """Return the default model ID."""
272
+ for m in self.available_models:
273
+ if m.is_default:
274
+ return m.id
275
+ if self.available_models:
276
+ return self.available_models[0].id
277
+ return ""
278
+
279
+ def get_model_info(self, model_id: str) -> ModelInfo | None:
280
+ """Get info for a specific model."""
281
+ for m in self.available_models:
282
+ if m.id == model_id:
283
+ return m
284
+ return None
285
+
286
+ def has_capability(self, cap: Capability) -> bool:
287
+ """Check if this agent supports a capability."""
288
+ return cap in self.capabilities
289
+
290
+ def get_missing_capabilities(self, compared_to: set[Capability]) -> list[str]:
291
+ """Get capabilities that this agent lacks compared to another set."""
292
+ missing = compared_to - self.capabilities
293
+ return [c.display_name for c in sorted(missing, key=lambda x: x.value)]
294
+
295
+ def get_capability_names(self) -> list[str]:
296
+ """Get display names of all capabilities."""
297
+ return [c.display_name for c in sorted(self.capabilities, key=lambda x: x.value)]
298
+
299
+ def get_status_display(self) -> tuple[str, str]:
300
+ """Return (status_text, style) for table display."""
301
+ if not self.is_installed():
302
+ return ("Not Installed", "dim")
303
+ if not self.is_configured():
304
+ return ("No Key", "status.no_key")
305
+
306
+ limit = self.check_limits()
307
+ if limit == LimitStatus.OK:
308
+ return ("Ready", "status.ready")
309
+ elif limit == LimitStatus.RATE_LIMITED:
310
+ return ("Rate Limited", "status.limited")
311
+ elif limit == LimitStatus.QUOTA_EXHAUSTED:
312
+ return ("Quota Exhausted", "error")
313
+ elif limit == LimitStatus.NO_SUBSCRIPTION:
314
+ return ("No Subscription", "status.no_key")
315
+ elif limit == LimitStatus.INVALID_KEY:
316
+ return ("Invalid Key", "error")
317
+ else:
318
+ return ("Unknown", "dim")
319
+
320
+ def to_table_row(self) -> dict:
321
+ """Convert to a dict suitable for render_agents_table()."""
322
+ status_text, status_style = self.get_status_display()
323
+ sub = self.get_subscription_info()
324
+
325
+ models_str = ", ".join(m.id for m in self.available_models[:3])
326
+ if len(self.available_models) > 3:
327
+ models_str += f" +{len(self.available_models) - 3}"
328
+
329
+ return {
330
+ "name": self.display_name,
331
+ "company": self.company,
332
+ "status": status_text,
333
+ "status_style": status_style,
334
+ "models": models_str,
335
+ "capabilities": [c.display_name for c in sorted(self.capabilities, key=lambda x: x.value)],
336
+ "missing_capabilities": [],
337
+ "subscription": sub.tier.value if sub.tier != SubscriptionTier.UNKNOWN else (
338
+ "Active" if sub.is_active else "Unknown"
339
+ ),
340
+ }
341
+
342
+ def __repr__(self) -> str:
343
+ return f"<{self.__class__.__name__} name={self.name!r} company={self.company!r}>"