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 +4 -0
- thwip/__main__.py +6 -0
- thwip/agents/__init__.py +98 -0
- thwip/agents/base.py +343 -0
- thwip/agents/claude_agent.py +346 -0
- thwip/agents/deepseek_agent.py +217 -0
- thwip/agents/google_agent.py +314 -0
- thwip/agents/groq_agent.py +181 -0
- thwip/agents/ollama_agent.py +180 -0
- thwip/agents/openai_agent.py +338 -0
- thwip/agents/openrouter_agent.py +192 -0
- thwip/cli.py +593 -0
- thwip/config.py +363 -0
- thwip/detector.py +259 -0
- thwip/limits.py +84 -0
- thwip/session.py +175 -0
- thwip/shortcuts.py +75 -0
- thwip/theme.py +380 -0
- thwip/tools/__init__.py +184 -0
- thwip/tools/code_runner.py +62 -0
- thwip/tools/file_editor.py +92 -0
- thwip/tools/git_ops.py +44 -0
- thwip/tools/terminal.py +65 -0
- thwip/utils.py +110 -0
- thwip_cli-1.0.0.dist-info/METADATA +157 -0
- thwip_cli-1.0.0.dist-info/RECORD +28 -0
- thwip_cli-1.0.0.dist-info/WHEEL +4 -0
- thwip_cli-1.0.0.dist-info/entry_points.txt +2 -0
thwip/__init__.py
ADDED
thwip/__main__.py
ADDED
thwip/agents/__init__.py
ADDED
|
@@ -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}>"
|