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.
Files changed (35) hide show
  1. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/PKG-INFO +1 -1
  2. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/pyproject.toml +1 -1
  3. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/__init__.py +1 -1
  4. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/base.py +25 -1
  5. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/claude_agent.py +68 -16
  6. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/google_agent.py +70 -11
  7. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/openai_agent.py +97 -15
  8. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/cli.py +156 -33
  9. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/theme.py +4 -2
  10. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/uv.lock +1 -1
  11. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/.github/workflows/publish.yml +0 -0
  12. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/.gitignore +0 -0
  13. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/README.md +0 -0
  14. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/install.sh +0 -0
  15. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/tests/test_agents.py +0 -0
  16. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/tests/test_detector.py +0 -0
  17. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/tests/test_session.py +0 -0
  18. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/tests/test_tools.py +0 -0
  19. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/__main__.py +0 -0
  20. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/__init__.py +0 -0
  21. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/deepseek_agent.py +0 -0
  22. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/groq_agent.py +0 -0
  23. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/ollama_agent.py +0 -0
  24. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/agents/openrouter_agent.py +0 -0
  25. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/config.py +0 -0
  26. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/detector.py +0 -0
  27. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/limits.py +0 -0
  28. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/session.py +0 -0
  29. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/shortcuts.py +0 -0
  30. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/tools/__init__.py +0 -0
  31. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/tools/code_runner.py +0 -0
  32. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/tools/file_editor.py +0 -0
  33. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/tools/git_ops.py +0 -0
  34. {thwip_cli-1.0.2 → thwip_cli-1.0.4}/thwip/tools/terminal.py +0 -0
  35. {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.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thwip-cli"
7
- version = "1.0.2"
7
+ version = "1.0.4"
8
8
  description = "Universal coding agent multiplexer: detect, switch, and route between AI coding agents seamlessly"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,4 +1,4 @@
1
1
  """thwip: Universal Coding Agent Multiplexer."""
2
2
 
3
- __version__ = "1.0.2"
3
+ __version__ = "1.0.4"
4
4
  __app_name__ = "thwip"
@@ -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 or configured."""
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 Anthropic API key."""
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
- We can't fully detect tier without an API call, but we can infer from
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 not key:
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=False,
218
- message="No API key found",
268
+ is_active=True,
269
+ message="API key detected",
219
270
  )
220
271
 
221
- # Anthropic keys start with "sk-ant-"
222
- if key.startswith("sk-ant-"):
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, # Assume pro if they have a key
276
+ tier=SubscriptionTier.PRO,
225
277
  is_active=True,
226
- message="API key detected",
278
+ message=f"Claude Code ({account})",
227
279
  )
228
280
 
229
281
  return SubscriptionInfo(
230
282
  tier=SubscriptionTier.UNKNOWN,
231
- is_active=True,
232
- message="API key format not recognized, but may work",
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
- # Also check for Antigravity IDE or Gemini app (macOS app)
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 not key:
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=False,
198
- message="No API key found",
255
+ is_active=True,
256
+ message="API key detected",
199
257
  )
200
258
 
201
- # Google Gemini API keys typically start with "AIza"
202
- if key.startswith("AIza"):
259
+ cli_auth = self._has_cli_auth()
260
+ if cli_auth:
261
+ account = cli_auth.get("account", "")
203
262
  return SubscriptionInfo(
204
- tier=SubscriptionTier.FREE, # Free tier by default
263
+ tier=SubscriptionTier.PRO,
205
264
  is_active=True,
206
- message="Google API key detected",
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=True,
212
- message="API key detected",
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 = "Codex / OpenAI"
43
+ display_name = "ChatGPT / Codex"
44
44
  company = "OpenAI"
45
- description = "OpenAI's coding agent with sandboxed code execution"
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=2.50,
102
- pricing_output=10.0,
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 not key:
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=False,
217
- message="No API key found",
284
+ is_active=True,
285
+ message="API key detected",
218
286
  )
219
287
 
220
- if key.startswith("sk-"):
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=SubscriptionTier.PRO,
304
+ tier=tier,
223
305
  is_active=True,
224
- message="OpenAI API key detected",
306
+ message=msg,
225
307
  )
226
308
 
227
309
  return SubscriptionInfo(
228
310
  tier=SubscriptionTier.UNKNOWN,
229
- is_active=True,
230
- message="API key detected",
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=len(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 available models for current or target agent"),
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
- agents = self.registry.list_agents()
326
- console.print("\n[bold white]Available Coding Agents on Your Machine:[/bold white]")
331
+ all_agents = self.registry.list_agents()
332
+ installed = [a for a in all_agents if a.is_installed()]
327
333
 
328
- # Group: Ready / Configured
329
- ready_agents = [a for a in agents if a.is_configured()]
330
- installed_agents = [a for a in agents if a.is_installed() and not a.is_configured()]
331
- other_agents = [a for a in agents if not a.is_installed()]
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
- ordered = ready_agents + installed_agents + other_agents
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 agents]:
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, agent_name: str = "") -> None:
401
- """List models for current or target agent."""
402
- target = self.registry.get_agent(agent_name) if agent_name else self.current_agent
403
- if not target:
404
- print_error(f"Agent '{agent_name}' not found.")
405
- return
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
- table = Table(title=f"Available Models for {target.display_name}", box=box.ROUNDED)
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="cyan")
410
- table.add_column("Context", style="white")
411
- table.add_column("Tools", style="green")
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 per 1M)", style="dim")
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(" v1.0.2\n\n", style="dim")
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(" tools detected, ", style="dim")
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")
@@ -1022,7 +1022,7 @@ wheels = [
1022
1022
 
1023
1023
  [[package]]
1024
1024
  name = "thwip-cli"
1025
- version = "1.0.2"
1025
+ version = "1.0.4"
1026
1026
  source = { editable = "." }
1027
1027
  dependencies = [
1028
1028
  { name = "anthropic" },
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