thwip-cli 1.0.2__tar.gz → 1.0.4__tar.gz
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_cli-1.0.2 → thwip_cli-1.0.4}/PKG-INFO +1 -1
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/pyproject.toml +1 -1
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/__init__.py +1 -1
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/base.py +25 -1
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/claude_agent.py +68 -16
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/google_agent.py +70 -11
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/openai_agent.py +97 -15
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/cli.py +156 -33
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/theme.py +4 -2
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/uv.lock +1 -1
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/.github/workflows/publish.yml +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/.gitignore +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/README.md +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/install.sh +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/tests/test_agents.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/tests/test_detector.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/tests/test_session.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/tests/test_tools.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/__main__.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/__init__.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/deepseek_agent.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/groq_agent.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/ollama_agent.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/openrouter_agent.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/config.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/detector.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/limits.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/session.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/shortcuts.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/tools/__init__.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/tools/code_runner.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/tools/file_editor.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/tools/git_ops.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/tools/terminal.py +0 -0
- {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thwip-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.4
|
|
4
4
|
Summary: Universal coding agent multiplexer: detect, switch, and route between AI coding agents seamlessly
|
|
5
5
|
Project-URL: Homepage, https://github.com/tanmayhutt/thwip-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/tanmayhutt/thwip-cli
|
|
@@ -158,6 +158,8 @@ class ModelInfo:
|
|
|
158
158
|
"""Information about a specific model."""
|
|
159
159
|
id: str # "claude-sonnet-4"
|
|
160
160
|
name: str # "Claude Sonnet 4"
|
|
161
|
+
tier: str = "balanced" # "flagship" (high), "balanced" (mid), "fast" (low-cost)
|
|
162
|
+
description: str = "" # e.g. "Flagship reasoning with 1M context"
|
|
161
163
|
context_window: int = 0 # Max tokens
|
|
162
164
|
max_output: int = 0 # Max output tokens
|
|
163
165
|
supports_tools: bool = True
|
|
@@ -208,9 +210,22 @@ class BaseAgent(ABC):
|
|
|
208
210
|
|
|
209
211
|
@abstractmethod
|
|
210
212
|
def is_configured(self) -> bool:
|
|
211
|
-
"""Check if this agent has a valid API key / credentials."""
|
|
213
|
+
"""Check if this agent has a valid API key or OAuth/subscription credentials."""
|
|
212
214
|
...
|
|
213
215
|
|
|
216
|
+
def _has_cli_auth(self) -> dict[str, str] | None:
|
|
217
|
+
"""
|
|
218
|
+
Check if the agent's CLI tool has its own OAuth/subscription auth.
|
|
219
|
+
Returns dict with 'method' and 'account' keys if found, else None.
|
|
220
|
+
Subclasses should override this.
|
|
221
|
+
"""
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def auth_method(self) -> str:
|
|
226
|
+
"""Return the auth method in use: 'api_key', 'oauth', 'subscription', or 'none'."""
|
|
227
|
+
return "none"
|
|
228
|
+
|
|
214
229
|
@abstractmethod
|
|
215
230
|
def get_install_info(self) -> dict[str, str]:
|
|
216
231
|
"""
|
|
@@ -303,6 +318,15 @@ class BaseAgent(ABC):
|
|
|
303
318
|
if not self.is_configured():
|
|
304
319
|
return ("Installed (No Key)", "status.limited")
|
|
305
320
|
|
|
321
|
+
# Show auth method in status for non-API-key auth
|
|
322
|
+
auth = self.auth_method
|
|
323
|
+
if auth in ("oauth", "subscription"):
|
|
324
|
+
cli_auth = self._has_cli_auth()
|
|
325
|
+
account = cli_auth.get("account", "") if cli_auth else ""
|
|
326
|
+
if account:
|
|
327
|
+
return (f"Ready ({account})", "status.ready")
|
|
328
|
+
return ("Ready (CLI Auth)", "status.ready")
|
|
329
|
+
|
|
306
330
|
limit = self.check_limits()
|
|
307
331
|
if limit == LimitStatus.OK:
|
|
308
332
|
return ("Ready", "status.ready")
|
|
@@ -59,6 +59,8 @@ class ClaudeAgent(BaseAgent):
|
|
|
59
59
|
ModelInfo(
|
|
60
60
|
id="claude-sonnet-4",
|
|
61
61
|
name="Claude Sonnet 4",
|
|
62
|
+
tier="flagship",
|
|
63
|
+
description="Flagship coding intelligence with hybrid thinking and tool precision",
|
|
62
64
|
context_window=200_000,
|
|
63
65
|
max_output=16_384,
|
|
64
66
|
supports_tools=True,
|
|
@@ -72,6 +74,8 @@ class ClaudeAgent(BaseAgent):
|
|
|
72
74
|
ModelInfo(
|
|
73
75
|
id="claude-opus-4",
|
|
74
76
|
name="Claude Opus 4",
|
|
77
|
+
tier="flagship",
|
|
78
|
+
description="Ultra high capability reasoning for complex systems engineering",
|
|
75
79
|
context_window=200_000,
|
|
76
80
|
max_output=32_000,
|
|
77
81
|
supports_tools=True,
|
|
@@ -84,6 +88,8 @@ class ClaudeAgent(BaseAgent):
|
|
|
84
88
|
ModelInfo(
|
|
85
89
|
id="claude-haiku-3.5",
|
|
86
90
|
name="Claude Haiku 3.5",
|
|
91
|
+
tier="fast",
|
|
92
|
+
description="Fastest lightweight Claude model for rapid iteration",
|
|
87
93
|
context_window=200_000,
|
|
88
94
|
max_output=8_192,
|
|
89
95
|
supports_tools=True,
|
|
@@ -149,7 +155,11 @@ class ClaudeAgent(BaseAgent):
|
|
|
149
155
|
# --- Detection ---
|
|
150
156
|
|
|
151
157
|
def is_installed(self) -> bool:
|
|
152
|
-
"""Check if Claude Code CLI or Claude desktop app is installed
|
|
158
|
+
"""Check if Claude Code CLI or Claude desktop app is installed.
|
|
159
|
+
|
|
160
|
+
The ~/.claude directory alone is not sufficient since other IDEs
|
|
161
|
+
(like Antigravity) may create it.
|
|
162
|
+
"""
|
|
153
163
|
if self.is_configured():
|
|
154
164
|
return True
|
|
155
165
|
if shutil.which("claude") is not None:
|
|
@@ -157,13 +167,50 @@ class ClaudeAgent(BaseAgent):
|
|
|
157
167
|
app_paths = [
|
|
158
168
|
Path("/Applications/Claude.app"),
|
|
159
169
|
Path.home() / "Applications" / "Claude.app",
|
|
160
|
-
Path.home() / ".claude",
|
|
161
170
|
]
|
|
162
171
|
return any(p.exists() for p in app_paths)
|
|
163
172
|
|
|
173
|
+
def _has_cli_auth(self) -> dict[str, str] | None:
|
|
174
|
+
"""Check for Claude Code subscription auth.
|
|
175
|
+
|
|
176
|
+
Requires the `claude` CLI binary or Claude.app to be present.
|
|
177
|
+
The ~/.claude directory alone is not sufficient since other IDEs
|
|
178
|
+
(like Antigravity) may create it.
|
|
179
|
+
"""
|
|
180
|
+
has_cli = shutil.which("claude") is not None
|
|
181
|
+
has_app = Path("/Applications/Claude.app").exists() or (Path.home() / "Applications" / "Claude.app").exists()
|
|
182
|
+
|
|
183
|
+
if not has_cli and not has_app:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
claude_dir = Path.home() / ".claude"
|
|
187
|
+
if not claude_dir.is_dir():
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
# Claude Code stores session data when authenticated
|
|
191
|
+
auth_indicators = [
|
|
192
|
+
claude_dir / "projects",
|
|
193
|
+
claude_dir / "backups",
|
|
194
|
+
claude_dir / "debug",
|
|
195
|
+
]
|
|
196
|
+
has_sessions = any(p.is_dir() and any(p.iterdir()) for p in auth_indicators if p.exists())
|
|
197
|
+
|
|
198
|
+
if has_sessions:
|
|
199
|
+
return {"method": "subscription", "account": "Anthropic Max"}
|
|
200
|
+
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def auth_method(self) -> str:
|
|
205
|
+
if self._get_api_key():
|
|
206
|
+
return "api_key"
|
|
207
|
+
if self._has_cli_auth():
|
|
208
|
+
return "subscription"
|
|
209
|
+
return "none"
|
|
210
|
+
|
|
164
211
|
def is_configured(self) -> bool:
|
|
165
|
-
"""Check if we have a valid
|
|
166
|
-
return bool(self._get_api_key())
|
|
212
|
+
"""Check if we have a valid API key or subscription auth."""
|
|
213
|
+
return bool(self._get_api_key()) or bool(self._has_cli_auth())
|
|
167
214
|
|
|
168
215
|
def get_install_info(self) -> dict[str, str]:
|
|
169
216
|
"""Get Claude Code installation details."""
|
|
@@ -192,7 +239,6 @@ class ClaudeAgent(BaseAgent):
|
|
|
192
239
|
app_paths = [
|
|
193
240
|
Path("/Applications/Claude.app"),
|
|
194
241
|
Path.home() / "Applications" / "Claude.app",
|
|
195
|
-
Path.home() / ".claude",
|
|
196
242
|
]
|
|
197
243
|
for app in app_paths:
|
|
198
244
|
if app.exists():
|
|
@@ -207,29 +253,35 @@ class ClaudeAgent(BaseAgent):
|
|
|
207
253
|
def get_subscription_info(self) -> SubscriptionInfo:
|
|
208
254
|
"""
|
|
209
255
|
Determine subscription status.
|
|
210
|
-
|
|
211
|
-
the key prefix and check basic validity.
|
|
256
|
+
Checks API key first, then CLI subscription auth.
|
|
212
257
|
"""
|
|
213
258
|
key = self._get_api_key()
|
|
214
|
-
if
|
|
259
|
+
if key:
|
|
260
|
+
if key.startswith("sk-ant-"):
|
|
261
|
+
return SubscriptionInfo(
|
|
262
|
+
tier=SubscriptionTier.PRO,
|
|
263
|
+
is_active=True,
|
|
264
|
+
message="Anthropic API key detected",
|
|
265
|
+
)
|
|
215
266
|
return SubscriptionInfo(
|
|
216
267
|
tier=SubscriptionTier.UNKNOWN,
|
|
217
|
-
is_active=
|
|
218
|
-
message="
|
|
268
|
+
is_active=True,
|
|
269
|
+
message="API key detected",
|
|
219
270
|
)
|
|
220
271
|
|
|
221
|
-
|
|
222
|
-
if
|
|
272
|
+
cli_auth = self._has_cli_auth()
|
|
273
|
+
if cli_auth:
|
|
274
|
+
account = cli_auth.get("account", "Active")
|
|
223
275
|
return SubscriptionInfo(
|
|
224
|
-
tier=SubscriptionTier.PRO,
|
|
276
|
+
tier=SubscriptionTier.PRO,
|
|
225
277
|
is_active=True,
|
|
226
|
-
message="
|
|
278
|
+
message=f"Claude Code ({account})",
|
|
227
279
|
)
|
|
228
280
|
|
|
229
281
|
return SubscriptionInfo(
|
|
230
282
|
tier=SubscriptionTier.UNKNOWN,
|
|
231
|
-
is_active=
|
|
232
|
-
message="
|
|
283
|
+
is_active=False,
|
|
284
|
+
message="No credentials found",
|
|
233
285
|
)
|
|
234
286
|
|
|
235
287
|
# --- Chat ---
|
|
@@ -59,6 +59,8 @@ class GoogleAgent(BaseAgent):
|
|
|
59
59
|
ModelInfo(
|
|
60
60
|
id="gemini-2.5-pro",
|
|
61
61
|
name="Gemini 2.5 Pro",
|
|
62
|
+
tier="flagship",
|
|
63
|
+
description="Flagship multimodal model with 1M context and advanced reasoning",
|
|
62
64
|
context_window=1_048_576,
|
|
63
65
|
max_output=65_536,
|
|
64
66
|
supports_tools=True,
|
|
@@ -72,6 +74,8 @@ class GoogleAgent(BaseAgent):
|
|
|
72
74
|
ModelInfo(
|
|
73
75
|
id="gemini-2.5-flash",
|
|
74
76
|
name="Gemini 2.5 Flash",
|
|
77
|
+
tier="balanced",
|
|
78
|
+
description="High-speed balanced model optimized for daily coding workflows",
|
|
75
79
|
context_window=1_048_576,
|
|
76
80
|
max_output=65_536,
|
|
77
81
|
supports_tools=True,
|
|
@@ -81,6 +85,20 @@ class GoogleAgent(BaseAgent):
|
|
|
81
85
|
pricing_input=0.15,
|
|
82
86
|
pricing_output=0.60,
|
|
83
87
|
),
|
|
88
|
+
ModelInfo(
|
|
89
|
+
id="gemini-2.5-flash-lite",
|
|
90
|
+
name="Gemini 2.5 Flash Lite",
|
|
91
|
+
tier="fast",
|
|
92
|
+
description="Ultra lightweight and cost-effective model for rapid tasks",
|
|
93
|
+
context_window=1_048_576,
|
|
94
|
+
max_output=65_536,
|
|
95
|
+
supports_tools=True,
|
|
96
|
+
supports_streaming=True,
|
|
97
|
+
supports_vision=False,
|
|
98
|
+
supports_thinking=False,
|
|
99
|
+
pricing_input=0.075,
|
|
100
|
+
pricing_output=0.30,
|
|
101
|
+
),
|
|
84
102
|
]
|
|
85
103
|
|
|
86
104
|
def __init__(self, api_key: str | None = None) -> None:
|
|
@@ -149,8 +167,31 @@ class GoogleAgent(BaseAgent):
|
|
|
149
167
|
]
|
|
150
168
|
return any(p.exists() for p in app_paths)
|
|
151
169
|
|
|
170
|
+
def _has_cli_auth(self) -> dict[str, str] | None:
|
|
171
|
+
"""Check for Google OAuth via Gemini CLI's google_accounts.json."""
|
|
172
|
+
acc_file = Path.home() / ".gemini" / "google_accounts.json"
|
|
173
|
+
if acc_file.is_file():
|
|
174
|
+
try:
|
|
175
|
+
data = json.loads(acc_file.read_text())
|
|
176
|
+
account = data.get("active") or ""
|
|
177
|
+
if not account and data.get("old"):
|
|
178
|
+
account = data["old"][0]
|
|
179
|
+
if account:
|
|
180
|
+
return {"method": "google_oauth", "account": account}
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def auth_method(self) -> str:
|
|
187
|
+
if self._get_api_key():
|
|
188
|
+
return "api_key"
|
|
189
|
+
if self._has_cli_auth():
|
|
190
|
+
return "oauth"
|
|
191
|
+
return "none"
|
|
192
|
+
|
|
152
193
|
def is_configured(self) -> bool:
|
|
153
|
-
return bool(self._get_api_key())
|
|
194
|
+
return bool(self._get_api_key()) or bool(self._has_cli_auth())
|
|
154
195
|
|
|
155
196
|
def get_install_info(self) -> dict[str, str]:
|
|
156
197
|
info: dict[str, str] = {"method": "not installed", "path": "", "version": ""}
|
|
@@ -171,7 +212,7 @@ class GoogleAgent(BaseAgent):
|
|
|
171
212
|
pass
|
|
172
213
|
break
|
|
173
214
|
|
|
174
|
-
#
|
|
215
|
+
# Check for Antigravity IDE or Gemini app (macOS app)
|
|
175
216
|
if not info["path"]:
|
|
176
217
|
app_paths = [
|
|
177
218
|
Path("/Applications/Antigravity IDE.app"),
|
|
@@ -187,29 +228,47 @@ class GoogleAgent(BaseAgent):
|
|
|
187
228
|
info["version"] = "Installed"
|
|
188
229
|
break
|
|
189
230
|
|
|
231
|
+
# Check for Google Account in ~/.gemini/google_accounts.json
|
|
232
|
+
acc_file = Path.home() / ".gemini" / "google_accounts.json"
|
|
233
|
+
if acc_file.is_file():
|
|
234
|
+
try:
|
|
235
|
+
acc_data = json.loads(acc_file.read_text())
|
|
236
|
+
account = acc_data.get("active") or (acc_data.get("old") and acc_data["old"][0]) or ""
|
|
237
|
+
if account:
|
|
238
|
+
info["account"] = account
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
190
242
|
return info
|
|
191
243
|
|
|
192
244
|
def get_subscription_info(self) -> SubscriptionInfo:
|
|
193
245
|
key = self._get_api_key()
|
|
194
|
-
if
|
|
246
|
+
if key:
|
|
247
|
+
if key.startswith("AIza"):
|
|
248
|
+
return SubscriptionInfo(
|
|
249
|
+
tier=SubscriptionTier.FREE,
|
|
250
|
+
is_active=True,
|
|
251
|
+
message="Google API key detected",
|
|
252
|
+
)
|
|
195
253
|
return SubscriptionInfo(
|
|
196
254
|
tier=SubscriptionTier.UNKNOWN,
|
|
197
|
-
is_active=
|
|
198
|
-
message="
|
|
255
|
+
is_active=True,
|
|
256
|
+
message="API key detected",
|
|
199
257
|
)
|
|
200
258
|
|
|
201
|
-
|
|
202
|
-
if
|
|
259
|
+
cli_auth = self._has_cli_auth()
|
|
260
|
+
if cli_auth:
|
|
261
|
+
account = cli_auth.get("account", "")
|
|
203
262
|
return SubscriptionInfo(
|
|
204
|
-
tier=SubscriptionTier.
|
|
263
|
+
tier=SubscriptionTier.PRO,
|
|
205
264
|
is_active=True,
|
|
206
|
-
message="Google
|
|
265
|
+
message=f"Google OAuth ({account})" if account else "Google OAuth active",
|
|
207
266
|
)
|
|
208
267
|
|
|
209
268
|
return SubscriptionInfo(
|
|
210
269
|
tier=SubscriptionTier.UNKNOWN,
|
|
211
|
-
is_active=
|
|
212
|
-
message="
|
|
270
|
+
is_active=False,
|
|
271
|
+
message="No credentials found",
|
|
213
272
|
)
|
|
214
273
|
|
|
215
274
|
# --- Chat ---
|
|
@@ -40,9 +40,9 @@ class OpenAIAgent(BaseAgent):
|
|
|
40
40
|
"""
|
|
41
41
|
|
|
42
42
|
name = "openai"
|
|
43
|
-
display_name = "
|
|
43
|
+
display_name = "ChatGPT / Codex"
|
|
44
44
|
company = "OpenAI"
|
|
45
|
-
description = "OpenAI's
|
|
45
|
+
description = "OpenAI's ChatGPT and Codex models with tool execution"
|
|
46
46
|
website = "https://platform.openai.com"
|
|
47
47
|
|
|
48
48
|
capabilities = {
|
|
@@ -59,6 +59,8 @@ class OpenAIAgent(BaseAgent):
|
|
|
59
59
|
ModelInfo(
|
|
60
60
|
id="gpt-4.1",
|
|
61
61
|
name="GPT-4.1",
|
|
62
|
+
tier="flagship",
|
|
63
|
+
description="Next-generation flagship with 1M context and advanced coding",
|
|
62
64
|
context_window=1_047_576,
|
|
63
65
|
max_output=32_768,
|
|
64
66
|
supports_tools=True,
|
|
@@ -71,6 +73,8 @@ class OpenAIAgent(BaseAgent):
|
|
|
71
73
|
ModelInfo(
|
|
72
74
|
id="o3",
|
|
73
75
|
name="O3",
|
|
76
|
+
tier="flagship",
|
|
77
|
+
description="Deep reasoning model with autonomous multi-step problem solving",
|
|
74
78
|
context_window=200_000,
|
|
75
79
|
max_output=100_000,
|
|
76
80
|
supports_tools=True,
|
|
@@ -79,9 +83,24 @@ class OpenAIAgent(BaseAgent):
|
|
|
79
83
|
pricing_input=2.0,
|
|
80
84
|
pricing_output=8.0,
|
|
81
85
|
),
|
|
86
|
+
ModelInfo(
|
|
87
|
+
id="gpt-4o",
|
|
88
|
+
name="GPT-4o",
|
|
89
|
+
tier="balanced",
|
|
90
|
+
description="High-speed multimodal flagship for omni-modal coding and vision",
|
|
91
|
+
context_window=128_000,
|
|
92
|
+
max_output=16_384,
|
|
93
|
+
supports_tools=True,
|
|
94
|
+
supports_streaming=True,
|
|
95
|
+
supports_vision=True,
|
|
96
|
+
pricing_input=2.50,
|
|
97
|
+
pricing_output=10.0,
|
|
98
|
+
),
|
|
82
99
|
ModelInfo(
|
|
83
100
|
id="o4-mini",
|
|
84
101
|
name="O4 Mini",
|
|
102
|
+
tier="balanced",
|
|
103
|
+
description="Fast and affordable reasoning model for everyday programming",
|
|
85
104
|
context_window=200_000,
|
|
86
105
|
max_output=100_000,
|
|
87
106
|
supports_tools=True,
|
|
@@ -91,19 +110,23 @@ class OpenAIAgent(BaseAgent):
|
|
|
91
110
|
pricing_output=4.40,
|
|
92
111
|
),
|
|
93
112
|
ModelInfo(
|
|
94
|
-
id="gpt-4o",
|
|
95
|
-
name="GPT-4o",
|
|
113
|
+
id="gpt-4o-mini",
|
|
114
|
+
name="GPT-4o Mini",
|
|
115
|
+
tier="fast",
|
|
116
|
+
description="Lightweight and low-latency model for fast script tasks",
|
|
96
117
|
context_window=128_000,
|
|
97
118
|
max_output=16_384,
|
|
98
119
|
supports_tools=True,
|
|
99
120
|
supports_streaming=True,
|
|
100
121
|
supports_vision=True,
|
|
101
|
-
pricing_input=
|
|
102
|
-
pricing_output=
|
|
122
|
+
pricing_input=0.15,
|
|
123
|
+
pricing_output=0.60,
|
|
103
124
|
),
|
|
104
125
|
ModelInfo(
|
|
105
126
|
id="codex-mini",
|
|
106
127
|
name="Codex Mini",
|
|
128
|
+
tier="fast",
|
|
129
|
+
description="Specialized fast code generation model",
|
|
107
130
|
context_window=200_000,
|
|
108
131
|
max_output=16_384,
|
|
109
132
|
supports_tools=True,
|
|
@@ -170,8 +193,47 @@ class OpenAIAgent(BaseAgent):
|
|
|
170
193
|
]
|
|
171
194
|
return any(p.exists() for p in app_paths)
|
|
172
195
|
|
|
196
|
+
def _has_cli_auth(self) -> dict[str, str] | None:
|
|
197
|
+
"""Check for ChatGPT OAuth via Codex CLI's auth.json."""
|
|
198
|
+
auth_file = Path.home() / ".codex" / "auth.json"
|
|
199
|
+
if auth_file.is_file():
|
|
200
|
+
try:
|
|
201
|
+
data = json.loads(auth_file.read_text())
|
|
202
|
+
if data.get("auth_mode") == "chatgpt" and data.get("tokens"):
|
|
203
|
+
tokens = data["tokens"]
|
|
204
|
+
result: dict[str, str] = {"method": "chatgpt_oauth"}
|
|
205
|
+
# Decode plan info from JWT id_token claims (no verification)
|
|
206
|
+
id_token = tokens.get("id_token", "")
|
|
207
|
+
if id_token:
|
|
208
|
+
try:
|
|
209
|
+
import base64
|
|
210
|
+
parts = id_token.split(".")
|
|
211
|
+
if len(parts) >= 2:
|
|
212
|
+
payload = parts[1]
|
|
213
|
+
padding = 4 - len(payload) % 4
|
|
214
|
+
if padding != 4:
|
|
215
|
+
payload += "=" * padding
|
|
216
|
+
claims = json.loads(base64.urlsafe_b64decode(payload))
|
|
217
|
+
result["account"] = claims.get("email", "")
|
|
218
|
+
auth_info = claims.get("https://api.openai.com/auth", {})
|
|
219
|
+
result["plan"] = auth_info.get("chatgpt_plan_type", "")
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
return result
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def auth_method(self) -> str:
|
|
229
|
+
if self._get_api_key():
|
|
230
|
+
return "api_key"
|
|
231
|
+
if self._has_cli_auth():
|
|
232
|
+
return "subscription"
|
|
233
|
+
return "none"
|
|
234
|
+
|
|
173
235
|
def is_configured(self) -> bool:
|
|
174
|
-
return bool(self._get_api_key())
|
|
236
|
+
return bool(self._get_api_key()) or bool(self._has_cli_auth())
|
|
175
237
|
|
|
176
238
|
def get_install_info(self) -> dict[str, str]:
|
|
177
239
|
info: dict[str, str] = {"method": "not installed", "path": "", "version": ""}
|
|
@@ -210,24 +272,44 @@ class OpenAIAgent(BaseAgent):
|
|
|
210
272
|
|
|
211
273
|
def get_subscription_info(self) -> SubscriptionInfo:
|
|
212
274
|
key = self._get_api_key()
|
|
213
|
-
if
|
|
275
|
+
if key:
|
|
276
|
+
if key.startswith("sk-"):
|
|
277
|
+
return SubscriptionInfo(
|
|
278
|
+
tier=SubscriptionTier.PRO,
|
|
279
|
+
is_active=True,
|
|
280
|
+
message="OpenAI API key detected",
|
|
281
|
+
)
|
|
214
282
|
return SubscriptionInfo(
|
|
215
283
|
tier=SubscriptionTier.UNKNOWN,
|
|
216
|
-
is_active=
|
|
217
|
-
message="
|
|
284
|
+
is_active=True,
|
|
285
|
+
message="API key detected",
|
|
218
286
|
)
|
|
219
287
|
|
|
220
|
-
|
|
288
|
+
cli_auth = self._has_cli_auth()
|
|
289
|
+
if cli_auth:
|
|
290
|
+
plan = cli_auth.get("plan", "")
|
|
291
|
+
account = cli_auth.get("account", "")
|
|
292
|
+
tier_map = {
|
|
293
|
+
"plus": SubscriptionTier.PRO,
|
|
294
|
+
"pro": SubscriptionTier.PRO,
|
|
295
|
+
"team": SubscriptionTier.TEAM,
|
|
296
|
+
"enterprise": SubscriptionTier.ENTERPRISE,
|
|
297
|
+
}
|
|
298
|
+
tier = tier_map.get(plan, SubscriptionTier.UNKNOWN)
|
|
299
|
+
plan_label = plan.title() if plan else "Active"
|
|
300
|
+
msg = f"ChatGPT {plan_label}"
|
|
301
|
+
if account:
|
|
302
|
+
msg += f" ({account})"
|
|
221
303
|
return SubscriptionInfo(
|
|
222
|
-
tier=
|
|
304
|
+
tier=tier,
|
|
223
305
|
is_active=True,
|
|
224
|
-
message=
|
|
306
|
+
message=msg,
|
|
225
307
|
)
|
|
226
308
|
|
|
227
309
|
return SubscriptionInfo(
|
|
228
310
|
tier=SubscriptionTier.UNKNOWN,
|
|
229
|
-
is_active=
|
|
230
|
-
message="
|
|
311
|
+
is_active=False,
|
|
312
|
+
message="No credentials found",
|
|
231
313
|
)
|
|
232
314
|
|
|
233
315
|
# --- Chat ---
|
|
@@ -107,6 +107,8 @@ class ThwipCLI:
|
|
|
107
107
|
"""Main async REPL loop."""
|
|
108
108
|
# 1. Detect agents on system
|
|
109
109
|
detected = self.detector.scan_all()
|
|
110
|
+
all_agents = self.registry.list_agents()
|
|
111
|
+
installed_count = len([a for a in all_agents if a.is_installed()])
|
|
110
112
|
ready_count = len(self.registry.get_ready_agents())
|
|
111
113
|
|
|
112
114
|
# 2. Display startup banner
|
|
@@ -118,7 +120,7 @@ class ThwipCLI:
|
|
|
118
120
|
model=self.session.current_model,
|
|
119
121
|
project_path=os.path.abspath(self.session.project_path),
|
|
120
122
|
session_name=self.session.name,
|
|
121
|
-
agents_detected=
|
|
123
|
+
agents_detected=installed_count,
|
|
122
124
|
agents_ready=ready_count,
|
|
123
125
|
)
|
|
124
126
|
)
|
|
@@ -195,8 +197,11 @@ class ThwipCLI:
|
|
|
195
197
|
elif cmd in ("/agents", "/list"):
|
|
196
198
|
self.cmd_show_agents()
|
|
197
199
|
|
|
200
|
+
elif cmd in ("/key", "/auth", "/config"):
|
|
201
|
+
self.cmd_auth_config(arg1, arg2)
|
|
202
|
+
|
|
198
203
|
elif cmd in ("/models", "/m"):
|
|
199
|
-
self.cmd_show_models(arg1)
|
|
204
|
+
self.cmd_show_models(arg1, arg2)
|
|
200
205
|
|
|
201
206
|
elif cmd == "/tools":
|
|
202
207
|
self.cmd_show_tools()
|
|
@@ -298,7 +303,8 @@ class ThwipCLI:
|
|
|
298
303
|
("/about", "Display full About section, architecture, and navigation guide"),
|
|
299
304
|
("/switch [agent] [model]", "Switch active agent/model mid-conversation without losing context"),
|
|
300
305
|
("/agents", "Show all detected coding agents, company status & capabilities"),
|
|
301
|
-
("/models [agent]", "List
|
|
306
|
+
("/models [agent|tier]", "List models filtered by provider or tier (flagship, balanced, fast)"),
|
|
307
|
+
("/key [provider] [key]", "View or configure API keys saved in ~/.thwip/config.toml"),
|
|
302
308
|
("/tools", "List all universal file, terminal, and git tools"),
|
|
303
309
|
("/status", "Display current session, project, and token stats"),
|
|
304
310
|
("/limits", "View token usage, quota, and spend metrics"),
|
|
@@ -322,15 +328,19 @@ class ThwipCLI:
|
|
|
322
328
|
async def cmd_switch(self, agent_name: str, model_id: str = "") -> None:
|
|
323
329
|
"""Switch to a different agent and/or model."""
|
|
324
330
|
if not agent_name:
|
|
325
|
-
|
|
326
|
-
|
|
331
|
+
all_agents = self.registry.list_agents()
|
|
332
|
+
installed = [a for a in all_agents if a.is_installed()]
|
|
327
333
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
334
|
+
if not installed:
|
|
335
|
+
print_error("No agents detected on your machine.")
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
console.print("\n[bold white]Available Coding Agents on Your Machine:[/bold white]")
|
|
332
339
|
|
|
333
|
-
|
|
340
|
+
# Sort: configured first, then just installed
|
|
341
|
+
ready_agents = [a for a in installed if a.is_configured()]
|
|
342
|
+
unready_agents = [a for a in installed if not a.is_configured()]
|
|
343
|
+
ordered = ready_agents + unready_agents
|
|
334
344
|
|
|
335
345
|
for i, a in enumerate(ordered, 1):
|
|
336
346
|
brand = get_brand(a.company)
|
|
@@ -343,10 +353,14 @@ class ThwipCLI:
|
|
|
343
353
|
f"({a.company}) - [{status_style}][{status_str}][/{status_style}][dim]{loc}[/dim]"
|
|
344
354
|
)
|
|
345
355
|
|
|
356
|
+
not_installed = len(all_agents) - len(installed)
|
|
357
|
+
if not_installed > 0:
|
|
358
|
+
console.print(f"\n [dim]{not_installed} other providers available (DeepSeek, Groq, Ollama, OpenRouter). Use /agents to see all.[/dim]")
|
|
359
|
+
|
|
346
360
|
choice = input(f"\nEnter choice [1-{len(ordered)}]: ").strip()
|
|
347
361
|
if choice.isdigit() and 1 <= int(choice) <= len(ordered):
|
|
348
362
|
agent_name = ordered[int(choice) - 1].name
|
|
349
|
-
elif choice.lower() in [a.name for a in
|
|
363
|
+
elif choice.lower() in [a.name for a in installed]:
|
|
350
364
|
agent_name = choice.lower()
|
|
351
365
|
else:
|
|
352
366
|
print_warning("Switch cancelled.")
|
|
@@ -397,33 +411,142 @@ class ThwipCLI:
|
|
|
397
411
|
rows = [a.to_table_row() for a in self.registry.list_agents()]
|
|
398
412
|
console.print(render_agents_table(rows))
|
|
399
413
|
|
|
400
|
-
def cmd_show_models(self,
|
|
401
|
-
"""List models
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
414
|
+
def cmd_show_models(self, arg1: str = "", arg2: str = "") -> None:
|
|
415
|
+
"""List models by provider or tier (flagship, balanced, fast)."""
|
|
416
|
+
tier_filter = ""
|
|
417
|
+
agent_target = None
|
|
418
|
+
|
|
419
|
+
tier_aliases = {
|
|
420
|
+
"flagship": "flagship",
|
|
421
|
+
"high": "flagship",
|
|
422
|
+
"pro": "flagship",
|
|
423
|
+
"balanced": "balanced",
|
|
424
|
+
"mid": "balanced",
|
|
425
|
+
"flash": "balanced",
|
|
426
|
+
"fast": "fast",
|
|
427
|
+
"low": "fast",
|
|
428
|
+
"lite": "fast",
|
|
429
|
+
"mini": "fast",
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if arg1.lower() in tier_aliases:
|
|
433
|
+
tier_filter = tier_aliases[arg1.lower()]
|
|
434
|
+
elif arg1:
|
|
435
|
+
agent_target = self.registry.get_agent(arg1)
|
|
436
|
+
if not agent_target:
|
|
437
|
+
print_error(f"Agent '{arg1}' not found.")
|
|
438
|
+
return
|
|
439
|
+
if arg2.lower() in tier_aliases:
|
|
440
|
+
tier_filter = tier_aliases[arg2.lower()]
|
|
406
441
|
|
|
407
|
-
|
|
442
|
+
if not agent_target and not tier_filter:
|
|
443
|
+
agent_target = self.current_agent
|
|
444
|
+
|
|
445
|
+
if agent_target:
|
|
446
|
+
agents_to_show = [agent_target]
|
|
447
|
+
title = f"Available Models for {agent_target.display_name}"
|
|
448
|
+
if tier_filter:
|
|
449
|
+
title += f" ({tier_filter.title()} Tier)"
|
|
450
|
+
else:
|
|
451
|
+
agents_to_show = self.registry.list_agents()
|
|
452
|
+
title = f"All {tier_filter.title()} Tier Models Across Providers"
|
|
453
|
+
|
|
454
|
+
table = Table(title=title, box=box.ROUNDED)
|
|
455
|
+
if not agent_target:
|
|
456
|
+
table.add_column("Provider", style="cyan")
|
|
408
457
|
table.add_column("Model ID", style="bold white")
|
|
409
|
-
table.add_column("Name", style="
|
|
410
|
-
table.add_column("
|
|
411
|
-
table.add_column("
|
|
458
|
+
table.add_column("Name", style="white")
|
|
459
|
+
table.add_column("Tier", style="bold")
|
|
460
|
+
table.add_column("Context", style="dim")
|
|
412
461
|
table.add_column("Thinking", style="magenta")
|
|
413
|
-
table.add_column("Price (In/Out
|
|
462
|
+
table.add_column("Price (In/Out 1M)", style="green")
|
|
463
|
+
|
|
464
|
+
tier_styles = {
|
|
465
|
+
"flagship": "[bold magenta]Flagship / High[/bold magenta]",
|
|
466
|
+
"balanced": "[bold cyan]Balanced / Mid[/bold cyan]",
|
|
467
|
+
"fast": "[bold green]Fast / Low[/bold green]",
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
for ag in agents_to_show:
|
|
471
|
+
for m in ag.available_models:
|
|
472
|
+
m_tier = getattr(m, "tier", "balanced")
|
|
473
|
+
if tier_filter and m_tier != tier_filter:
|
|
474
|
+
continue
|
|
475
|
+
|
|
476
|
+
ctx = f"{m.context_window:,}" if m.context_window else "-"
|
|
477
|
+
price = f"${m.pricing_input} / ${m.pricing_output}" if m.pricing_input else "Free"
|
|
478
|
+
tier_badge = tier_styles.get(m_tier, m_tier.title())
|
|
479
|
+
def_mark = " [dim](default)[/dim]" if m.is_default else ""
|
|
480
|
+
|
|
481
|
+
row = []
|
|
482
|
+
if not agent_target:
|
|
483
|
+
row.append(ag.company)
|
|
484
|
+
row.extend([
|
|
485
|
+
m.id + def_mark,
|
|
486
|
+
m.name,
|
|
487
|
+
tier_badge,
|
|
488
|
+
ctx,
|
|
489
|
+
"yes" if m.supports_thinking else "-",
|
|
490
|
+
price,
|
|
491
|
+
])
|
|
492
|
+
table.add_row(*row)
|
|
493
|
+
|
|
494
|
+
console.print(table)
|
|
495
|
+
console.print("[dim]Filter by tier: [bold]/models flagship[/bold], [bold]/models balanced[/bold], [bold]/models fast[/bold][/dim]\n")
|
|
496
|
+
|
|
497
|
+
def cmd_auth_config(self, provider: str = "", key: str = "") -> None:
|
|
498
|
+
"""View or set API credentials stored in ~/.thwip/config.toml."""
|
|
499
|
+
provider_map = {
|
|
500
|
+
"google": "google",
|
|
501
|
+
"gemini": "google",
|
|
502
|
+
"antigravity": "google",
|
|
503
|
+
"openai": "openai",
|
|
504
|
+
"chatgpt": "openai",
|
|
505
|
+
"codex": "openai",
|
|
506
|
+
"claude": "anthropic",
|
|
507
|
+
"anthropic": "anthropic",
|
|
508
|
+
"deepseek": "deepseek",
|
|
509
|
+
"groq": "groq",
|
|
510
|
+
"openrouter": "openrouter",
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if provider and key:
|
|
514
|
+
target_prov = provider_map.get(provider.lower(), provider.lower())
|
|
515
|
+
self.config.keys[target_prov] = key
|
|
516
|
+
self.config.key_sources[target_prov] = "config.toml"
|
|
517
|
+
self.config.save()
|
|
518
|
+
|
|
519
|
+
# Refresh agent registry
|
|
520
|
+
self.registry = AgentRegistry(self.config)
|
|
521
|
+
agent = self.registry.get_agent(self.session.current_agent)
|
|
522
|
+
if agent:
|
|
523
|
+
self.current_agent = agent
|
|
524
|
+
print_success(f"API key for '{target_prov}' saved to ~/.thwip/config.toml")
|
|
525
|
+
return
|
|
526
|
+
|
|
527
|
+
# Show interactive auth overview
|
|
528
|
+
table = Table(title="thwip API Credentials & Authentication", box=box.ROUNDED)
|
|
529
|
+
table.add_column("Provider", style="bold white")
|
|
530
|
+
table.add_column("Auth Status", style="white")
|
|
531
|
+
table.add_column("Key Source", style="dim")
|
|
532
|
+
table.add_column("Config Command", style="cyan")
|
|
533
|
+
|
|
534
|
+
for prov, label in [
|
|
535
|
+
("google", "Google Gemini / Antigravity"),
|
|
536
|
+
("openai", "OpenAI / ChatGPT / Codex"),
|
|
537
|
+
("anthropic", "Anthropic Claude"),
|
|
538
|
+
("deepseek", "DeepSeek"),
|
|
539
|
+
("groq", "Groq"),
|
|
540
|
+
("openrouter", "OpenRouter"),
|
|
541
|
+
]:
|
|
542
|
+
has_key = bool(self.config.keys.get(prov))
|
|
543
|
+
source = self.config.key_sources.get(prov, "none")
|
|
544
|
+
status = "[bold green]Configured[/bold green]" if has_key else "[bold yellow]Missing[/bold yellow]"
|
|
545
|
+
cmd_hint = f"/key {prov} <your_api_key>"
|
|
546
|
+
table.add_row(label, status, source, cmd_hint)
|
|
414
547
|
|
|
415
|
-
for m in target.available_models:
|
|
416
|
-
ctx = f"{m.context_window:,}" if m.context_window else "-"
|
|
417
|
-
price = f"${m.pricing_input} / ${m.pricing_output}" if m.pricing_input else "Free / Local"
|
|
418
|
-
table.add_row(
|
|
419
|
-
m.id + (" (default)" if m.is_default else ""),
|
|
420
|
-
m.name,
|
|
421
|
-
ctx,
|
|
422
|
-
"yes" if m.supports_tools else "-",
|
|
423
|
-
"yes" if m.supports_thinking else "-",
|
|
424
|
-
price,
|
|
425
|
-
)
|
|
426
548
|
console.print(table)
|
|
549
|
+
console.print("[dim]To set a key: [bold white]/key <provider> <key>[/bold white] (e.g. /key google AIzaSy...)[/dim]\n")
|
|
427
550
|
|
|
428
551
|
def cmd_show_status(self) -> None:
|
|
429
552
|
"""Display status."""
|
|
@@ -16,6 +16,8 @@ from rich.table import Table
|
|
|
16
16
|
from rich.text import Text
|
|
17
17
|
from rich.theme import Theme
|
|
18
18
|
from rich.markdown import Markdown
|
|
19
|
+
|
|
20
|
+
from thwip import __version__
|
|
19
21
|
from rich.syntax import Syntax
|
|
20
22
|
from rich import box
|
|
21
23
|
|
|
@@ -151,7 +153,7 @@ def render_startup_banner(
|
|
|
151
153
|
lines.append("╚═╝ ╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝╚═╝ \n\n", style="dim")
|
|
152
154
|
|
|
153
155
|
lines.append(" Universal Coding Agent Multiplexer", style="bold white")
|
|
154
|
-
lines.append("
|
|
156
|
+
lines.append(f" v{__version__}\n\n", style="dim")
|
|
155
157
|
lines.append(" Agent: ", style="dim")
|
|
156
158
|
lines.append(f"{agent_name}", style=brand.label_style)
|
|
157
159
|
lines.append(f" ({company})\n", style="dim")
|
|
@@ -163,7 +165,7 @@ def render_startup_banner(
|
|
|
163
165
|
lines.append(f"{session_name}\n", style="white")
|
|
164
166
|
lines.append(" Environment: ", style="dim")
|
|
165
167
|
lines.append(f"{agents_detected}", style="bold white")
|
|
166
|
-
lines.append("
|
|
168
|
+
lines.append(" agents installed, ", style="dim")
|
|
167
169
|
lines.append(f"{agents_ready}", style="success")
|
|
168
170
|
lines.append(" ready\n\n", style="dim")
|
|
169
171
|
lines.append(" Quickstart: Type ", style="dim")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|