thwip-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thwip/__init__.py +4 -0
- thwip/__main__.py +6 -0
- thwip/agents/__init__.py +98 -0
- thwip/agents/base.py +343 -0
- thwip/agents/claude_agent.py +346 -0
- thwip/agents/deepseek_agent.py +217 -0
- thwip/agents/google_agent.py +314 -0
- thwip/agents/groq_agent.py +181 -0
- thwip/agents/ollama_agent.py +180 -0
- thwip/agents/openai_agent.py +338 -0
- thwip/agents/openrouter_agent.py +192 -0
- thwip/cli.py +593 -0
- thwip/config.py +363 -0
- thwip/detector.py +259 -0
- thwip/limits.py +84 -0
- thwip/session.py +175 -0
- thwip/shortcuts.py +75 -0
- thwip/theme.py +380 -0
- thwip/tools/__init__.py +184 -0
- thwip/tools/code_runner.py +62 -0
- thwip/tools/file_editor.py +92 -0
- thwip/tools/git_ops.py +44 -0
- thwip/tools/terminal.py +65 -0
- thwip/utils.py +110 -0
- thwip_cli-1.0.0.dist-info/METADATA +157 -0
- thwip_cli-1.0.0.dist-info/RECORD +28 -0
- thwip_cli-1.0.0.dist-info/WHEEL +4 -0
- thwip_cli-1.0.0.dist-info/entry_points.txt +2 -0
thwip/config.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for thwip.
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- Loading/saving ~/.thwip/config.toml
|
|
6
|
+
- Auto-discovering API keys from installed agent configs and env vars
|
|
7
|
+
- First-run setup wizard
|
|
8
|
+
- Merging keys from all sources
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import tomllib
|
|
22
|
+
except ImportError:
|
|
23
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
24
|
+
|
|
25
|
+
import tomli_w
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Paths
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def get_config_dir() -> Path:
|
|
33
|
+
"""Return ~/.thwip/, creating it if needed."""
|
|
34
|
+
config_dir = Path.home() / ".thwip"
|
|
35
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
return config_dir
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_sessions_dir() -> Path:
|
|
40
|
+
"""Return ~/.thwip/sessions/, creating it if needed."""
|
|
41
|
+
d = get_config_dir() / "sessions"
|
|
42
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return d
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_config_path() -> Path:
|
|
47
|
+
return get_config_dir() / "config.toml"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_usage_path() -> Path:
|
|
51
|
+
return get_config_dir() / "usage.json"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# API Key Discovery
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
# Map of env var names to provider keys
|
|
59
|
+
ENV_KEY_MAP: dict[str, str] = {
|
|
60
|
+
"ANTHROPIC_API_KEY": "anthropic",
|
|
61
|
+
"OPENAI_API_KEY": "openai",
|
|
62
|
+
"GEMINI_API_KEY": "google",
|
|
63
|
+
"GOOGLE_API_KEY": "google",
|
|
64
|
+
"DEEPSEEK_API_KEY": "deepseek",
|
|
65
|
+
"MISTRAL_API_KEY": "mistral",
|
|
66
|
+
"GROQ_API_KEY": "groq",
|
|
67
|
+
"OPENROUTER_API_KEY": "openrouter",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Known agent config file locations where we can find API keys
|
|
71
|
+
AGENT_CONFIG_LOCATIONS: list[dict[str, Any]] = [
|
|
72
|
+
{
|
|
73
|
+
"provider": "anthropic",
|
|
74
|
+
"paths": ["~/.claude.json", "~/.claude/config.json", "~/.config/claude/config.json"],
|
|
75
|
+
"key_field": "apiKey",
|
|
76
|
+
"format": "json",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"provider": "openai",
|
|
80
|
+
"paths": ["~/.config/openai/config.json"],
|
|
81
|
+
"key_field": "api_key",
|
|
82
|
+
"format": "json",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"provider": "google",
|
|
86
|
+
"paths": [
|
|
87
|
+
"~/.config/gemini/config.json",
|
|
88
|
+
"~/.gemini/config.json",
|
|
89
|
+
],
|
|
90
|
+
"key_field": "api_key",
|
|
91
|
+
"format": "json",
|
|
92
|
+
},
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def discover_api_keys() -> dict[str, str]:
|
|
97
|
+
"""
|
|
98
|
+
Auto-discover API keys from:
|
|
99
|
+
1. Environment variables
|
|
100
|
+
2. Installed agent config files
|
|
101
|
+
3. thwip's own config
|
|
102
|
+
|
|
103
|
+
Returns a dict of {provider: api_key}.
|
|
104
|
+
"""
|
|
105
|
+
keys: dict[str, str] = {}
|
|
106
|
+
|
|
107
|
+
# --- 1. Environment variables ---
|
|
108
|
+
for env_var, provider in ENV_KEY_MAP.items():
|
|
109
|
+
val = os.environ.get(env_var, "").strip()
|
|
110
|
+
if val:
|
|
111
|
+
keys[provider] = val
|
|
112
|
+
|
|
113
|
+
# --- 2. Installed agent config files ---
|
|
114
|
+
for agent_conf in AGENT_CONFIG_LOCATIONS:
|
|
115
|
+
provider = agent_conf["provider"]
|
|
116
|
+
if provider in keys:
|
|
117
|
+
continue # Already found from env
|
|
118
|
+
|
|
119
|
+
for path_str in agent_conf["paths"]:
|
|
120
|
+
path = Path(path_str).expanduser()
|
|
121
|
+
if not path.is_file():
|
|
122
|
+
continue
|
|
123
|
+
try:
|
|
124
|
+
if agent_conf["format"] == "json":
|
|
125
|
+
data = json.loads(path.read_text())
|
|
126
|
+
key_field = agent_conf["key_field"]
|
|
127
|
+
# Support nested keys like {"credentials": {"api_key": "..."}}
|
|
128
|
+
if isinstance(data, dict):
|
|
129
|
+
val = data.get(key_field, "")
|
|
130
|
+
if not val and "credentials" in data:
|
|
131
|
+
val = data["credentials"].get(key_field, "")
|
|
132
|
+
if val:
|
|
133
|
+
keys[provider] = val
|
|
134
|
+
break
|
|
135
|
+
except (json.JSONDecodeError, KeyError, TypeError, OSError):
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
return keys
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_key_sources() -> dict[str, str]:
|
|
142
|
+
"""Return where each discovered key came from (for display)."""
|
|
143
|
+
sources: dict[str, str] = {}
|
|
144
|
+
|
|
145
|
+
for env_var, provider in ENV_KEY_MAP.items():
|
|
146
|
+
if os.environ.get(env_var, "").strip():
|
|
147
|
+
sources[provider] = f"env:{env_var}"
|
|
148
|
+
|
|
149
|
+
for agent_conf in AGENT_CONFIG_LOCATIONS:
|
|
150
|
+
provider = agent_conf["provider"]
|
|
151
|
+
if provider in sources:
|
|
152
|
+
continue
|
|
153
|
+
for path_str in agent_conf["paths"]:
|
|
154
|
+
path = Path(path_str).expanduser()
|
|
155
|
+
if path.is_file():
|
|
156
|
+
try:
|
|
157
|
+
data = json.loads(path.read_text())
|
|
158
|
+
if data.get(agent_conf["key_field"]):
|
|
159
|
+
sources[provider] = str(path)
|
|
160
|
+
break
|
|
161
|
+
except (json.JSONDecodeError, OSError):
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
return sources
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Config Dataclass
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class FallbackConfig:
|
|
173
|
+
enabled: bool = True
|
|
174
|
+
chain: list[str] = field(default_factory=lambda: [
|
|
175
|
+
"claude/claude-sonnet-4",
|
|
176
|
+
"google/gemini-2.5-pro",
|
|
177
|
+
"openai/gpt-4.1",
|
|
178
|
+
"deepseek/deepseek-chat",
|
|
179
|
+
"ollama/llama3.3",
|
|
180
|
+
])
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class DisplayConfig:
|
|
185
|
+
show_agent_badge: bool = True
|
|
186
|
+
show_token_count: bool = True
|
|
187
|
+
show_cost: bool = True
|
|
188
|
+
show_capabilities: bool = True
|
|
189
|
+
markdown: bool = True
|
|
190
|
+
syntax_highlight: bool = True
|
|
191
|
+
max_width: int = 120
|
|
192
|
+
dynamic_ui: bool = True # Change UI based on agent capabilities
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass
|
|
196
|
+
class LimitsConfig:
|
|
197
|
+
warn_at_percent: int = 80
|
|
198
|
+
auto_switch: bool = False # Prompt user vs auto-switch
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class ThwipConfig:
|
|
203
|
+
"""Main configuration object."""
|
|
204
|
+
|
|
205
|
+
# Defaults
|
|
206
|
+
default_agent: str = "claude"
|
|
207
|
+
default_model: str = "claude-sonnet-4"
|
|
208
|
+
project: str = "."
|
|
209
|
+
theme: str = "dark"
|
|
210
|
+
stream: bool = True
|
|
211
|
+
auto_save: bool = True
|
|
212
|
+
confirm_tools: bool = True
|
|
213
|
+
|
|
214
|
+
# API keys (merged from all sources)
|
|
215
|
+
keys: dict[str, str] = field(default_factory=dict)
|
|
216
|
+
key_sources: dict[str, str] = field(default_factory=dict)
|
|
217
|
+
|
|
218
|
+
# Ollama
|
|
219
|
+
ollama_host: str = "http://localhost:11434"
|
|
220
|
+
|
|
221
|
+
# Sub-configs
|
|
222
|
+
fallback: FallbackConfig = field(default_factory=FallbackConfig)
|
|
223
|
+
display: DisplayConfig = field(default_factory=DisplayConfig)
|
|
224
|
+
limits: LimitsConfig = field(default_factory=LimitsConfig)
|
|
225
|
+
|
|
226
|
+
@classmethod
|
|
227
|
+
def load(cls) -> "ThwipConfig":
|
|
228
|
+
"""Load config from file, env vars, and agent configs."""
|
|
229
|
+
config = cls()
|
|
230
|
+
config_path = get_config_path()
|
|
231
|
+
|
|
232
|
+
# Load from file if exists
|
|
233
|
+
if config_path.is_file():
|
|
234
|
+
try:
|
|
235
|
+
with open(config_path, "rb") as f:
|
|
236
|
+
data = tomllib.load(f)
|
|
237
|
+
config._apply_toml(data)
|
|
238
|
+
except Exception:
|
|
239
|
+
pass # Use defaults on parse error
|
|
240
|
+
|
|
241
|
+
# Merge API keys from all sources
|
|
242
|
+
discovered = discover_api_keys()
|
|
243
|
+
config.key_sources = get_key_sources()
|
|
244
|
+
|
|
245
|
+
# Config file keys take priority, then discovered keys fill gaps
|
|
246
|
+
for provider, key in discovered.items():
|
|
247
|
+
if provider not in config.keys or not config.keys[provider]:
|
|
248
|
+
config.keys[provider] = key
|
|
249
|
+
|
|
250
|
+
return config
|
|
251
|
+
|
|
252
|
+
def _apply_toml(self, data: dict[str, Any]) -> None:
|
|
253
|
+
"""Apply parsed TOML data to config fields."""
|
|
254
|
+
defaults = data.get("defaults", {})
|
|
255
|
+
if "agent" in defaults:
|
|
256
|
+
self.default_agent = defaults["agent"]
|
|
257
|
+
if "model" in defaults:
|
|
258
|
+
self.default_model = defaults["model"]
|
|
259
|
+
if "project" in defaults:
|
|
260
|
+
self.project = defaults["project"]
|
|
261
|
+
if "theme" in defaults:
|
|
262
|
+
self.theme = defaults["theme"]
|
|
263
|
+
if "stream" in defaults:
|
|
264
|
+
self.stream = defaults["stream"]
|
|
265
|
+
if "auto_save" in defaults:
|
|
266
|
+
self.auto_save = defaults["auto_save"]
|
|
267
|
+
if "confirm_tools" in defaults:
|
|
268
|
+
self.confirm_tools = defaults["confirm_tools"]
|
|
269
|
+
|
|
270
|
+
# Keys from config file
|
|
271
|
+
keys_data = data.get("keys", {})
|
|
272
|
+
for provider, key in keys_data.items():
|
|
273
|
+
if key: # Only if non-empty
|
|
274
|
+
self.keys[provider] = key
|
|
275
|
+
self.key_sources[provider] = "config.toml"
|
|
276
|
+
|
|
277
|
+
# Ollama
|
|
278
|
+
ollama = data.get("ollama", {})
|
|
279
|
+
if "host" in ollama:
|
|
280
|
+
self.ollama_host = ollama["host"]
|
|
281
|
+
|
|
282
|
+
# Fallback
|
|
283
|
+
fallback = data.get("fallback", {})
|
|
284
|
+
if "enabled" in fallback:
|
|
285
|
+
self.fallback.enabled = fallback["enabled"]
|
|
286
|
+
if "chain" in fallback:
|
|
287
|
+
self.fallback.chain = fallback["chain"]
|
|
288
|
+
|
|
289
|
+
# Display
|
|
290
|
+
display = data.get("display", {})
|
|
291
|
+
for key in ("show_agent_badge", "show_token_count", "show_cost",
|
|
292
|
+
"show_capabilities", "markdown", "syntax_highlight",
|
|
293
|
+
"max_width", "dynamic_ui"):
|
|
294
|
+
if key in display:
|
|
295
|
+
setattr(self.display, key, display[key])
|
|
296
|
+
|
|
297
|
+
# Limits
|
|
298
|
+
limits = data.get("limits", {})
|
|
299
|
+
if "warn_at_percent" in limits:
|
|
300
|
+
self.limits.warn_at_percent = limits["warn_at_percent"]
|
|
301
|
+
if "auto_switch" in limits:
|
|
302
|
+
self.limits.auto_switch = limits["auto_switch"]
|
|
303
|
+
|
|
304
|
+
def save(self) -> None:
|
|
305
|
+
"""Save current config to TOML file."""
|
|
306
|
+
data: dict[str, Any] = {
|
|
307
|
+
"defaults": {
|
|
308
|
+
"agent": self.default_agent,
|
|
309
|
+
"model": self.default_model,
|
|
310
|
+
"project": self.project,
|
|
311
|
+
"theme": self.theme,
|
|
312
|
+
"stream": self.stream,
|
|
313
|
+
"auto_save": self.auto_save,
|
|
314
|
+
"confirm_tools": self.confirm_tools,
|
|
315
|
+
},
|
|
316
|
+
"keys": {k: v for k, v in self.keys.items()},
|
|
317
|
+
"ollama": {
|
|
318
|
+
"host": self.ollama_host,
|
|
319
|
+
},
|
|
320
|
+
"fallback": {
|
|
321
|
+
"enabled": self.fallback.enabled,
|
|
322
|
+
"chain": self.fallback.chain,
|
|
323
|
+
},
|
|
324
|
+
"display": {
|
|
325
|
+
"show_agent_badge": self.display.show_agent_badge,
|
|
326
|
+
"show_token_count": self.display.show_token_count,
|
|
327
|
+
"show_cost": self.display.show_cost,
|
|
328
|
+
"show_capabilities": self.display.show_capabilities,
|
|
329
|
+
"markdown": self.display.markdown,
|
|
330
|
+
"syntax_highlight": self.display.syntax_highlight,
|
|
331
|
+
"max_width": self.display.max_width,
|
|
332
|
+
"dynamic_ui": self.display.dynamic_ui,
|
|
333
|
+
},
|
|
334
|
+
"limits": {
|
|
335
|
+
"warn_at_percent": self.limits.warn_at_percent,
|
|
336
|
+
"auto_switch": self.limits.auto_switch,
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
config_path = get_config_path()
|
|
341
|
+
with open(config_path, "wb") as f:
|
|
342
|
+
tomli_w.dump(data, f)
|
|
343
|
+
|
|
344
|
+
def get_key(self, provider: str) -> str | None:
|
|
345
|
+
"""Get API key for a provider, returning None if not found."""
|
|
346
|
+
return self.keys.get(provider)
|
|
347
|
+
|
|
348
|
+
def has_key(self, provider: str) -> bool:
|
|
349
|
+
"""Check if we have a valid API key for a provider."""
|
|
350
|
+
key = self.keys.get(provider, "")
|
|
351
|
+
return bool(key and key.strip())
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def create_default_config() -> None:
|
|
355
|
+
"""Create default config file if it doesn't exist."""
|
|
356
|
+
config_path = get_config_path()
|
|
357
|
+
if config_path.is_file():
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
config = ThwipConfig()
|
|
361
|
+
# Discover any existing keys
|
|
362
|
+
config.keys = discover_api_keys()
|
|
363
|
+
config.save()
|
thwip/detector.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""
|
|
2
|
+
System-wide coding agent & LLM detector.
|
|
3
|
+
|
|
4
|
+
Scans the local machine for:
|
|
5
|
+
1. CLI binaries (claude, agy, gemini, codex, aider, copilot, ollama)
|
|
6
|
+
2. npm global packages
|
|
7
|
+
3. Python / pip packages
|
|
8
|
+
4. VS Code & Cursor extensions (Cline, Continue, Copilot, etc.)
|
|
9
|
+
5. macOS Applications (/Applications/Cursor.app, /Applications/Windsurf.app)
|
|
10
|
+
6. Environment variables & agent credential files
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from thwip.agents.base import Capability
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class DetectedTool:
|
|
28
|
+
"""An AI coding tool or agent discovered on the system."""
|
|
29
|
+
name: str
|
|
30
|
+
company: str
|
|
31
|
+
category: str # "CLI Agent", "IDE App", "Editor Extension", "Local Engine"
|
|
32
|
+
install_path: str
|
|
33
|
+
version: str | None = None
|
|
34
|
+
is_configured: bool = False
|
|
35
|
+
config_source: str = ""
|
|
36
|
+
capabilities: list[str] = field(default_factory=list)
|
|
37
|
+
subscription_status: str = "Unknown"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Comprehensive signature database of AI coding tools
|
|
41
|
+
KNOWN_CLI_TOOLS: list[dict[str, Any]] = [
|
|
42
|
+
{
|
|
43
|
+
"name": "Claude Code",
|
|
44
|
+
"company": "Anthropic",
|
|
45
|
+
"binaries": ["claude"],
|
|
46
|
+
"category": "CLI Agent",
|
|
47
|
+
"capabilities": ["Chat", "File Edit", "Code Run", "Terminal", "Git", "Search"],
|
|
48
|
+
"key_env": "ANTHROPIC_API_KEY",
|
|
49
|
+
"config_files": ["~/.claude.json", "~/.claude/config.json"],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"name": "Antigravity / Gemini CLI",
|
|
53
|
+
"company": "Google",
|
|
54
|
+
"binaries": ["agy", "gemini", "antigravity"],
|
|
55
|
+
"category": "CLI Agent",
|
|
56
|
+
"capabilities": ["Chat", "File Edit", "Code Run", "Terminal", "Browser", "Search"],
|
|
57
|
+
"key_env": "GEMINI_API_KEY",
|
|
58
|
+
"config_files": ["~/.config/gemini/config.json", "~/.gemini/config.json"],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "Codex / OpenAI CLI",
|
|
62
|
+
"company": "OpenAI",
|
|
63
|
+
"binaries": ["codex", "openai"],
|
|
64
|
+
"category": "CLI Agent",
|
|
65
|
+
"capabilities": ["Chat", "File Edit", "Code Run", "Terminal"],
|
|
66
|
+
"key_env": "OPENAI_API_KEY",
|
|
67
|
+
"config_files": ["~/.config/openai/config.json"],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "Aider",
|
|
71
|
+
"company": "Independent",
|
|
72
|
+
"binaries": ["aider"],
|
|
73
|
+
"category": "CLI Agent",
|
|
74
|
+
"capabilities": ["Chat", "File Edit", "Git"],
|
|
75
|
+
"key_env": "OPENAI_API_KEY",
|
|
76
|
+
"config_files": ["~/.aider.conf.yml"],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "GitHub Copilot CLI",
|
|
80
|
+
"company": "GitHub / Microsoft",
|
|
81
|
+
"binaries": ["github-copilot-cli", "copilot"],
|
|
82
|
+
"category": "CLI Agent",
|
|
83
|
+
"capabilities": ["Chat", "Terminal Suggestions"],
|
|
84
|
+
"key_env": "GITHUB_TOKEN",
|
|
85
|
+
"config_files": ["~/.config/github-copilot/hosts.json"],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"name": "Ollama",
|
|
89
|
+
"company": "Ollama",
|
|
90
|
+
"binaries": ["ollama"],
|
|
91
|
+
"category": "Local Engine",
|
|
92
|
+
"capabilities": ["Chat", "File Edit", "Code Run"],
|
|
93
|
+
"key_env": "",
|
|
94
|
+
"config_files": [],
|
|
95
|
+
},
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
KNOWN_APPS: list[dict[str, Any]] = [
|
|
99
|
+
{
|
|
100
|
+
"name": "Cursor",
|
|
101
|
+
"company": "Cursor Inc",
|
|
102
|
+
"mac_path": "/Applications/Cursor.app",
|
|
103
|
+
"category": "IDE App",
|
|
104
|
+
"capabilities": ["Chat", "File Edit", "Terminal", "Codebase Indexing"],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"name": "Windsurf",
|
|
108
|
+
"company": "Codeium",
|
|
109
|
+
"mac_path": "/Applications/Windsurf.app",
|
|
110
|
+
"category": "IDE App",
|
|
111
|
+
"capabilities": ["Chat", "File Edit", "Terminal", "Cascade Flows"],
|
|
112
|
+
},
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
KNOWN_EXTENSIONS: list[dict[str, Any]] = [
|
|
116
|
+
{
|
|
117
|
+
"id": "saoudrizwan.claude-dev",
|
|
118
|
+
"name": "Cline (Claude Dev)",
|
|
119
|
+
"company": "Independent",
|
|
120
|
+
"capabilities": ["Chat", "File Edit", "Terminal", "Browser"],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"id": "continue.continue",
|
|
124
|
+
"name": "Continue.dev",
|
|
125
|
+
"company": "Continue",
|
|
126
|
+
"capabilities": ["Chat", "File Edit", "Autocomplete"],
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"id": "github.copilot",
|
|
130
|
+
"name": "GitHub Copilot",
|
|
131
|
+
"company": "Microsoft",
|
|
132
|
+
"capabilities": ["Chat", "Autocomplete"],
|
|
133
|
+
},
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class SystemDetector:
|
|
138
|
+
"""Scans and discovers all AI coding agents and installed environments."""
|
|
139
|
+
|
|
140
|
+
def __init__(self) -> None:
|
|
141
|
+
self.detected_tools: list[DetectedTool] = []
|
|
142
|
+
|
|
143
|
+
def scan_all(self) -> list[DetectedTool]:
|
|
144
|
+
"""Perform a full system discovery scan."""
|
|
145
|
+
tools: list[DetectedTool] = []
|
|
146
|
+
|
|
147
|
+
# 1. Scan CLI Tools
|
|
148
|
+
for spec in KNOWN_CLI_TOOLS:
|
|
149
|
+
binary_found = None
|
|
150
|
+
for b in spec["binaries"]:
|
|
151
|
+
p = shutil.which(b)
|
|
152
|
+
if p:
|
|
153
|
+
binary_found = p
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
is_configured = False
|
|
157
|
+
config_source = ""
|
|
158
|
+
|
|
159
|
+
# Check environment variables
|
|
160
|
+
if spec.get("key_env") and os.environ.get(spec["key_env"]):
|
|
161
|
+
is_configured = True
|
|
162
|
+
config_source = f"env:{spec['key_env']}"
|
|
163
|
+
|
|
164
|
+
# Check config files
|
|
165
|
+
if not is_configured:
|
|
166
|
+
for c_file in spec.get("config_files", []):
|
|
167
|
+
cp = Path(c_file).expanduser()
|
|
168
|
+
if cp.is_file():
|
|
169
|
+
is_configured = True
|
|
170
|
+
config_source = str(cp)
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
# Ollama is configured if binary is present
|
|
174
|
+
if spec["name"] == "Ollama" and binary_found:
|
|
175
|
+
is_configured = True
|
|
176
|
+
config_source = "localhost:11434"
|
|
177
|
+
|
|
178
|
+
if binary_found or is_configured:
|
|
179
|
+
# Try getting version
|
|
180
|
+
ver = None
|
|
181
|
+
if binary_found:
|
|
182
|
+
try:
|
|
183
|
+
res = subprocess.run(
|
|
184
|
+
[binary_found, "--version"],
|
|
185
|
+
capture_output=True,
|
|
186
|
+
text=True,
|
|
187
|
+
timeout=3,
|
|
188
|
+
)
|
|
189
|
+
if res.returncode == 0:
|
|
190
|
+
ver = res.stdout.strip().split("\n")[0]
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
sub_status = "Active" if is_configured else "Needs Key"
|
|
195
|
+
if spec["name"] == "Ollama":
|
|
196
|
+
sub_status = "Unlimited"
|
|
197
|
+
|
|
198
|
+
tools.append(
|
|
199
|
+
DetectedTool(
|
|
200
|
+
name=spec["name"],
|
|
201
|
+
company=spec["company"],
|
|
202
|
+
category=spec["category"],
|
|
203
|
+
install_path=binary_found or "(API only)",
|
|
204
|
+
version=ver,
|
|
205
|
+
is_configured=is_configured,
|
|
206
|
+
config_source=config_source,
|
|
207
|
+
capabilities=spec["capabilities"],
|
|
208
|
+
subscription_status=sub_status,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# 2. Scan macOS /Applications
|
|
213
|
+
for app in KNOWN_APPS:
|
|
214
|
+
p = Path(app["mac_path"])
|
|
215
|
+
if p.exists():
|
|
216
|
+
tools.append(
|
|
217
|
+
DetectedTool(
|
|
218
|
+
name=app["name"],
|
|
219
|
+
company=app["company"],
|
|
220
|
+
category=app["category"],
|
|
221
|
+
install_path=str(p),
|
|
222
|
+
version="Installed",
|
|
223
|
+
is_configured=True,
|
|
224
|
+
config_source="macOS App",
|
|
225
|
+
capabilities=app["capabilities"],
|
|
226
|
+
subscription_status="Active",
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# 3. Scan VS Code extensions
|
|
231
|
+
try:
|
|
232
|
+
res = subprocess.run(
|
|
233
|
+
["code", "--list-extensions"],
|
|
234
|
+
capture_output=True,
|
|
235
|
+
text=True,
|
|
236
|
+
timeout=4,
|
|
237
|
+
)
|
|
238
|
+
if res.returncode == 0:
|
|
239
|
+
installed_exts = res.stdout.lower().splitlines()
|
|
240
|
+
for ext in KNOWN_EXTENSIONS:
|
|
241
|
+
if ext["id"].lower() in installed_exts:
|
|
242
|
+
tools.append(
|
|
243
|
+
DetectedTool(
|
|
244
|
+
name=f"{ext['name']} (VS Code)",
|
|
245
|
+
company=ext["company"],
|
|
246
|
+
category="Editor Extension",
|
|
247
|
+
install_path="VS Code Extension",
|
|
248
|
+
version="Installed",
|
|
249
|
+
is_configured=True,
|
|
250
|
+
config_source="VS Code Config",
|
|
251
|
+
capabilities=ext["capabilities"],
|
|
252
|
+
subscription_status="Active",
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
self.detected_tools = tools
|
|
259
|
+
return tools
|
thwip/limits.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Usage tracking & limit management for thwip.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import asdict, dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from thwip.config import get_usage_path
|
|
14
|
+
from thwip.utils import estimate_cost
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class AgentUsageStats:
|
|
19
|
+
"""Cumulative usage statistics for a specific agent."""
|
|
20
|
+
input_tokens: int = 0
|
|
21
|
+
output_tokens: int = 0
|
|
22
|
+
request_count: int = 0
|
|
23
|
+
estimated_cost: float = 0.0
|
|
24
|
+
last_limit_hit_timestamp: float = 0.0
|
|
25
|
+
last_error: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UsageTracker:
|
|
29
|
+
"""Tracks token usage and costs across sessions and agents."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self.stats: dict[str, AgentUsageStats] = {}
|
|
33
|
+
self.load()
|
|
34
|
+
|
|
35
|
+
def record_usage(self, agent_name: str, model: str, input_tokens: int, output_tokens: int) -> None:
|
|
36
|
+
"""Record tokens used for an agent call."""
|
|
37
|
+
if agent_name not in self.stats:
|
|
38
|
+
self.stats[agent_name] = AgentUsageStats()
|
|
39
|
+
|
|
40
|
+
st = self.stats[agent_name]
|
|
41
|
+
st.input_tokens += input_tokens
|
|
42
|
+
st.output_tokens += output_tokens
|
|
43
|
+
st.request_count += 1
|
|
44
|
+
st.estimated_cost += estimate_cost(model, input_tokens, output_tokens)
|
|
45
|
+
self.save()
|
|
46
|
+
|
|
47
|
+
def record_limit_hit(self, agent_name: str, error_msg: str) -> None:
|
|
48
|
+
"""Record when an agent hits a rate limit or quota."""
|
|
49
|
+
if agent_name not in self.stats:
|
|
50
|
+
self.stats[agent_name] = AgentUsageStats()
|
|
51
|
+
self.stats[agent_name].last_limit_hit_timestamp = time.time()
|
|
52
|
+
self.stats[agent_name].last_error = error_msg
|
|
53
|
+
self.save()
|
|
54
|
+
|
|
55
|
+
def get_summary(self) -> dict[str, Any]:
|
|
56
|
+
"""Return summary of total spend and tokens."""
|
|
57
|
+
total_tokens = sum(s.input_tokens + s.output_tokens for s in self.stats.values())
|
|
58
|
+
total_cost = sum(s.estimated_cost for s in self.stats.values())
|
|
59
|
+
total_reqs = sum(s.request_count for s in self.stats.values())
|
|
60
|
+
return {
|
|
61
|
+
"total_tokens": total_tokens,
|
|
62
|
+
"total_cost": total_cost,
|
|
63
|
+
"total_requests": total_reqs,
|
|
64
|
+
"by_agent": {k: asdict(v) for k, v in self.stats.items()},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def save(self) -> None:
|
|
68
|
+
path = get_usage_path()
|
|
69
|
+
data = {k: asdict(v) for k, v in self.stats.items()}
|
|
70
|
+
try:
|
|
71
|
+
path.write_text(json.dumps(data, indent=2))
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def load(self) -> None:
|
|
76
|
+
path = get_usage_path()
|
|
77
|
+
if not path.is_file():
|
|
78
|
+
return
|
|
79
|
+
try:
|
|
80
|
+
data = json.loads(path.read_text())
|
|
81
|
+
for k, v in data.items():
|
|
82
|
+
self.stats[k] = AgentUsageStats(**v)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|