thwip-cli 1.0.3__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.3 → thwip_cli-1.0.4}/PKG-INFO +1 -1
  2. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/pyproject.toml +1 -1
  3. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/__init__.py +1 -1
  4. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/agents/base.py +23 -1
  5. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/agents/claude_agent.py +62 -16
  6. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/agents/google_agent.py +40 -10
  7. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/agents/openai_agent.py +68 -9
  8. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/cli.py +19 -9
  9. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/theme.py +4 -2
  10. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/uv.lock +1 -1
  11. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/.github/workflows/publish.yml +0 -0
  12. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/.gitignore +0 -0
  13. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/README.md +0 -0
  14. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/install.sh +0 -0
  15. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/tests/test_agents.py +0 -0
  16. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/tests/test_detector.py +0 -0
  17. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/tests/test_session.py +0 -0
  18. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/tests/test_tools.py +0 -0
  19. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/__main__.py +0 -0
  20. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/agents/__init__.py +0 -0
  21. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/agents/deepseek_agent.py +0 -0
  22. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/agents/groq_agent.py +0 -0
  23. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/agents/ollama_agent.py +0 -0
  24. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/agents/openrouter_agent.py +0 -0
  25. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/config.py +0 -0
  26. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/detector.py +0 -0
  27. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/limits.py +0 -0
  28. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/session.py +0 -0
  29. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/shortcuts.py +0 -0
  30. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/tools/__init__.py +0 -0
  31. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/tools/code_runner.py +0 -0
  32. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/tools/file_editor.py +0 -0
  33. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/tools/git_ops.py +0 -0
  34. {thwip_cli-1.0.3 → thwip_cli-1.0.4}/thwip/tools/terminal.py +0 -0
  35. {thwip_cli-1.0.3 → 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
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.3"
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.3"
3
+ __version__ = "1.0.4"
4
4
  __app_name__ = "thwip"
@@ -210,9 +210,22 @@ class BaseAgent(ABC):
210
210
 
211
211
  @abstractmethod
212
212
  def is_configured(self) -> bool:
213
- """Check if this agent has a valid API key / credentials."""
213
+ """Check if this agent has a valid API key or OAuth/subscription credentials."""
214
214
  ...
215
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
+
216
229
  @abstractmethod
217
230
  def get_install_info(self) -> dict[str, str]:
218
231
  """
@@ -305,6 +318,15 @@ class BaseAgent(ABC):
305
318
  if not self.is_configured():
306
319
  return ("Installed (No Key)", "status.limited")
307
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
+
308
330
  limit = self.check_limits()
309
331
  if limit == LimitStatus.OK:
310
332
  return ("Ready", "status.ready")
@@ -155,7 +155,11 @@ class ClaudeAgent(BaseAgent):
155
155
  # --- Detection ---
156
156
 
157
157
  def is_installed(self) -> bool:
158
- """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
+ """
159
163
  if self.is_configured():
160
164
  return True
161
165
  if shutil.which("claude") is not None:
@@ -163,13 +167,50 @@ class ClaudeAgent(BaseAgent):
163
167
  app_paths = [
164
168
  Path("/Applications/Claude.app"),
165
169
  Path.home() / "Applications" / "Claude.app",
166
- Path.home() / ".claude",
167
170
  ]
168
171
  return any(p.exists() for p in app_paths)
169
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
+
170
211
  def is_configured(self) -> bool:
171
- """Check if we have a valid Anthropic API key."""
172
- 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())
173
214
 
174
215
  def get_install_info(self) -> dict[str, str]:
175
216
  """Get Claude Code installation details."""
@@ -198,7 +239,6 @@ class ClaudeAgent(BaseAgent):
198
239
  app_paths = [
199
240
  Path("/Applications/Claude.app"),
200
241
  Path.home() / "Applications" / "Claude.app",
201
- Path.home() / ".claude",
202
242
  ]
203
243
  for app in app_paths:
204
244
  if app.exists():
@@ -213,29 +253,35 @@ class ClaudeAgent(BaseAgent):
213
253
  def get_subscription_info(self) -> SubscriptionInfo:
214
254
  """
215
255
  Determine subscription status.
216
- We can't fully detect tier without an API call, but we can infer from
217
- the key prefix and check basic validity.
256
+ Checks API key first, then CLI subscription auth.
218
257
  """
219
258
  key = self._get_api_key()
220
- 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
+ )
221
266
  return SubscriptionInfo(
222
267
  tier=SubscriptionTier.UNKNOWN,
223
- is_active=False,
224
- message="No API key found",
268
+ is_active=True,
269
+ message="API key detected",
225
270
  )
226
271
 
227
- # Anthropic keys start with "sk-ant-"
228
- if key.startswith("sk-ant-"):
272
+ cli_auth = self._has_cli_auth()
273
+ if cli_auth:
274
+ account = cli_auth.get("account", "Active")
229
275
  return SubscriptionInfo(
230
- tier=SubscriptionTier.PRO, # Assume pro if they have a key
276
+ tier=SubscriptionTier.PRO,
231
277
  is_active=True,
232
- message="API key detected",
278
+ message=f"Claude Code ({account})",
233
279
  )
234
280
 
235
281
  return SubscriptionInfo(
236
282
  tier=SubscriptionTier.UNKNOWN,
237
- is_active=True,
238
- message="API key format not recognized, but may work",
283
+ is_active=False,
284
+ message="No credentials found",
239
285
  )
240
286
 
241
287
  # --- Chat ---
@@ -167,8 +167,31 @@ class GoogleAgent(BaseAgent):
167
167
  ]
168
168
  return any(p.exists() for p in app_paths)
169
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
+
170
193
  def is_configured(self) -> bool:
171
- return bool(self._get_api_key())
194
+ return bool(self._get_api_key()) or bool(self._has_cli_auth())
172
195
 
173
196
  def get_install_info(self) -> dict[str, str]:
174
197
  info: dict[str, str] = {"method": "not installed", "path": "", "version": ""}
@@ -220,25 +243,32 @@ class GoogleAgent(BaseAgent):
220
243
 
221
244
  def get_subscription_info(self) -> SubscriptionInfo:
222
245
  key = self._get_api_key()
223
- 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
+ )
224
253
  return SubscriptionInfo(
225
254
  tier=SubscriptionTier.UNKNOWN,
226
- is_active=False,
227
- message="No API key found",
255
+ is_active=True,
256
+ message="API key detected",
228
257
  )
229
258
 
230
- # Google Gemini API keys typically start with "AIza"
231
- if key.startswith("AIza"):
259
+ cli_auth = self._has_cli_auth()
260
+ if cli_auth:
261
+ account = cli_auth.get("account", "")
232
262
  return SubscriptionInfo(
233
- tier=SubscriptionTier.FREE, # Free tier by default
263
+ tier=SubscriptionTier.PRO,
234
264
  is_active=True,
235
- message="Google API key detected",
265
+ message=f"Google OAuth ({account})" if account else "Google OAuth active",
236
266
  )
237
267
 
238
268
  return SubscriptionInfo(
239
269
  tier=SubscriptionTier.UNKNOWN,
240
- is_active=True,
241
- message="API key detected",
270
+ is_active=False,
271
+ message="No credentials found",
242
272
  )
243
273
 
244
274
  # --- Chat ---
@@ -193,8 +193,47 @@ class OpenAIAgent(BaseAgent):
193
193
  ]
194
194
  return any(p.exists() for p in app_paths)
195
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
+
196
235
  def is_configured(self) -> bool:
197
- return bool(self._get_api_key())
236
+ return bool(self._get_api_key()) or bool(self._has_cli_auth())
198
237
 
199
238
  def get_install_info(self) -> dict[str, str]:
200
239
  info: dict[str, str] = {"method": "not installed", "path": "", "version": ""}
@@ -233,24 +272,44 @@ class OpenAIAgent(BaseAgent):
233
272
 
234
273
  def get_subscription_info(self) -> SubscriptionInfo:
235
274
  key = self._get_api_key()
236
- 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
+ )
237
282
  return SubscriptionInfo(
238
283
  tier=SubscriptionTier.UNKNOWN,
239
- is_active=False,
240
- message="No API key found",
284
+ is_active=True,
285
+ message="API key detected",
241
286
  )
242
287
 
243
- 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})"
244
303
  return SubscriptionInfo(
245
- tier=SubscriptionTier.PRO,
304
+ tier=tier,
246
305
  is_active=True,
247
- message="OpenAI API key detected",
306
+ message=msg,
248
307
  )
249
308
 
250
309
  return SubscriptionInfo(
251
310
  tier=SubscriptionTier.UNKNOWN,
252
- is_active=True,
253
- message="API key detected",
311
+ is_active=False,
312
+ message="No credentials found",
254
313
  )
255
314
 
256
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
  )
@@ -326,15 +328,19 @@ class ThwipCLI:
326
328
  async def cmd_switch(self, agent_name: str, model_id: str = "") -> None:
327
329
  """Switch to a different agent and/or model."""
328
330
  if not agent_name:
329
- agents = self.registry.list_agents()
330
- 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()]
331
333
 
332
- # Group: Ready / Configured
333
- ready_agents = [a for a in agents if a.is_configured()]
334
- installed_agents = [a for a in agents if a.is_installed() and not a.is_configured()]
335
- 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
336
337
 
337
- ordered = ready_agents + installed_agents + other_agents
338
+ console.print("\n[bold white]Available Coding Agents on Your Machine:[/bold white]")
339
+
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
338
344
 
339
345
  for i, a in enumerate(ordered, 1):
340
346
  brand = get_brand(a.company)
@@ -347,10 +353,14 @@ class ThwipCLI:
347
353
  f"({a.company}) - [{status_style}][{status_str}][/{status_style}][dim]{loc}[/dim]"
348
354
  )
349
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
+
350
360
  choice = input(f"\nEnter choice [1-{len(ordered)}]: ").strip()
351
361
  if choice.isdigit() and 1 <= int(choice) <= len(ordered):
352
362
  agent_name = ordered[int(choice) - 1].name
353
- elif choice.lower() in [a.name for a in agents]:
363
+ elif choice.lower() in [a.name for a in installed]:
354
364
  agent_name = choice.lower()
355
365
  else:
356
366
  print_warning("Switch cancelled.")
@@ -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