claude-dev-cli 0.1.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.

Potentially problematic release.


This version of claude-dev-cli might be problematic. Click here for more details.

@@ -0,0 +1,174 @@
1
+ """Configuration management for Claude Dev CLI."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Dict, Optional, List
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class APIConfig(BaseModel):
11
+ """Configuration for a Claude API key."""
12
+
13
+ name: str
14
+ api_key: str
15
+ description: Optional[str] = None
16
+ default: bool = False
17
+
18
+
19
+ class ProjectProfile(BaseModel):
20
+ """Project-specific configuration."""
21
+
22
+ name: str
23
+ api_config: str # Name of the API config to use
24
+ system_prompt: Optional[str] = None
25
+ allowed_commands: List[str] = Field(default_factory=lambda: ["all"])
26
+
27
+
28
+ class Config:
29
+ """Manages configuration for Claude Dev CLI."""
30
+
31
+ CONFIG_DIR = Path.home() / ".claude-dev-cli"
32
+ CONFIG_FILE = CONFIG_DIR / "config.json"
33
+ USAGE_LOG = CONFIG_DIR / "usage.jsonl"
34
+
35
+ def __init__(self) -> None:
36
+ """Initialize configuration."""
37
+ self.config_dir = self.CONFIG_DIR
38
+ self.config_file = self.CONFIG_FILE
39
+ self.usage_log = self.USAGE_LOG
40
+ self._ensure_config_dir()
41
+ self._data: Dict = self._load_config()
42
+
43
+ def _ensure_config_dir(self) -> None:
44
+ """Ensure configuration directory exists."""
45
+ self.config_dir.mkdir(parents=True, exist_ok=True)
46
+ self.usage_log.touch(exist_ok=True)
47
+
48
+ def _load_config(self) -> Dict:
49
+ """Load configuration from file."""
50
+ if not self.config_file.exists():
51
+ default_config = {
52
+ "api_configs": [],
53
+ "project_profiles": [],
54
+ "default_model": "claude-3-5-sonnet-20241022",
55
+ "max_tokens": 4096,
56
+ }
57
+ self._save_config(default_config)
58
+ return default_config
59
+
60
+ with open(self.config_file, 'r') as f:
61
+ return json.load(f)
62
+
63
+ def _save_config(self, data: Optional[Dict] = None) -> None:
64
+ """Save configuration to file."""
65
+ if data is None:
66
+ data = self._data
67
+
68
+ with open(self.config_file, 'w') as f:
69
+ json.dump(data, f, indent=2)
70
+
71
+ def add_api_config(
72
+ self,
73
+ name: str,
74
+ api_key: Optional[str] = None,
75
+ description: Optional[str] = None,
76
+ make_default: bool = False
77
+ ) -> None:
78
+ """Add a new API configuration."""
79
+ # Get API key from env if not provided
80
+ if api_key is None:
81
+ env_var = f"{name.upper()}_ANTHROPIC_API_KEY"
82
+ api_key = os.environ.get(env_var)
83
+ if not api_key:
84
+ raise ValueError(
85
+ f"API key not provided and {env_var} environment variable not set"
86
+ )
87
+
88
+ # Check if name already exists
89
+ api_configs = self._data.get("api_configs", [])
90
+ for config in api_configs:
91
+ if config["name"] == name:
92
+ raise ValueError(f"API config with name '{name}' already exists")
93
+
94
+ # If this is the first config or make_default is True, set as default
95
+ if make_default or not api_configs:
96
+ for config in api_configs:
97
+ config["default"] = False
98
+
99
+ api_config = APIConfig(
100
+ name=name,
101
+ api_key=api_key,
102
+ description=description,
103
+ default=make_default or not api_configs
104
+ )
105
+
106
+ api_configs.append(api_config.model_dump())
107
+ self._data["api_configs"] = api_configs
108
+ self._save_config()
109
+
110
+ def get_api_config(self, name: Optional[str] = None) -> Optional[APIConfig]:
111
+ """Get API configuration by name or default."""
112
+ api_configs = self._data.get("api_configs", [])
113
+
114
+ if name:
115
+ for config in api_configs:
116
+ if config["name"] == name:
117
+ return APIConfig(**config)
118
+ else:
119
+ # Return default
120
+ for config in api_configs:
121
+ if config.get("default", False):
122
+ return APIConfig(**config)
123
+
124
+ return None
125
+
126
+ def list_api_configs(self) -> List[APIConfig]:
127
+ """List all API configurations."""
128
+ return [APIConfig(**c) for c in self._data.get("api_configs", [])]
129
+
130
+ def add_project_profile(
131
+ self,
132
+ name: str,
133
+ api_config: str,
134
+ system_prompt: Optional[str] = None,
135
+ allowed_commands: Optional[List[str]] = None
136
+ ) -> None:
137
+ """Add a project profile."""
138
+ profiles = self._data.get("project_profiles", [])
139
+
140
+ profile = ProjectProfile(
141
+ name=name,
142
+ api_config=api_config,
143
+ system_prompt=system_prompt,
144
+ allowed_commands=allowed_commands or ["all"]
145
+ )
146
+
147
+ profiles.append(profile.model_dump())
148
+ self._data["project_profiles"] = profiles
149
+ self._save_config()
150
+
151
+ def get_project_profile(self, cwd: Optional[Path] = None) -> Optional[ProjectProfile]:
152
+ """Get project profile for current directory."""
153
+ if cwd is None:
154
+ cwd = Path.cwd()
155
+
156
+ # Check for .claude-dev-cli file in current or parent directories
157
+ current = cwd
158
+ while current != current.parent:
159
+ config_file = current / ".claude-dev-cli"
160
+ if config_file.exists():
161
+ with open(config_file, 'r') as f:
162
+ data = json.load(f)
163
+ return ProjectProfile(**data)
164
+ current = current.parent
165
+
166
+ return None
167
+
168
+ def get_model(self) -> str:
169
+ """Get default model."""
170
+ return self._data.get("default_model", "claude-3-5-sonnet-20241022")
171
+
172
+ def get_max_tokens(self) -> int:
173
+ """Get default max tokens."""
174
+ return self._data.get("max_tokens", 4096)
claude_dev_cli/core.py ADDED
@@ -0,0 +1,132 @@
1
+ """Core Claude API client with routing and tracking."""
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Optional, Dict, Any, List
7
+ from anthropic import Anthropic
8
+
9
+ from claude_dev_cli.config import Config
10
+
11
+
12
+ class ClaudeClient:
13
+ """Claude API client with multi-key routing and usage tracking."""
14
+
15
+ def __init__(self, config: Optional[Config] = None, api_config_name: Optional[str] = None):
16
+ """Initialize Claude client."""
17
+ self.config = config or Config()
18
+
19
+ # Determine which API config to use
20
+ project_profile = self.config.get_project_profile()
21
+ if project_profile:
22
+ api_config_name = project_profile.api_config
23
+
24
+ self.api_config = self.config.get_api_config(api_config_name)
25
+ if not self.api_config:
26
+ raise ValueError(
27
+ "No API configuration found. Run 'cdc config add' to set up an API key."
28
+ )
29
+
30
+ self.client = Anthropic(api_key=self.api_config.api_key)
31
+ self.model = self.config.get_model()
32
+ self.max_tokens = self.config.get_max_tokens()
33
+
34
+ def call(
35
+ self,
36
+ prompt: str,
37
+ system_prompt: Optional[str] = None,
38
+ model: Optional[str] = None,
39
+ max_tokens: Optional[int] = None,
40
+ temperature: float = 1.0,
41
+ stream: bool = False
42
+ ) -> str:
43
+ """Make a call to Claude API."""
44
+ model = model or self.model
45
+ max_tokens = max_tokens or self.max_tokens
46
+
47
+ # Check project profile for system prompt
48
+ project_profile = self.config.get_project_profile()
49
+ if project_profile and project_profile.system_prompt and not system_prompt:
50
+ system_prompt = project_profile.system_prompt
51
+
52
+ kwargs: Dict[str, Any] = {
53
+ "model": model,
54
+ "max_tokens": max_tokens,
55
+ "temperature": temperature,
56
+ "messages": [{"role": "user", "content": prompt}]
57
+ }
58
+
59
+ if system_prompt:
60
+ kwargs["system"] = system_prompt
61
+
62
+ start_time = datetime.utcnow()
63
+ response = self.client.messages.create(**kwargs)
64
+ end_time = datetime.utcnow()
65
+
66
+ # Log usage
67
+ self._log_usage(
68
+ prompt=prompt,
69
+ response=response,
70
+ model=model,
71
+ duration_ms=int((end_time - start_time).total_seconds() * 1000),
72
+ api_config_name=self.api_config.name
73
+ )
74
+
75
+ # Extract text from response
76
+ text_blocks = [
77
+ block.text for block in response.content if hasattr(block, 'text')
78
+ ]
79
+ return '\n'.join(text_blocks)
80
+
81
+ def call_streaming(
82
+ self,
83
+ prompt: str,
84
+ system_prompt: Optional[str] = None,
85
+ model: Optional[str] = None,
86
+ max_tokens: Optional[int] = None,
87
+ temperature: float = 1.0
88
+ ):
89
+ """Make a streaming call to Claude API."""
90
+ model = model or self.model
91
+ max_tokens = max_tokens or self.max_tokens
92
+
93
+ # Check project profile for system prompt
94
+ project_profile = self.config.get_project_profile()
95
+ if project_profile and project_profile.system_prompt and not system_prompt:
96
+ system_prompt = project_profile.system_prompt
97
+
98
+ kwargs: Dict[str, Any] = {
99
+ "model": model,
100
+ "max_tokens": max_tokens,
101
+ "temperature": temperature,
102
+ "messages": [{"role": "user", "content": prompt}]
103
+ }
104
+
105
+ if system_prompt:
106
+ kwargs["system"] = system_prompt
107
+
108
+ with self.client.messages.stream(**kwargs) as stream:
109
+ for text in stream.text_stream:
110
+ yield text
111
+
112
+ def _log_usage(
113
+ self,
114
+ prompt: str,
115
+ response: Any,
116
+ model: str,
117
+ duration_ms: int,
118
+ api_config_name: str
119
+ ) -> None:
120
+ """Log API usage to file."""
121
+ log_entry = {
122
+ "timestamp": datetime.utcnow().isoformat(),
123
+ "api_config": api_config_name,
124
+ "model": model,
125
+ "prompt_preview": prompt[:100],
126
+ "input_tokens": response.usage.input_tokens,
127
+ "output_tokens": response.usage.output_tokens,
128
+ "duration_ms": duration_ms,
129
+ }
130
+
131
+ with open(self.config.usage_log, 'a') as f:
132
+ f.write(json.dumps(log_entry) + '\n')
@@ -0,0 +1,104 @@
1
+ """Prompt templates for various commands."""
2
+
3
+ TEST_GENERATION_PROMPT = """Generate comprehensive pytest tests for the following Python code from {filename}.
4
+
5
+ Code:
6
+ ```python
7
+ {code}
8
+ ```
9
+
10
+ Requirements:
11
+ - Use pytest framework
12
+ - Include fixtures where appropriate
13
+ - Test normal cases, edge cases, and error conditions
14
+ - Use descriptive test names
15
+ - Add docstrings to test functions
16
+ - Mock external dependencies if needed
17
+
18
+ Please provide only the test code without explanations."""
19
+
20
+ CODE_REVIEW_PROMPT = """Review the following Python code from {filename} for:
21
+
22
+ 1. **Bugs and Logic Errors**: Identify potential bugs
23
+ 2. **Security Issues**: Check for vulnerabilities
24
+ 3. **Performance**: Suggest optimizations
25
+ 4. **Best Practices**: Python idioms and patterns
26
+ 5. **Code Quality**: Readability and maintainability
27
+
28
+ Code:
29
+ ```python
30
+ {code}
31
+ ```
32
+
33
+ Please provide a structured review with specific line numbers where applicable."""
34
+
35
+ DEBUG_PROMPT = """Analyze the following error and code to identify the root cause and provide a fix.
36
+
37
+ File: {filename}
38
+
39
+ Code:
40
+ ```python
41
+ {code}
42
+ ```
43
+
44
+ Error:
45
+ ```
46
+ {error}
47
+ ```
48
+
49
+ Please provide:
50
+ 1. Root cause analysis
51
+ 2. Specific fix with code
52
+ 3. Explanation of why the error occurred"""
53
+
54
+ DOCS_GENERATION_PROMPT = """Generate comprehensive documentation for the following Python code from {filename}.
55
+
56
+ Code:
57
+ ```python
58
+ {code}
59
+ ```
60
+
61
+ Please provide:
62
+ 1. Module-level docstring
63
+ 2. Function/class docstrings in Google style
64
+ 3. Usage examples
65
+ 4. A README section explaining the module
66
+
67
+ Use clear, concise language suitable for developers."""
68
+
69
+ REFACTOR_PROMPT = """Analyze the following Python code from {filename} and suggest refactoring improvements.
70
+
71
+ Code:
72
+ ```python
73
+ {code}
74
+ ```
75
+
76
+ Focus on:
77
+ 1. Code duplication (DRY principle)
78
+ 2. Function/class complexity
79
+ 3. Naming conventions
80
+ 4. Code organization
81
+ 5. Design patterns that could be applied
82
+ 6. Type hints and documentation
83
+
84
+ Please provide refactored code with explanations."""
85
+
86
+ GIT_COMMIT_PROMPT = """Generate a conventional commit message for the following git diff.
87
+
88
+ Diff:
89
+ ```
90
+ {diff}
91
+ ```
92
+
93
+ Format:
94
+ ```
95
+ <type>(<scope>): <subject>
96
+
97
+ <body>
98
+
99
+ <footer>
100
+ ```
101
+
102
+ Types: feat, fix, docs, style, refactor, test, chore
103
+
104
+ Keep the subject under 50 characters. Use present tense. Be specific about what changed and why."""
@@ -0,0 +1,202 @@
1
+ """Usage tracking and statistics."""
2
+
3
+ import json
4
+ from collections import defaultdict
5
+ from datetime import datetime, timedelta
6
+ from pathlib import Path
7
+ from typing import Optional, Dict, Any, List
8
+
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+ from rich.panel import Panel
12
+
13
+ from claude_dev_cli.config import Config
14
+
15
+
16
+ # Pricing per 1M tokens (as of Dec 2024)
17
+ MODEL_PRICING = {
18
+ "claude-3-5-sonnet-20241022": {"input": 3.00, "output": 15.00},
19
+ "claude-3-5-sonnet-20240620": {"input": 3.00, "output": 15.00},
20
+ "claude-3-opus-20240229": {"input": 15.00, "output": 75.00},
21
+ "claude-3-sonnet-20240229": {"input": 3.00, "output": 15.00},
22
+ "claude-3-haiku-20240307": {"input": 0.25, "output": 1.25},
23
+ }
24
+
25
+
26
+ class UsageTracker:
27
+ """Track and display API usage statistics."""
28
+
29
+ def __init__(self, config: Optional[Config] = None):
30
+ """Initialize usage tracker."""
31
+ self.config = config or Config()
32
+
33
+ def _read_logs(
34
+ self,
35
+ days: Optional[int] = None,
36
+ api_config: Optional[str] = None
37
+ ) -> List[Dict[str, Any]]:
38
+ """Read usage logs with optional filters."""
39
+ if not self.config.usage_log.exists():
40
+ return []
41
+
42
+ cutoff = None
43
+ if days:
44
+ cutoff = datetime.utcnow() - timedelta(days=days)
45
+
46
+ logs = []
47
+ with open(self.config.usage_log, 'r') as f:
48
+ for line in f:
49
+ try:
50
+ entry = json.loads(line)
51
+ timestamp = datetime.fromisoformat(entry["timestamp"])
52
+
53
+ # Apply filters
54
+ if cutoff and timestamp < cutoff:
55
+ continue
56
+ if api_config and entry.get("api_config") != api_config:
57
+ continue
58
+
59
+ logs.append(entry)
60
+ except json.JSONDecodeError:
61
+ continue
62
+
63
+ return logs
64
+
65
+ def _calculate_cost(self, model: str, input_tokens: int, output_tokens: int) -> float:
66
+ """Calculate cost for a given usage."""
67
+ pricing = MODEL_PRICING.get(model, {"input": 3.00, "output": 15.00})
68
+
69
+ input_cost = (input_tokens / 1_000_000) * pricing["input"]
70
+ output_cost = (output_tokens / 1_000_000) * pricing["output"]
71
+
72
+ return input_cost + output_cost
73
+
74
+ def display_usage(
75
+ self,
76
+ console: Console,
77
+ days: Optional[int] = None,
78
+ api_config: Optional[str] = None
79
+ ) -> None:
80
+ """Display usage statistics."""
81
+ logs = self._read_logs(days=days, api_config=api_config)
82
+
83
+ if not logs:
84
+ console.print("[yellow]No usage data found.[/yellow]")
85
+ return
86
+
87
+ # Calculate totals
88
+ total_input = 0
89
+ total_output = 0
90
+ total_cost = 0.0
91
+ total_calls = len(logs)
92
+
93
+ by_api = defaultdict(lambda: {"input": 0, "output": 0, "calls": 0, "cost": 0.0})
94
+ by_date = defaultdict(lambda: {"input": 0, "output": 0, "calls": 0, "cost": 0.0})
95
+ by_model = defaultdict(lambda: {"input": 0, "output": 0, "calls": 0, "cost": 0.0})
96
+
97
+ for entry in logs:
98
+ input_tokens = entry["input_tokens"]
99
+ output_tokens = entry["output_tokens"]
100
+ model = entry["model"]
101
+ api = entry["api_config"]
102
+ date = entry["timestamp"][:10]
103
+
104
+ cost = self._calculate_cost(model, input_tokens, output_tokens)
105
+
106
+ total_input += input_tokens
107
+ total_output += output_tokens
108
+ total_cost += cost
109
+
110
+ by_api[api]["input"] += input_tokens
111
+ by_api[api]["output"] += output_tokens
112
+ by_api[api]["calls"] += 1
113
+ by_api[api]["cost"] += cost
114
+
115
+ by_date[date]["input"] += input_tokens
116
+ by_date[date]["output"] += output_tokens
117
+ by_date[date]["calls"] += 1
118
+ by_date[date]["cost"] += cost
119
+
120
+ by_model[model]["input"] += input_tokens
121
+ by_model[model]["output"] += output_tokens
122
+ by_model[model]["calls"] += 1
123
+ by_model[model]["cost"] += cost
124
+
125
+ # Display summary
126
+ title = "Usage Summary"
127
+ if days:
128
+ title += f" (Last {days} days)"
129
+ if api_config:
130
+ title += f" - {api_config}"
131
+
132
+ summary = f"""[bold]Total Calls:[/bold] {total_calls:,}
133
+ [bold]Input Tokens:[/bold] {total_input:,}
134
+ [bold]Output Tokens:[/bold] {total_output:,}
135
+ [bold]Total Tokens:[/bold] {total_input + total_output:,}
136
+ [bold]Estimated Cost:[/bold] ${total_cost:.2f}"""
137
+
138
+ console.print(Panel(summary, title=title, border_style="green"))
139
+
140
+ # Display by API config
141
+ if len(by_api) > 1:
142
+ console.print("\n[bold]By API Config:[/bold]")
143
+ api_table = Table(show_header=True, header_style="bold cyan")
144
+ api_table.add_column("API Config")
145
+ api_table.add_column("Calls", justify="right")
146
+ api_table.add_column("Input Tokens", justify="right")
147
+ api_table.add_column("Output Tokens", justify="right")
148
+ api_table.add_column("Cost", justify="right")
149
+
150
+ for api_name in sorted(by_api.keys()):
151
+ stats = by_api[api_name]
152
+ api_table.add_row(
153
+ api_name,
154
+ f"{stats['calls']:,}",
155
+ f"{stats['input']:,}",
156
+ f"{stats['output']:,}",
157
+ f"${stats['cost']:.2f}"
158
+ )
159
+
160
+ console.print(api_table)
161
+
162
+ # Display by model
163
+ if len(by_model) > 1:
164
+ console.print("\n[bold]By Model:[/bold]")
165
+ model_table = Table(show_header=True, header_style="bold cyan")
166
+ model_table.add_column("Model")
167
+ model_table.add_column("Calls", justify="right")
168
+ model_table.add_column("Input Tokens", justify="right")
169
+ model_table.add_column("Output Tokens", justify="right")
170
+ model_table.add_column("Cost", justify="right")
171
+
172
+ for model_name in sorted(by_model.keys()):
173
+ stats = by_model[model_name]
174
+ model_table.add_row(
175
+ model_name.split("-")[-1], # Show short version
176
+ f"{stats['calls']:,}",
177
+ f"{stats['input']:,}",
178
+ f"{stats['output']:,}",
179
+ f"${stats['cost']:.2f}"
180
+ )
181
+
182
+ console.print(model_table)
183
+
184
+ # Display by date (last 7 days)
185
+ console.print("\n[bold]By Date:[/bold]")
186
+ date_table = Table(show_header=True, header_style="bold cyan")
187
+ date_table.add_column("Date")
188
+ date_table.add_column("Calls", justify="right")
189
+ date_table.add_column("Tokens", justify="right")
190
+ date_table.add_column("Cost", justify="right")
191
+
192
+ for date in sorted(by_date.keys(), reverse=True)[:7]:
193
+ stats = by_date[date]
194
+ total_tokens = stats['input'] + stats['output']
195
+ date_table.add_row(
196
+ date,
197
+ f"{stats['calls']:,}",
198
+ f"{total_tokens:,}",
199
+ f"${stats['cost']:.2f}"
200
+ )
201
+
202
+ console.print(date_table)