thwip-cli 1.0.0__tar.gz → 1.0.2__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/.github/workflows/publish.yml +55 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/PKG-INFO +1 -1
- thwip_cli-1.0.2/install.sh +22 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/pyproject.toml +1 -1
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/__init__.py +1 -1
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/agents/base.py +2 -2
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/agents/claude_agent.py +25 -3
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/agents/google_agent.py +20 -7
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/agents/openai_agent.py +28 -6
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/cli.py +92 -15
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/detector.py +21 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/theme.py +85 -11
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/uv.lock +2 -2
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/.gitignore +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/README.md +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/tests/test_agents.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/tests/test_detector.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/tests/test_session.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/tests/test_tools.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/__main__.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/agents/__init__.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/agents/deepseek_agent.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/agents/groq_agent.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/agents/ollama_agent.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/agents/openrouter_agent.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/config.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/limits.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/session.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/shortcuts.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/tools/__init__.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/tools/code_runner.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/tools/file_editor.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/tools/git_ops.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/tools/terminal.py +0 -0
- {thwip_cli-1.0.0 → thwip_cli-1.0.2}/thwip/utils.py +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
name: Publish to PyPI and GitHub Releases
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- name: Checkout code
|
|
14
|
+
uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Install uv
|
|
17
|
+
uses: astral-sh/setup-uv@v5
|
|
18
|
+
|
|
19
|
+
- name: Set up Python
|
|
20
|
+
run: uv python install 3.13
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies and test
|
|
23
|
+
run: |
|
|
24
|
+
uv sync --all-extras
|
|
25
|
+
uv run pytest
|
|
26
|
+
|
|
27
|
+
build-and-publish:
|
|
28
|
+
needs: test
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
permissions:
|
|
31
|
+
contents: write
|
|
32
|
+
id-token: write
|
|
33
|
+
|
|
34
|
+
steps:
|
|
35
|
+
- name: Checkout code
|
|
36
|
+
uses: actions/checkout@v4
|
|
37
|
+
|
|
38
|
+
- name: Install uv
|
|
39
|
+
uses: astral-sh/setup-uv@v5
|
|
40
|
+
|
|
41
|
+
- name: Build package
|
|
42
|
+
run: uv build
|
|
43
|
+
|
|
44
|
+
- name: Publish to PyPI
|
|
45
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
46
|
+
with:
|
|
47
|
+
packages-dir: dist/
|
|
48
|
+
continue-on-error: true
|
|
49
|
+
|
|
50
|
+
- name: Create GitHub Release
|
|
51
|
+
uses: softprops/action-gh-release@v2
|
|
52
|
+
if: startsWith(github.ref, 'refs/tags/')
|
|
53
|
+
with:
|
|
54
|
+
files: dist/*
|
|
55
|
+
generate_release_notes: true
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thwip-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
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
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Install script for thwip (Universal Coding Agent Multiplexer)
|
|
3
|
+
set -e
|
|
4
|
+
|
|
5
|
+
echo "Installing thwip..."
|
|
6
|
+
|
|
7
|
+
if command -v uv &> /dev/null; then
|
|
8
|
+
echo "Installing via uv tool..."
|
|
9
|
+
uv tool install --force thwip-cli
|
|
10
|
+
elif command -v pipx &> /dev/null; then
|
|
11
|
+
echo "Installing via pipx..."
|
|
12
|
+
pipx install --force thwip-cli
|
|
13
|
+
elif command -v pip3 &> /dev/null; then
|
|
14
|
+
echo "Installing via pip3..."
|
|
15
|
+
pip3 install --upgrade thwip-cli
|
|
16
|
+
else
|
|
17
|
+
echo "Error: Neither uv, pipx, nor pip3 was found on your system."
|
|
18
|
+
echo "Please install Python (>= 3.11) or uv to use thwip."
|
|
19
|
+
exit 1
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
echo "thwip installed successfully. Run 'thwip' to get started."
|
|
@@ -301,7 +301,7 @@ class BaseAgent(ABC):
|
|
|
301
301
|
if not self.is_installed():
|
|
302
302
|
return ("Not Installed", "dim")
|
|
303
303
|
if not self.is_configured():
|
|
304
|
-
return ("No Key", "status.
|
|
304
|
+
return ("Installed (No Key)", "status.limited")
|
|
305
305
|
|
|
306
306
|
limit = self.check_limits()
|
|
307
307
|
if limit == LimitStatus.OK:
|
|
@@ -315,7 +315,7 @@ class BaseAgent(ABC):
|
|
|
315
315
|
elif limit == LimitStatus.INVALID_KEY:
|
|
316
316
|
return ("Invalid Key", "error")
|
|
317
317
|
else:
|
|
318
|
-
return ("
|
|
318
|
+
return ("Installed", "status.ready")
|
|
319
319
|
|
|
320
320
|
def to_table_row(self) -> dict:
|
|
321
321
|
"""Convert to a dict suitable for render_agents_table()."""
|
|
@@ -149,8 +149,17 @@ class ClaudeAgent(BaseAgent):
|
|
|
149
149
|
# --- Detection ---
|
|
150
150
|
|
|
151
151
|
def is_installed(self) -> bool:
|
|
152
|
-
"""Check if Claude Code CLI is installed or configured."""
|
|
153
|
-
|
|
152
|
+
"""Check if Claude Code CLI or Claude desktop app is installed or configured."""
|
|
153
|
+
if self.is_configured():
|
|
154
|
+
return True
|
|
155
|
+
if shutil.which("claude") is not None:
|
|
156
|
+
return True
|
|
157
|
+
app_paths = [
|
|
158
|
+
Path("/Applications/Claude.app"),
|
|
159
|
+
Path.home() / "Applications" / "Claude.app",
|
|
160
|
+
Path.home() / ".claude",
|
|
161
|
+
]
|
|
162
|
+
return any(p.exists() for p in app_paths)
|
|
154
163
|
|
|
155
164
|
def is_configured(self) -> bool:
|
|
156
165
|
"""Check if we have a valid Anthropic API key."""
|
|
@@ -166,7 +175,7 @@ class ClaudeAgent(BaseAgent):
|
|
|
166
175
|
path = shutil.which("claude")
|
|
167
176
|
if path:
|
|
168
177
|
info["path"] = path
|
|
169
|
-
info["method"] = "
|
|
178
|
+
info["method"] = "CLI binary"
|
|
170
179
|
|
|
171
180
|
# Try to get version
|
|
172
181
|
try:
|
|
@@ -179,6 +188,19 @@ class ClaudeAgent(BaseAgent):
|
|
|
179
188
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
180
189
|
pass
|
|
181
190
|
|
|
191
|
+
if not info["path"]:
|
|
192
|
+
app_paths = [
|
|
193
|
+
Path("/Applications/Claude.app"),
|
|
194
|
+
Path.home() / "Applications" / "Claude.app",
|
|
195
|
+
Path.home() / ".claude",
|
|
196
|
+
]
|
|
197
|
+
for app in app_paths:
|
|
198
|
+
if app.exists():
|
|
199
|
+
info["method"] = "macOS config" if app.name == ".claude" else "macOS app"
|
|
200
|
+
info["path"] = str(app)
|
|
201
|
+
info["version"] = "Installed"
|
|
202
|
+
break
|
|
203
|
+
|
|
182
204
|
self._install_cache = info
|
|
183
205
|
return info
|
|
184
206
|
|
|
@@ -134,11 +134,20 @@ class GoogleAgent(BaseAgent):
|
|
|
134
134
|
# --- Detection ---
|
|
135
135
|
|
|
136
136
|
def is_installed(self) -> bool:
|
|
137
|
-
"""Check if Gemini CLI, Antigravity, or
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
137
|
+
"""Check if Gemini CLI, Antigravity IDE, or Gemini app is installed."""
|
|
138
|
+
if self.is_configured():
|
|
139
|
+
return True
|
|
140
|
+
if any(shutil.which(cmd) is not None for cmd in ("gemini", "agy", "antigravity")):
|
|
141
|
+
return True
|
|
142
|
+
app_paths = [
|
|
143
|
+
Path("/Applications/Antigravity IDE.app"),
|
|
144
|
+
Path("/Applications/Antigravity.app"),
|
|
145
|
+
Path("/Applications/Gemini.app"),
|
|
146
|
+
Path.home() / "Applications" / "Antigravity IDE.app",
|
|
147
|
+
Path.home() / "Applications" / "Antigravity.app",
|
|
148
|
+
Path.home() / ".gemini",
|
|
149
|
+
]
|
|
150
|
+
return any(p.exists() for p in app_paths)
|
|
142
151
|
|
|
143
152
|
def is_configured(self) -> bool:
|
|
144
153
|
return bool(self._get_api_key())
|
|
@@ -150,7 +159,7 @@ class GoogleAgent(BaseAgent):
|
|
|
150
159
|
path = shutil.which(cmd)
|
|
151
160
|
if path:
|
|
152
161
|
info["path"] = path
|
|
153
|
-
info["method"] = "
|
|
162
|
+
info["method"] = "CLI binary"
|
|
154
163
|
try:
|
|
155
164
|
result = subprocess.run(
|
|
156
165
|
[cmd, "--version"],
|
|
@@ -162,16 +171,20 @@ class GoogleAgent(BaseAgent):
|
|
|
162
171
|
pass
|
|
163
172
|
break
|
|
164
173
|
|
|
165
|
-
# Also check for Antigravity IDE (macOS app)
|
|
174
|
+
# Also check for Antigravity IDE or Gemini app (macOS app)
|
|
166
175
|
if not info["path"]:
|
|
167
176
|
app_paths = [
|
|
177
|
+
Path("/Applications/Antigravity IDE.app"),
|
|
168
178
|
Path("/Applications/Antigravity.app"),
|
|
179
|
+
Path("/Applications/Gemini.app"),
|
|
180
|
+
Path.home() / "Applications" / "Antigravity IDE.app",
|
|
169
181
|
Path.home() / "Applications" / "Antigravity.app",
|
|
170
182
|
]
|
|
171
183
|
for app in app_paths:
|
|
172
184
|
if app.exists():
|
|
173
185
|
info["method"] = "macOS app"
|
|
174
186
|
info["path"] = str(app)
|
|
187
|
+
info["version"] = "Installed"
|
|
175
188
|
break
|
|
176
189
|
|
|
177
190
|
return info
|
|
@@ -157,10 +157,18 @@ class OpenAIAgent(BaseAgent):
|
|
|
157
157
|
# --- Detection ---
|
|
158
158
|
|
|
159
159
|
def is_installed(self) -> bool:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
)
|
|
160
|
+
"""Check if Codex CLI, OpenAI CLI, or ChatGPT app is installed."""
|
|
161
|
+
if self.is_configured():
|
|
162
|
+
return True
|
|
163
|
+
if any(shutil.which(cmd) is not None for cmd in ("codex", "openai", "chatgpt")):
|
|
164
|
+
return True
|
|
165
|
+
app_paths = [
|
|
166
|
+
Path("/Applications/ChatGPT.app"),
|
|
167
|
+
Path("/Applications/OpenAI.app"),
|
|
168
|
+
Path.home() / "Applications" / "ChatGPT.app",
|
|
169
|
+
Path.home() / ".config" / "openai",
|
|
170
|
+
]
|
|
171
|
+
return any(p.exists() for p in app_paths)
|
|
164
172
|
|
|
165
173
|
def is_configured(self) -> bool:
|
|
166
174
|
return bool(self._get_api_key())
|
|
@@ -168,11 +176,11 @@ class OpenAIAgent(BaseAgent):
|
|
|
168
176
|
def get_install_info(self) -> dict[str, str]:
|
|
169
177
|
info: dict[str, str] = {"method": "not installed", "path": "", "version": ""}
|
|
170
178
|
|
|
171
|
-
for cmd in ("codex", "openai"):
|
|
179
|
+
for cmd in ("codex", "openai", "chatgpt"):
|
|
172
180
|
path = shutil.which(cmd)
|
|
173
181
|
if path:
|
|
174
182
|
info["path"] = path
|
|
175
|
-
info["method"] = "
|
|
183
|
+
info["method"] = "CLI binary"
|
|
176
184
|
try:
|
|
177
185
|
result = subprocess.run(
|
|
178
186
|
[cmd, "--version"],
|
|
@@ -184,6 +192,20 @@ class OpenAIAgent(BaseAgent):
|
|
|
184
192
|
pass
|
|
185
193
|
break
|
|
186
194
|
|
|
195
|
+
# Also check for ChatGPT macOS app
|
|
196
|
+
if not info["path"]:
|
|
197
|
+
app_paths = [
|
|
198
|
+
Path("/Applications/ChatGPT.app"),
|
|
199
|
+
Path("/Applications/OpenAI.app"),
|
|
200
|
+
Path.home() / "Applications" / "ChatGPT.app",
|
|
201
|
+
]
|
|
202
|
+
for app in app_paths:
|
|
203
|
+
if app.exists():
|
|
204
|
+
info["method"] = "macOS app"
|
|
205
|
+
info["path"] = str(app)
|
|
206
|
+
info["version"] = "Installed"
|
|
207
|
+
break
|
|
208
|
+
|
|
187
209
|
return info
|
|
188
210
|
|
|
189
211
|
def get_subscription_info(self) -> SubscriptionInfo:
|
|
@@ -53,6 +53,7 @@ from thwip.theme import (
|
|
|
53
53
|
print_info,
|
|
54
54
|
print_success,
|
|
55
55
|
print_warning,
|
|
56
|
+
render_about_guide,
|
|
56
57
|
render_agent_badge,
|
|
57
58
|
render_agents_table,
|
|
58
59
|
render_capability_disclaimer,
|
|
@@ -182,6 +183,9 @@ class ThwipCLI:
|
|
|
182
183
|
if cmd in ("/quit", "/exit", "/q"):
|
|
183
184
|
return "QUIT"
|
|
184
185
|
|
|
186
|
+
elif cmd in ("/about", "/guide", "/info"):
|
|
187
|
+
self.cmd_show_about()
|
|
188
|
+
|
|
185
189
|
elif cmd in ("/help", "/h"):
|
|
186
190
|
self.show_help()
|
|
187
191
|
|
|
@@ -194,6 +198,9 @@ class ThwipCLI:
|
|
|
194
198
|
elif cmd in ("/models", "/m"):
|
|
195
199
|
self.cmd_show_models(arg1)
|
|
196
200
|
|
|
201
|
+
elif cmd == "/tools":
|
|
202
|
+
self.cmd_show_tools()
|
|
203
|
+
|
|
197
204
|
elif cmd == "/status":
|
|
198
205
|
self.cmd_show_status()
|
|
199
206
|
|
|
@@ -206,6 +213,10 @@ class ThwipCLI:
|
|
|
206
213
|
elif cmd == "/history":
|
|
207
214
|
self.cmd_show_history()
|
|
208
215
|
|
|
216
|
+
elif cmd in ("/clear", "/reset"):
|
|
217
|
+
self.session.messages.clear()
|
|
218
|
+
print_info("Conversation history cleared.")
|
|
219
|
+
|
|
209
220
|
elif cmd == "/cost":
|
|
210
221
|
self.cmd_show_cost()
|
|
211
222
|
|
|
@@ -236,10 +247,47 @@ class ThwipCLI:
|
|
|
236
247
|
print_info("Usage: /session [save|load|list|clear] [name]")
|
|
237
248
|
|
|
238
249
|
else:
|
|
239
|
-
print_warning(f"Unknown command '{cmd}'. Type /help for
|
|
250
|
+
print_warning(f"Unknown command '{cmd}'. Type /help or /about for navigation guide.")
|
|
240
251
|
|
|
241
252
|
return None
|
|
242
253
|
|
|
254
|
+
def cmd_show_about(self) -> None:
|
|
255
|
+
"""Display the complete About section and navigation guide."""
|
|
256
|
+
detected = len(self.detector.scan_all())
|
|
257
|
+
ready = len(self.registry.get_ready_agents())
|
|
258
|
+
console.print(
|
|
259
|
+
render_about_guide(
|
|
260
|
+
agents_detected=detected,
|
|
261
|
+
agents_ready=ready,
|
|
262
|
+
active_agent=self.current_agent.display_name,
|
|
263
|
+
active_model=self.session.current_model,
|
|
264
|
+
active_company=self.current_agent.company,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def cmd_show_tools(self) -> None:
|
|
269
|
+
"""Display available universal tools."""
|
|
270
|
+
table = Table(title="Universal Tool Engine", box=box.ROUNDED)
|
|
271
|
+
table.add_column("Tool Name", style="bold white")
|
|
272
|
+
table.add_column("Category", style="cyan")
|
|
273
|
+
table.add_column("Description", style="white")
|
|
274
|
+
table.add_column("Status", style="green")
|
|
275
|
+
|
|
276
|
+
tools_info = [
|
|
277
|
+
("read_file", "Filesystem", "Read file contents with line offset controls", "Active"),
|
|
278
|
+
("edit_file", "Filesystem", "Structured string replacement in local files", "Active"),
|
|
279
|
+
("write_file", "Filesystem", "Create or overwrite full files", "Active"),
|
|
280
|
+
("list_dir", "Filesystem", "List directory tree and metadata", "Active"),
|
|
281
|
+
("run_command", "Terminal", "Execute non-interactive shell commands safely", "Active"),
|
|
282
|
+
("run_code", "Sandbox", "Run inline Python or Node.js code snippets", "Active"),
|
|
283
|
+
("git_status", "Version Control", "Inspect repository changes and staging", "Active"),
|
|
284
|
+
("git_diff", "Version Control", "View unified unstaged/staged diffs", "Active"),
|
|
285
|
+
("git_commit", "Version Control", "Commit staged workspace changes", "Active"),
|
|
286
|
+
]
|
|
287
|
+
for name, cat, desc, status in tools_info:
|
|
288
|
+
table.add_row(name, cat, desc, f"[bold green]{status}[/bold green]")
|
|
289
|
+
console.print(table)
|
|
290
|
+
|
|
243
291
|
def show_help(self) -> None:
|
|
244
292
|
"""Show help information."""
|
|
245
293
|
table = Table(title="thwip Commands & Shortcuts", box=box.ROUNDED)
|
|
@@ -247,22 +295,24 @@ class ThwipCLI:
|
|
|
247
295
|
table.add_column("Description", style="white")
|
|
248
296
|
|
|
249
297
|
commands = [
|
|
298
|
+
("/about", "Display full About section, architecture, and navigation guide"),
|
|
250
299
|
("/switch [agent] [model]", "Switch active agent/model mid-conversation without losing context"),
|
|
251
300
|
("/agents", "Show all detected coding agents, company status & capabilities"),
|
|
252
301
|
("/models [agent]", "List available models for current or target agent"),
|
|
302
|
+
("/tools", "List all universal file, terminal, and git tools"),
|
|
253
303
|
("/status", "Display current session, project, and token stats"),
|
|
254
304
|
("/limits", "View token usage, quota, and spend metrics"),
|
|
255
305
|
("/detect", "Re-scan system for newly installed coding agents"),
|
|
256
306
|
("/session save [name]", "Save current chat session"),
|
|
257
307
|
("/session load <name>", "Load a previously saved session"),
|
|
258
308
|
("/session list", "List all saved sessions"),
|
|
259
|
-
("/
|
|
309
|
+
("/clear", "Clear current conversation memory"),
|
|
260
310
|
("/history", "View conversation history with model attribution badges"),
|
|
261
311
|
("/cost", "Show estimated session and cumulative cost"),
|
|
262
312
|
("/project [path]", "View or change project working directory"),
|
|
263
|
-
("Ctrl + S", "
|
|
264
|
-
("Ctrl + T", "Status view"),
|
|
265
|
-
("Ctrl +
|
|
313
|
+
("Ctrl + S", "Interactive agent/model switcher prompt"),
|
|
314
|
+
("Ctrl + T", "Status view and token counters"),
|
|
315
|
+
("Ctrl + C", "Interrupt active response or tool execution"),
|
|
266
316
|
("/quit", "Exit thwip"),
|
|
267
317
|
]
|
|
268
318
|
for c, d in commands:
|
|
@@ -272,21 +322,32 @@ class ThwipCLI:
|
|
|
272
322
|
async def cmd_switch(self, agent_name: str, model_id: str = "") -> None:
|
|
273
323
|
"""Switch to a different agent and/or model."""
|
|
274
324
|
if not agent_name:
|
|
275
|
-
# Interactive selection
|
|
276
325
|
agents = self.registry.list_agents()
|
|
277
|
-
console.print("\n[bold white]
|
|
278
|
-
|
|
326
|
+
console.print("\n[bold white]Available Coding Agents on Your Machine:[/bold white]")
|
|
327
|
+
|
|
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()]
|
|
332
|
+
|
|
333
|
+
ordered = ready_agents + installed_agents + other_agents
|
|
334
|
+
|
|
335
|
+
for i, a in enumerate(ordered, 1):
|
|
279
336
|
brand = get_brand(a.company)
|
|
280
|
-
status_str,
|
|
281
|
-
|
|
337
|
+
status_str, status_style = a.get_status_display()
|
|
338
|
+
install_info = a.get_install_info()
|
|
339
|
+
loc = f" ({install_info['path']})" if install_info.get("path") else ""
|
|
340
|
+
|
|
282
341
|
console.print(
|
|
283
342
|
f" [bold white]{i}.[/bold white] [bold]{a.display_name}[/bold] "
|
|
284
|
-
f"({a.company}) - [{status_str}]
|
|
343
|
+
f"({a.company}) - [{status_style}][{status_str}][/{status_style}][dim]{loc}[/dim]"
|
|
285
344
|
)
|
|
286
345
|
|
|
287
|
-
choice = input(f"\nEnter choice [1-{len(
|
|
288
|
-
if choice.isdigit() and 1 <= int(choice) <= len(
|
|
289
|
-
agent_name =
|
|
346
|
+
choice = input(f"\nEnter choice [1-{len(ordered)}]: ").strip()
|
|
347
|
+
if choice.isdigit() and 1 <= int(choice) <= len(ordered):
|
|
348
|
+
agent_name = ordered[int(choice) - 1].name
|
|
349
|
+
elif choice.lower() in [a.name for a in agents]:
|
|
350
|
+
agent_name = choice.lower()
|
|
290
351
|
else:
|
|
291
352
|
print_warning("Switch cancelled.")
|
|
292
353
|
return
|
|
@@ -313,7 +374,23 @@ class ThwipCLI:
|
|
|
313
374
|
unsupported=missing,
|
|
314
375
|
)
|
|
315
376
|
)
|
|
316
|
-
|
|
377
|
+
|
|
378
|
+
if not new_agent.is_configured():
|
|
379
|
+
key_name = {
|
|
380
|
+
"google": "GEMINI_API_KEY",
|
|
381
|
+
"openai": "OPENAI_API_KEY",
|
|
382
|
+
"claude": "ANTHROPIC_API_KEY",
|
|
383
|
+
"deepseek": "DEEPSEEK_API_KEY",
|
|
384
|
+
"groq": "GROQ_API_KEY",
|
|
385
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
386
|
+
}.get(new_agent.name, "API_KEY")
|
|
387
|
+
print_warning(
|
|
388
|
+
f"Notice: {new_agent.display_name} was selected, but no API key was found.\n"
|
|
389
|
+
f" Set your key with: export {key_name}=your_key_here\n"
|
|
390
|
+
f" Or add it to ~/.thwip/config.toml"
|
|
391
|
+
)
|
|
392
|
+
else:
|
|
393
|
+
print_success(f"Now chatting with {new_agent.display_name} ({chosen_model}). Context preserved!")
|
|
317
394
|
|
|
318
395
|
def cmd_show_agents(self) -> None:
|
|
319
396
|
"""Show table of all detected agents."""
|
|
@@ -96,6 +96,27 @@ KNOWN_CLI_TOOLS: list[dict[str, Any]] = [
|
|
|
96
96
|
]
|
|
97
97
|
|
|
98
98
|
KNOWN_APPS: list[dict[str, Any]] = [
|
|
99
|
+
{
|
|
100
|
+
"name": "Antigravity IDE",
|
|
101
|
+
"company": "Google",
|
|
102
|
+
"mac_path": "/Applications/Antigravity IDE.app",
|
|
103
|
+
"category": "IDE App",
|
|
104
|
+
"capabilities": ["Chat", "File Edit", "Terminal", "Browser", "Search"],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"name": "ChatGPT Desktop",
|
|
108
|
+
"company": "OpenAI",
|
|
109
|
+
"mac_path": "/Applications/ChatGPT.app",
|
|
110
|
+
"category": "Desktop App",
|
|
111
|
+
"capabilities": ["Chat", "Code Run", "Vision"],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "Gemini Desktop",
|
|
115
|
+
"company": "Google",
|
|
116
|
+
"mac_path": "/Applications/Gemini.app",
|
|
117
|
+
"category": "Desktop App",
|
|
118
|
+
"capabilities": ["Chat", "Search", "Vision"],
|
|
119
|
+
},
|
|
99
120
|
{
|
|
100
121
|
"name": "Cursor",
|
|
101
122
|
"company": "Cursor Inc",
|
|
@@ -130,27 +130,51 @@ def render_startup_banner(
|
|
|
130
130
|
agents_detected: int,
|
|
131
131
|
agents_ready: int,
|
|
132
132
|
) -> Panel:
|
|
133
|
-
"""Render the startup banner with current agent info."""
|
|
133
|
+
"""Render the startup banner with ASCII logo and current agent info."""
|
|
134
134
|
brand = get_brand(company)
|
|
135
135
|
|
|
136
136
|
lines = Text()
|
|
137
|
-
|
|
138
|
-
lines.append("
|
|
139
|
-
lines.append("
|
|
140
|
-
lines.append("
|
|
137
|
+
# Web sign + Wordmark logo in exact website theme
|
|
138
|
+
lines.append(" \\ | / ", style="bold #8E8B8B")
|
|
139
|
+
lines.append("████████╗██╗ ██╗██╗ ██╗██╗██████╗ \n", style="bold #F1ECEC")
|
|
140
|
+
lines.append(" -- \\ | / -- ", style="bold #8E8B8B")
|
|
141
|
+
lines.append("╚══██╔══╝██║ ██║██║ ██║██║██╔══██╗\n", style="bold #4285F4")
|
|
142
|
+
lines.append(" - - <(", style="bold #8E8B8B")
|
|
143
|
+
lines.append("●", style="bold #FFFFFF")
|
|
144
|
+
lines.append(")> - - ", style="bold #8E8B8B")
|
|
145
|
+
lines.append("██║ ███████║██║ █╗ ██║██║██████╔╝\n", style="bold #D97757")
|
|
146
|
+
lines.append(" -- / | \\ -- ", style="bold #8E8B8B")
|
|
147
|
+
lines.append("██║ ██╔══██║██║███╗██║██║██╔═══╝ \n", style="bold #10A37F")
|
|
148
|
+
lines.append(" / | \\ ", style="bold #8E8B8B")
|
|
149
|
+
lines.append("██║ ██║ ██║╚███╔███╔╝██║██║ \n", style="bold #F1ECEC")
|
|
150
|
+
lines.append(" ", style="bold #8E8B8B")
|
|
151
|
+
lines.append("╚═╝ ╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝╚═╝ \n\n", style="dim")
|
|
152
|
+
|
|
153
|
+
lines.append(" Universal Coding Agent Multiplexer", style="bold white")
|
|
154
|
+
lines.append(" v1.0.2\n\n", style="dim")
|
|
155
|
+
lines.append(" Agent: ", style="dim")
|
|
141
156
|
lines.append(f"{agent_name}", style=brand.label_style)
|
|
142
157
|
lines.append(f" ({company})\n", style="dim")
|
|
143
|
-
lines.append(" Model:
|
|
158
|
+
lines.append(" Model: ", style="dim")
|
|
144
159
|
lines.append(f"{model}\n", style="bold white")
|
|
145
|
-
lines.append("
|
|
160
|
+
lines.append(" Workspace: ", style="dim")
|
|
146
161
|
lines.append(f"{project_path}\n", style="white")
|
|
147
|
-
lines.append(" Session:
|
|
162
|
+
lines.append(" Session: ", style="dim")
|
|
148
163
|
lines.append(f"{session_name}\n", style="white")
|
|
149
|
-
lines.append("
|
|
164
|
+
lines.append(" Environment: ", style="dim")
|
|
150
165
|
lines.append(f"{agents_detected}", style="bold white")
|
|
151
|
-
lines.append(" detected, ", style="dim")
|
|
166
|
+
lines.append(" tools detected, ", style="dim")
|
|
152
167
|
lines.append(f"{agents_ready}", style="success")
|
|
153
|
-
lines.append(" ready", style="dim")
|
|
168
|
+
lines.append(" ready\n\n", style="dim")
|
|
169
|
+
lines.append(" Quickstart: Type ", style="dim")
|
|
170
|
+
lines.append("/about", style="bold cyan")
|
|
171
|
+
lines.append(" for guide, ", style="dim")
|
|
172
|
+
lines.append("/switch", style="bold cyan")
|
|
173
|
+
lines.append(" to change agent, ", style="dim")
|
|
174
|
+
lines.append("/agents", style="bold cyan")
|
|
175
|
+
lines.append(" to list tools, ", style="dim")
|
|
176
|
+
lines.append("Ctrl+S", style="bold yellow")
|
|
177
|
+
lines.append(" for switcher.", style="dim")
|
|
154
178
|
|
|
155
179
|
return Panel(
|
|
156
180
|
lines,
|
|
@@ -160,6 +184,56 @@ def render_startup_banner(
|
|
|
160
184
|
)
|
|
161
185
|
|
|
162
186
|
|
|
187
|
+
def render_about_guide(
|
|
188
|
+
agents_detected: int = 0,
|
|
189
|
+
agents_ready: int = 0,
|
|
190
|
+
active_agent: str = "",
|
|
191
|
+
active_model: str = "",
|
|
192
|
+
active_company: str = "",
|
|
193
|
+
) -> Panel:
|
|
194
|
+
"""Render comprehensive about section and navigation guide."""
|
|
195
|
+
brand = get_brand(active_company)
|
|
196
|
+
content = Text()
|
|
197
|
+
|
|
198
|
+
content.append("thwip - Universal Coding Agent Multiplexer\n", style="bold white")
|
|
199
|
+
content.append("Combine, switch, and route across installed AI coding agents seamlessly.\n\n", style="dim")
|
|
200
|
+
|
|
201
|
+
content.append("HOW IT WORKS\n", style="bold cyan")
|
|
202
|
+
content.append(" 1. Auto-Detection: Scans environment variables, local CLI agents, and existing credentials\n", style="dim")
|
|
203
|
+
content.append(" in ~/.claude.json, ~/.gemini/config.json, ~/.config/openai/, or Ollama.\n", style="dim")
|
|
204
|
+
content.append(" 2. Unified State: Maintains conversation history and tool outputs in a portable format.\n", style="dim")
|
|
205
|
+
content.append(" 3. Hot-Swapping: Switch models mid-conversation with zero context loss.\n", style="dim")
|
|
206
|
+
content.append(" 4. Universal Tools: Provides safe file editing, shell commands, execution, and git operations.\n", style="dim")
|
|
207
|
+
content.append(" 5. Quota Failover: Catches HTTP 429 errors and offers immediate one-key fallback switching.\n\n", style="dim")
|
|
208
|
+
|
|
209
|
+
content.append("NAVIGATION & COMMANDS\n", style="bold cyan")
|
|
210
|
+
content.append(" /switch <agent> [model] Hot-swap active model mid-conversation (e.g. /switch google gemini-2.5-pro)\n", style="white")
|
|
211
|
+
content.append(" /agents List all discovered agents, status, and capabilities\n", style="white")
|
|
212
|
+
content.append(" /models [agent] Show available models and context window limits\n", style="white")
|
|
213
|
+
content.append(" /limits View live token counts, cumulative costs, and quotas\n", style="white")
|
|
214
|
+
content.append(" /tools List all universal file, execution, and git tools\n", style="white")
|
|
215
|
+
content.append(" /session save <name> Save current conversation to disk\n", style="white")
|
|
216
|
+
content.append(" /session load <name> Restore and continue a saved session\n", style="white")
|
|
217
|
+
content.append(" /history View turn summary across models\n", style="white")
|
|
218
|
+
content.append(" /about or /guide Display this navigation guide anytime\n", style="white")
|
|
219
|
+
content.append(" /clear Reset conversation memory\n", style="white")
|
|
220
|
+
content.append(" /exit or /quit Exit the REPL\n\n", style="white")
|
|
221
|
+
|
|
222
|
+
content.append("KEYBOARD SHORTCUTS\n", style="bold cyan")
|
|
223
|
+
content.append(" Ctrl+S Interactive model switcher prompt\n", style="white")
|
|
224
|
+
content.append(" Ctrl+T Display current agent status and token counters\n", style="white")
|
|
225
|
+
content.append(" Ctrl+C Interrupt current response or tool execution\n", style="white")
|
|
226
|
+
|
|
227
|
+
return Panel(
|
|
228
|
+
content,
|
|
229
|
+
title="About & Navigation Guide",
|
|
230
|
+
title_align="left",
|
|
231
|
+
border_style=brand.primary,
|
|
232
|
+
box=box.ROUNDED,
|
|
233
|
+
padding=(1, 2),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
163
237
|
def render_agent_badge(agent_name: str, model: str, company: str) -> Text:
|
|
164
238
|
"""Render an inline badge for message attribution."""
|
|
165
239
|
brand = get_brand(company)
|
|
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
|