claude-dev-cli 0.3.0__tar.gz → 0.4.0__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.
Potentially problematic release.
This version of claude-dev-cli might be problematic. Click here for more details.
- {claude_dev_cli-0.3.0/src/claude_dev_cli.egg-info → claude_dev_cli-0.4.0}/PKG-INFO +31 -6
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/README.md +25 -5
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/pyproject.toml +7 -1
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli/cli.py +51 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli/config.py +72 -12
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli/core.py +13 -5
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli/plugins/diff_editor/viewer.py +143 -7
- claude_dev_cli-0.4.0/src/claude_dev_cli/secure_storage.py +219 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0/src/claude_dev_cli.egg-info}/PKG-INFO +31 -6
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli.egg-info/SOURCES.txt +3 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli.egg-info/requires.txt +6 -0
- claude_dev_cli-0.4.0/tests/test_diff_editor.py +272 -0
- claude_dev_cli-0.4.0/tests/test_secure_storage.py +224 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/LICENSE +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/MANIFEST.in +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/setup.cfg +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli/__init__.py +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli/commands.py +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli/plugins/__init__.py +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli/plugins/base.py +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli/plugins/diff_editor/__init__.py +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli/plugins/diff_editor/plugin.py +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli/templates.py +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli/toon_utils.py +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli/usage.py +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli.egg-info/dependency_links.txt +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli.egg-info/entry_points.txt +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli.egg-info/top_level.txt +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/tests/test_cli.py +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/tests/test_commands.py +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/tests/test_config.py +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/tests/test_core.py +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/tests/test_toon_utils.py +0 -0
- {claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/tests/test_usage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: claude-dev-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A powerful CLI tool for developers using Claude AI with multi-API routing, test generation, code review, and usage tracking
|
|
5
5
|
Author-email: Julio <thinmanj@users.noreply.github.com>
|
|
6
6
|
License: MIT
|
|
@@ -26,14 +26,19 @@ Requires-Dist: anthropic>=0.18.0
|
|
|
26
26
|
Requires-Dist: click>=8.1.0
|
|
27
27
|
Requires-Dist: rich>=13.0.0
|
|
28
28
|
Requires-Dist: pydantic>=2.0.0
|
|
29
|
+
Requires-Dist: keyring>=24.0.0
|
|
30
|
+
Requires-Dist: cryptography>=41.0.0
|
|
29
31
|
Provides-Extra: toon
|
|
30
32
|
Requires-Dist: toon-format>=0.9.0; extra == "toon"
|
|
33
|
+
Provides-Extra: plugins
|
|
34
|
+
Requires-Dist: pygments>=2.0.0; extra == "plugins"
|
|
31
35
|
Provides-Extra: dev
|
|
32
36
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
33
37
|
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
34
38
|
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
35
39
|
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
36
40
|
Requires-Dist: toon-format>=0.9.0; extra == "dev"
|
|
41
|
+
Requires-Dist: pygments>=2.0.0; extra == "dev"
|
|
37
42
|
Dynamic: license-file
|
|
38
43
|
|
|
39
44
|
# Claude Dev CLI
|
|
@@ -43,9 +48,10 @@ A powerful command-line tool for developers using Claude AI with multi-API routi
|
|
|
43
48
|
## Features
|
|
44
49
|
|
|
45
50
|
### 🔑 Multi-API Key Management
|
|
51
|
+
- **Secure Storage**: API keys stored in system keyring (macOS Keychain, Linux Secret Service, Windows Credential Locker)
|
|
46
52
|
- Route tasks to different Claude API keys (personal, client, enterprise)
|
|
47
53
|
- Automatic API selection based on project configuration
|
|
48
|
-
-
|
|
54
|
+
- Automatic migration from plaintext to secure storage
|
|
49
55
|
|
|
50
56
|
### 🧪 Developer Tools
|
|
51
57
|
- **Test Generation**: Automatic pytest test generation for Python code
|
|
@@ -95,13 +101,17 @@ pip install claude-dev-cli[toon]
|
|
|
95
101
|
# Add your personal API key
|
|
96
102
|
export PERSONAL_ANTHROPIC_API_KEY="sk-ant-..."
|
|
97
103
|
cdc config add personal --default --description "My personal API key"
|
|
104
|
+
# 🔐 Stored securely in system keyring
|
|
98
105
|
|
|
99
106
|
# Add client's API key
|
|
100
107
|
export CLIENT_ANTHROPIC_API_KEY="sk-ant-..."
|
|
101
108
|
cdc config add client --description "Client's Enterprise API"
|
|
102
109
|
|
|
103
|
-
# List configured APIs
|
|
110
|
+
# List configured APIs (shows storage method)
|
|
104
111
|
cdc config list
|
|
112
|
+
|
|
113
|
+
# Manually migrate existing keys (automatic on first run)
|
|
114
|
+
cdc config migrate-keys
|
|
105
115
|
```
|
|
106
116
|
|
|
107
117
|
### 2. Basic Usage
|
|
@@ -183,22 +193,37 @@ cat large_data.json | cdc toon encode | cdc ask "analyze this data"
|
|
|
183
193
|
|
|
184
194
|
## Configuration
|
|
185
195
|
|
|
196
|
+
### Secure API Key Storage
|
|
197
|
+
|
|
198
|
+
**🔐 Your API keys are stored securely and never in plain text.**
|
|
199
|
+
|
|
200
|
+
- **macOS**: Keychain
|
|
201
|
+
- **Linux**: Secret Service API (GNOME Keyring, KWallet)
|
|
202
|
+
- **Windows**: Windows Credential Locker
|
|
203
|
+
- **Fallback**: Encrypted file (if keyring unavailable)
|
|
204
|
+
|
|
205
|
+
Keys are automatically migrated from plaintext on first run. You can also manually migrate:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
cdc config migrate-keys
|
|
209
|
+
```
|
|
210
|
+
|
|
186
211
|
### Global Configuration
|
|
187
212
|
|
|
188
|
-
Configuration is stored in `~/.claude-dev-cli/config.json
|
|
213
|
+
Configuration metadata is stored in `~/.claude-dev-cli/config.json` (API keys are NOT in this file):
|
|
189
214
|
|
|
190
215
|
```json
|
|
191
216
|
{
|
|
192
217
|
"api_configs": [
|
|
193
218
|
{
|
|
194
219
|
"name": "personal",
|
|
195
|
-
"api_key": "
|
|
220
|
+
"api_key": "", // Empty - actual key in secure storage
|
|
196
221
|
"description": "My personal API key",
|
|
197
222
|
"default": true
|
|
198
223
|
},
|
|
199
224
|
{
|
|
200
225
|
"name": "client",
|
|
201
|
-
"api_key": "
|
|
226
|
+
"api_key": "", // Empty - actual key in secure storage
|
|
202
227
|
"description": "Client's Enterprise API",
|
|
203
228
|
"default": false
|
|
204
229
|
}
|
|
@@ -5,9 +5,10 @@ A powerful command-line tool for developers using Claude AI with multi-API routi
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
### 🔑 Multi-API Key Management
|
|
8
|
+
- **Secure Storage**: API keys stored in system keyring (macOS Keychain, Linux Secret Service, Windows Credential Locker)
|
|
8
9
|
- Route tasks to different Claude API keys (personal, client, enterprise)
|
|
9
10
|
- Automatic API selection based on project configuration
|
|
10
|
-
-
|
|
11
|
+
- Automatic migration from plaintext to secure storage
|
|
11
12
|
|
|
12
13
|
### 🧪 Developer Tools
|
|
13
14
|
- **Test Generation**: Automatic pytest test generation for Python code
|
|
@@ -57,13 +58,17 @@ pip install claude-dev-cli[toon]
|
|
|
57
58
|
# Add your personal API key
|
|
58
59
|
export PERSONAL_ANTHROPIC_API_KEY="sk-ant-..."
|
|
59
60
|
cdc config add personal --default --description "My personal API key"
|
|
61
|
+
# 🔐 Stored securely in system keyring
|
|
60
62
|
|
|
61
63
|
# Add client's API key
|
|
62
64
|
export CLIENT_ANTHROPIC_API_KEY="sk-ant-..."
|
|
63
65
|
cdc config add client --description "Client's Enterprise API"
|
|
64
66
|
|
|
65
|
-
# List configured APIs
|
|
67
|
+
# List configured APIs (shows storage method)
|
|
66
68
|
cdc config list
|
|
69
|
+
|
|
70
|
+
# Manually migrate existing keys (automatic on first run)
|
|
71
|
+
cdc config migrate-keys
|
|
67
72
|
```
|
|
68
73
|
|
|
69
74
|
### 2. Basic Usage
|
|
@@ -145,22 +150,37 @@ cat large_data.json | cdc toon encode | cdc ask "analyze this data"
|
|
|
145
150
|
|
|
146
151
|
## Configuration
|
|
147
152
|
|
|
153
|
+
### Secure API Key Storage
|
|
154
|
+
|
|
155
|
+
**🔐 Your API keys are stored securely and never in plain text.**
|
|
156
|
+
|
|
157
|
+
- **macOS**: Keychain
|
|
158
|
+
- **Linux**: Secret Service API (GNOME Keyring, KWallet)
|
|
159
|
+
- **Windows**: Windows Credential Locker
|
|
160
|
+
- **Fallback**: Encrypted file (if keyring unavailable)
|
|
161
|
+
|
|
162
|
+
Keys are automatically migrated from plaintext on first run. You can also manually migrate:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
cdc config migrate-keys
|
|
166
|
+
```
|
|
167
|
+
|
|
148
168
|
### Global Configuration
|
|
149
169
|
|
|
150
|
-
Configuration is stored in `~/.claude-dev-cli/config.json
|
|
170
|
+
Configuration metadata is stored in `~/.claude-dev-cli/config.json` (API keys are NOT in this file):
|
|
151
171
|
|
|
152
172
|
```json
|
|
153
173
|
{
|
|
154
174
|
"api_configs": [
|
|
155
175
|
{
|
|
156
176
|
"name": "personal",
|
|
157
|
-
"api_key": "
|
|
177
|
+
"api_key": "", // Empty - actual key in secure storage
|
|
158
178
|
"description": "My personal API key",
|
|
159
179
|
"default": true
|
|
160
180
|
},
|
|
161
181
|
{
|
|
162
182
|
"name": "client",
|
|
163
|
-
"api_key": "
|
|
183
|
+
"api_key": "", // Empty - actual key in secure storage
|
|
164
184
|
"description": "Client's Enterprise API",
|
|
165
185
|
"default": false
|
|
166
186
|
}
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "claude-dev-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "A powerful CLI tool for developers using Claude AI with multi-API routing, test generation, code review, and usage tracking"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -32,18 +32,24 @@ dependencies = [
|
|
|
32
32
|
"click>=8.1.0",
|
|
33
33
|
"rich>=13.0.0",
|
|
34
34
|
"pydantic>=2.0.0",
|
|
35
|
+
"keyring>=24.0.0",
|
|
36
|
+
"cryptography>=41.0.0",
|
|
35
37
|
]
|
|
36
38
|
|
|
37
39
|
[project.optional-dependencies]
|
|
38
40
|
toon = [
|
|
39
41
|
"toon-format>=0.9.0",
|
|
40
42
|
]
|
|
43
|
+
plugins = [
|
|
44
|
+
"pygments>=2.0.0",
|
|
45
|
+
]
|
|
41
46
|
dev = [
|
|
42
47
|
"pytest>=7.0.0",
|
|
43
48
|
"black>=23.0.0",
|
|
44
49
|
"ruff>=0.1.0",
|
|
45
50
|
"mypy>=1.0.0",
|
|
46
51
|
"toon-format>=0.9.0",
|
|
52
|
+
"pygments>=2.0.0",
|
|
47
53
|
]
|
|
48
54
|
|
|
49
55
|
[project.urls]
|
|
@@ -182,6 +182,49 @@ def config_add(
|
|
|
182
182
|
make_default=default
|
|
183
183
|
)
|
|
184
184
|
console.print(f"[green]✓[/green] Added API config: {name}")
|
|
185
|
+
|
|
186
|
+
# Show storage method
|
|
187
|
+
storage_method = config.secure_storage.get_storage_method()
|
|
188
|
+
if storage_method == "keyring":
|
|
189
|
+
console.print("[dim]🔐 Stored securely in system keyring[/dim]")
|
|
190
|
+
else:
|
|
191
|
+
console.print("[dim]🔒 Stored in encrypted file (keyring unavailable)[/dim]")
|
|
192
|
+
except Exception as e:
|
|
193
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
194
|
+
sys.exit(1)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@config.command('migrate-keys')
|
|
198
|
+
@click.pass_context
|
|
199
|
+
def config_migrate_keys(ctx: click.Context) -> None:
|
|
200
|
+
"""Manually migrate API keys to secure storage."""
|
|
201
|
+
console = ctx.obj['console']
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
config = Config()
|
|
205
|
+
|
|
206
|
+
# Check if any keys need migration
|
|
207
|
+
api_configs = config._data.get("api_configs", [])
|
|
208
|
+
plaintext_keys = {c["name"]: c.get("api_key", "")
|
|
209
|
+
for c in api_configs
|
|
210
|
+
if c.get("api_key")}
|
|
211
|
+
|
|
212
|
+
if not plaintext_keys:
|
|
213
|
+
console.print("[green]✓[/green] All keys are already in secure storage.")
|
|
214
|
+
storage_method = config.secure_storage.get_storage_method()
|
|
215
|
+
console.print(f"[dim]Using: {storage_method}[/dim]")
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
console.print(f"[yellow]Found {len(plaintext_keys)} plaintext key(s)[/yellow]")
|
|
219
|
+
console.print("Migrating to secure storage...\n")
|
|
220
|
+
|
|
221
|
+
# Trigger migration
|
|
222
|
+
config._auto_migrate_keys()
|
|
223
|
+
|
|
224
|
+
console.print(f"[green]✓[/green] Migrated {len(plaintext_keys)} key(s) to secure storage.")
|
|
225
|
+
storage_method = config.secure_storage.get_storage_method()
|
|
226
|
+
console.print(f"[dim]Using: {storage_method}[/dim]")
|
|
227
|
+
|
|
185
228
|
except Exception as e:
|
|
186
229
|
console.print(f"[red]Error: {e}[/red]")
|
|
187
230
|
sys.exit(1)
|
|
@@ -201,6 +244,14 @@ def config_list(ctx: click.Context) -> None:
|
|
|
201
244
|
console.print("Run 'cdc config add' to add one.")
|
|
202
245
|
return
|
|
203
246
|
|
|
247
|
+
# Show storage method
|
|
248
|
+
storage_method = config.secure_storage.get_storage_method()
|
|
249
|
+
storage_display = {
|
|
250
|
+
"keyring": "🔐 System Keyring (Secure)",
|
|
251
|
+
"encrypted_file": "🔒 Encrypted File (Fallback)"
|
|
252
|
+
}.get(storage_method, storage_method)
|
|
253
|
+
console.print(f"\n[dim]Storage: {storage_display}[/dim]\n")
|
|
254
|
+
|
|
204
255
|
for cfg in api_configs:
|
|
205
256
|
default_marker = " [bold green](default)[/bold green]" if cfg.default else ""
|
|
206
257
|
console.print(f"• {cfg.name}{default_marker}")
|
|
@@ -6,6 +6,8 @@ from pathlib import Path
|
|
|
6
6
|
from typing import Dict, Optional, List
|
|
7
7
|
from pydantic import BaseModel, Field
|
|
8
8
|
|
|
9
|
+
from claude_dev_cli.secure_storage import SecureStorage
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
class APIConfig(BaseModel):
|
|
11
13
|
"""Configuration for a Claude API key."""
|
|
@@ -28,17 +30,23 @@ class ProjectProfile(BaseModel):
|
|
|
28
30
|
class Config:
|
|
29
31
|
"""Manages configuration for Claude Dev CLI."""
|
|
30
32
|
|
|
31
|
-
CONFIG_DIR = Path.home() / ".claude-dev-cli"
|
|
32
|
-
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
33
|
-
USAGE_LOG = CONFIG_DIR / "usage.jsonl"
|
|
34
|
-
|
|
35
33
|
def __init__(self) -> None:
|
|
36
34
|
"""Initialize configuration."""
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
# Determine home directory (respects HOME env var for testing)
|
|
36
|
+
home = Path(os.environ.get("HOME", str(Path.home())))
|
|
37
|
+
|
|
38
|
+
self.config_dir = home / ".claude-dev-cli"
|
|
39
|
+
self.config_file = self.config_dir / "config.json"
|
|
40
|
+
self.usage_log = self.config_dir / "usage.jsonl"
|
|
41
|
+
|
|
40
42
|
self._ensure_config_dir()
|
|
41
43
|
self._data: Dict = self._load_config()
|
|
44
|
+
|
|
45
|
+
# Initialize secure storage
|
|
46
|
+
self.secure_storage = SecureStorage(self.config_dir)
|
|
47
|
+
|
|
48
|
+
# Auto-migrate if plaintext keys exist
|
|
49
|
+
self._auto_migrate_keys()
|
|
42
50
|
|
|
43
51
|
def _ensure_config_dir(self) -> None:
|
|
44
52
|
"""Ensure configuration directory exists."""
|
|
@@ -68,6 +76,22 @@ class Config:
|
|
|
68
76
|
with open(self.config_file, 'w') as f:
|
|
69
77
|
json.dump(data, f, indent=2)
|
|
70
78
|
|
|
79
|
+
def _auto_migrate_keys(self) -> None:
|
|
80
|
+
"""Automatically migrate plaintext API keys to secure storage."""
|
|
81
|
+
api_configs = self._data.get("api_configs", [])
|
|
82
|
+
migrated = False
|
|
83
|
+
|
|
84
|
+
for config in api_configs:
|
|
85
|
+
if "api_key" in config and config["api_key"]:
|
|
86
|
+
# Migrate this key to secure storage
|
|
87
|
+
self.secure_storage.store_key(config["name"], config["api_key"])
|
|
88
|
+
# Remove from plaintext config
|
|
89
|
+
config["api_key"] = "" # Empty string indicates key is in secure storage
|
|
90
|
+
migrated = True
|
|
91
|
+
|
|
92
|
+
if migrated:
|
|
93
|
+
self._save_config()
|
|
94
|
+
|
|
71
95
|
def add_api_config(
|
|
72
96
|
self,
|
|
73
97
|
name: str,
|
|
@@ -91,14 +115,18 @@ class Config:
|
|
|
91
115
|
if config["name"] == name:
|
|
92
116
|
raise ValueError(f"API config with name '{name}' already exists")
|
|
93
117
|
|
|
118
|
+
# Store API key in secure storage
|
|
119
|
+
self.secure_storage.store_key(name, api_key)
|
|
120
|
+
|
|
94
121
|
# If this is the first config or make_default is True, set as default
|
|
95
122
|
if make_default or not api_configs:
|
|
96
123
|
for config in api_configs:
|
|
97
124
|
config["default"] = False
|
|
98
125
|
|
|
126
|
+
# Store metadata without the actual key (empty string indicates secure storage)
|
|
99
127
|
api_config = APIConfig(
|
|
100
128
|
name=name,
|
|
101
|
-
api_key=
|
|
129
|
+
api_key="", # Empty string indicates key is in secure storage
|
|
102
130
|
description=description,
|
|
103
131
|
default=make_default or not api_configs
|
|
104
132
|
)
|
|
@@ -111,21 +139,53 @@ class Config:
|
|
|
111
139
|
"""Get API configuration by name or default."""
|
|
112
140
|
api_configs = self._data.get("api_configs", [])
|
|
113
141
|
|
|
142
|
+
config_data = None
|
|
114
143
|
if name:
|
|
115
144
|
for config in api_configs:
|
|
116
145
|
if config["name"] == name:
|
|
117
|
-
|
|
146
|
+
config_data = config
|
|
147
|
+
break
|
|
118
148
|
else:
|
|
119
149
|
# Return default
|
|
120
150
|
for config in api_configs:
|
|
121
151
|
if config.get("default", False):
|
|
122
|
-
|
|
152
|
+
config_data = config
|
|
153
|
+
break
|
|
123
154
|
|
|
124
|
-
|
|
155
|
+
if not config_data:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
# Retrieve actual API key from secure storage
|
|
159
|
+
api_key = self.secure_storage.get_key(config_data["name"])
|
|
160
|
+
if not api_key:
|
|
161
|
+
# Fallback to plaintext if not in secure storage (shouldn't happen after migration)
|
|
162
|
+
api_key = config_data.get("api_key", "")
|
|
163
|
+
|
|
164
|
+
# Return config with actual key
|
|
165
|
+
return APIConfig(
|
|
166
|
+
name=config_data["name"],
|
|
167
|
+
api_key=api_key,
|
|
168
|
+
description=config_data.get("description"),
|
|
169
|
+
default=config_data.get("default", False)
|
|
170
|
+
)
|
|
125
171
|
|
|
126
172
|
def list_api_configs(self) -> List[APIConfig]:
|
|
127
173
|
"""List all API configurations."""
|
|
128
|
-
|
|
174
|
+
configs = []
|
|
175
|
+
for c in self._data.get("api_configs", []):
|
|
176
|
+
# Retrieve actual API key from secure storage
|
|
177
|
+
api_key = self.secure_storage.get_key(c["name"])
|
|
178
|
+
if not api_key:
|
|
179
|
+
# Fallback to plaintext
|
|
180
|
+
api_key = c.get("api_key", "")
|
|
181
|
+
|
|
182
|
+
configs.append(APIConfig(
|
|
183
|
+
name=c["name"],
|
|
184
|
+
api_key=api_key,
|
|
185
|
+
description=c.get("description"),
|
|
186
|
+
default=c.get("default", False)
|
|
187
|
+
))
|
|
188
|
+
return configs
|
|
129
189
|
|
|
130
190
|
def add_project_profile(
|
|
131
191
|
self,
|
|
@@ -13,13 +13,21 @@ class ClaudeClient:
|
|
|
13
13
|
"""Claude API client with multi-key routing and usage tracking."""
|
|
14
14
|
|
|
15
15
|
def __init__(self, config: Optional[Config] = None, api_config_name: Optional[str] = None):
|
|
16
|
-
"""Initialize Claude client.
|
|
16
|
+
"""Initialize Claude client.
|
|
17
|
+
|
|
18
|
+
API routing hierarchy (highest to lowest priority):
|
|
19
|
+
1. Explicit api_config_name parameter
|
|
20
|
+
2. Project-specific .claude-dev-cli file
|
|
21
|
+
3. Default API config
|
|
22
|
+
"""
|
|
17
23
|
self.config = config or Config()
|
|
18
24
|
|
|
19
|
-
# Determine which API config to use
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
# Determine which API config to use based on hierarchy
|
|
26
|
+
if not api_config_name:
|
|
27
|
+
# Check for project profile if no explicit config provided
|
|
28
|
+
project_profile = self.config.get_project_profile()
|
|
29
|
+
if project_profile:
|
|
30
|
+
api_config_name = project_profile.api_config
|
|
23
31
|
|
|
24
32
|
self.api_config = self.config.get_api_config(api_config_name)
|
|
25
33
|
if not self.api_config:
|
{claude_dev_cli-0.3.0 → claude_dev_cli-0.4.0}/src/claude_dev_cli/plugins/diff_editor/viewer.py
RENAMED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Interactive diff viewer with multiple keybinding modes."""
|
|
2
2
|
|
|
3
3
|
import difflib
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
4
6
|
from pathlib import Path
|
|
5
7
|
from typing import List, Optional, Tuple
|
|
6
8
|
import os
|
|
@@ -11,6 +13,13 @@ from rich.syntax import Syntax
|
|
|
11
13
|
from rich.table import Table
|
|
12
14
|
from rich.text import Text
|
|
13
15
|
|
|
16
|
+
try:
|
|
17
|
+
from pygments import lexers
|
|
18
|
+
from pygments.util import ClassNotFound
|
|
19
|
+
PYGMENTS_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
PYGMENTS_AVAILABLE = False
|
|
22
|
+
|
|
14
23
|
|
|
15
24
|
class Hunk:
|
|
16
25
|
"""Represents a single diff hunk."""
|
|
@@ -69,6 +78,12 @@ class DiffViewer:
|
|
|
69
78
|
self.hunks = self._generate_hunks()
|
|
70
79
|
self.current_hunk_idx = 0
|
|
71
80
|
self.filename = proposed_path.name
|
|
81
|
+
|
|
82
|
+
# Detect lexer for syntax highlighting
|
|
83
|
+
self.lexer_name = self._detect_lexer()
|
|
84
|
+
|
|
85
|
+
# History stack for undo support
|
|
86
|
+
self.history: List[Tuple[int, Optional[bool]]] = []
|
|
72
87
|
|
|
73
88
|
def _detect_keybinding_mode(self) -> str:
|
|
74
89
|
"""Auto-detect keybinding preference from environment."""
|
|
@@ -80,6 +95,17 @@ class DiffViewer:
|
|
|
80
95
|
return "nvim"
|
|
81
96
|
return "fresh"
|
|
82
97
|
|
|
98
|
+
def _detect_lexer(self) -> Optional[str]:
|
|
99
|
+
"""Detect appropriate lexer for syntax highlighting based on filename."""
|
|
100
|
+
if not PYGMENTS_AVAILABLE:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
lexer = lexers.get_lexer_for_filename(str(self.proposed_path))
|
|
105
|
+
return lexer.name
|
|
106
|
+
except ClassNotFound:
|
|
107
|
+
return None
|
|
108
|
+
|
|
83
109
|
def _generate_hunks(self) -> List[Hunk]:
|
|
84
110
|
"""Generate hunks from diff."""
|
|
85
111
|
hunks = []
|
|
@@ -199,14 +225,28 @@ class DiffViewer:
|
|
|
199
225
|
# Show original (if any deletions)
|
|
200
226
|
if hunk.original_lines:
|
|
201
227
|
self.console.print("\n[bold red]━━━ Original (-):[/bold red]")
|
|
202
|
-
|
|
203
|
-
|
|
228
|
+
code = "".join(hunk.original_lines)
|
|
229
|
+
if self.lexer_name and code.strip():
|
|
230
|
+
syntax = Syntax(code, self.lexer_name, theme="monokai", line_numbers=False)
|
|
231
|
+
# Wrap in red for deletions
|
|
232
|
+
for line in code.splitlines():
|
|
233
|
+
self.console.print(f"[red]- {line}[/red]")
|
|
234
|
+
else:
|
|
235
|
+
for line in hunk.original_lines:
|
|
236
|
+
self.console.print(f"[red]- {line}[/red]", end="")
|
|
204
237
|
|
|
205
238
|
# Show proposed (if any additions)
|
|
206
239
|
if hunk.proposed_lines:
|
|
207
240
|
self.console.print("\n[bold green]━━━ Proposed (+):[/bold green]")
|
|
208
|
-
|
|
209
|
-
|
|
241
|
+
code = "".join(hunk.proposed_lines)
|
|
242
|
+
if self.lexer_name and code.strip():
|
|
243
|
+
syntax = Syntax(code, self.lexer_name, theme="monokai", line_numbers=False)
|
|
244
|
+
# Wrap in green for additions
|
|
245
|
+
for line in code.splitlines():
|
|
246
|
+
self.console.print(f"[green]+ {line}[/green]")
|
|
247
|
+
else:
|
|
248
|
+
for line in hunk.proposed_lines:
|
|
249
|
+
self.console.print(f"[green]+ {line}[/green]", end="")
|
|
210
250
|
|
|
211
251
|
# Context
|
|
212
252
|
context = hunk.get_context()
|
|
@@ -233,6 +273,77 @@ class DiffViewer:
|
|
|
233
273
|
|
|
234
274
|
self.console.print(prompt)
|
|
235
275
|
|
|
276
|
+
def _edit_hunk(self, hunk: Hunk) -> Optional[List[str]]:
|
|
277
|
+
"""Open hunk in editor for inline editing.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Edited lines or None if cancelled
|
|
281
|
+
"""
|
|
282
|
+
# Get editor from environment
|
|
283
|
+
editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "nano"))
|
|
284
|
+
|
|
285
|
+
# Create temp file with proposed lines
|
|
286
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".tmp", delete=False) as f:
|
|
287
|
+
f.write("".join(hunk.proposed_lines))
|
|
288
|
+
temp_path = f.name
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
# Open in editor
|
|
292
|
+
result = subprocess.run([editor, temp_path])
|
|
293
|
+
|
|
294
|
+
if result.returncode == 0:
|
|
295
|
+
# Read edited content
|
|
296
|
+
with open(temp_path) as f:
|
|
297
|
+
content = f.read()
|
|
298
|
+
return content.splitlines(keepends=True)
|
|
299
|
+
else:
|
|
300
|
+
self.console.print("[yellow]Edit cancelled[/yellow]")
|
|
301
|
+
return None
|
|
302
|
+
finally:
|
|
303
|
+
# Clean up temp file
|
|
304
|
+
Path(temp_path).unlink(missing_ok=True)
|
|
305
|
+
|
|
306
|
+
def _split_hunk(self, hunk: Hunk, hunk_idx: int) -> None:
|
|
307
|
+
"""Split a hunk into smaller hunks.
|
|
308
|
+
|
|
309
|
+
For now, splits by line - each line becomes its own hunk.
|
|
310
|
+
More advanced splitting could be added later.
|
|
311
|
+
"""
|
|
312
|
+
if len(hunk.proposed_lines) <= 1 and len(hunk.original_lines) <= 1:
|
|
313
|
+
self.console.print("[yellow]Hunk is too small to split[/yellow]")
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
new_hunks = []
|
|
317
|
+
|
|
318
|
+
# Split proposed lines into individual hunks
|
|
319
|
+
if hunk.proposed_lines:
|
|
320
|
+
for i, line in enumerate(hunk.proposed_lines):
|
|
321
|
+
new_hunks.append(Hunk(
|
|
322
|
+
original_lines=[],
|
|
323
|
+
proposed_lines=[line],
|
|
324
|
+
original_start=hunk.original_start,
|
|
325
|
+
proposed_start=hunk.proposed_start + i
|
|
326
|
+
))
|
|
327
|
+
|
|
328
|
+
# Split original lines into deletions
|
|
329
|
+
if hunk.original_lines and not hunk.proposed_lines:
|
|
330
|
+
for i, line in enumerate(hunk.original_lines):
|
|
331
|
+
new_hunks.append(Hunk(
|
|
332
|
+
original_lines=[line],
|
|
333
|
+
proposed_lines=[],
|
|
334
|
+
original_start=hunk.original_start + i,
|
|
335
|
+
proposed_start=hunk.proposed_start
|
|
336
|
+
))
|
|
337
|
+
|
|
338
|
+
if new_hunks:
|
|
339
|
+
# Replace current hunk with split hunks
|
|
340
|
+
self.hunks = (
|
|
341
|
+
self.hunks[:hunk_idx] +
|
|
342
|
+
new_hunks +
|
|
343
|
+
self.hunks[hunk_idx + 1:]
|
|
344
|
+
)
|
|
345
|
+
self.console.print(f"[green]✓ Split into {len(new_hunks)} hunks[/green]")
|
|
346
|
+
|
|
236
347
|
def run(self) -> Optional[str]:
|
|
237
348
|
"""Run the interactive diff viewer.
|
|
238
349
|
|
|
@@ -258,16 +369,30 @@ class DiffViewer:
|
|
|
258
369
|
|
|
259
370
|
# Process choice
|
|
260
371
|
if choice in kb["accept"]:
|
|
372
|
+
# Save history before changing
|
|
373
|
+
self.history.append((self.current_hunk_idx, hunk.accepted))
|
|
261
374
|
hunk.accepted = True
|
|
262
375
|
self.current_hunk_idx += 1
|
|
263
376
|
elif choice in kb["reject"]:
|
|
377
|
+
# Save history before changing
|
|
378
|
+
self.history.append((self.current_hunk_idx, hunk.accepted))
|
|
264
379
|
hunk.accepted = False
|
|
265
380
|
self.current_hunk_idx += 1
|
|
266
381
|
elif choice in kb["edit"]:
|
|
267
|
-
|
|
268
|
-
self.
|
|
382
|
+
# Enter edit mode for this hunk
|
|
383
|
+
edited_lines = self._edit_hunk(hunk)
|
|
384
|
+
if edited_lines is not None:
|
|
385
|
+
# Save history
|
|
386
|
+
self.history.append((self.current_hunk_idx, hunk.accepted))
|
|
387
|
+
# Update hunk with edited content
|
|
388
|
+
hunk.proposed_lines = edited_lines
|
|
389
|
+
hunk.accepted = True
|
|
390
|
+
self.console.print("[green]✓ Hunk updated with edits[/green]")
|
|
391
|
+
self.console.input("Press Enter to continue...")
|
|
392
|
+
self.current_hunk_idx += 1
|
|
269
393
|
elif choice in kb["split"]:
|
|
270
|
-
|
|
394
|
+
# Split current hunk
|
|
395
|
+
self._split_hunk(hunk, self.current_hunk_idx)
|
|
271
396
|
self.console.input("Press Enter to continue...")
|
|
272
397
|
elif choice in kb["next"]:
|
|
273
398
|
if self.current_hunk_idx < len(self.hunks) - 1:
|
|
@@ -283,6 +408,17 @@ class DiffViewer:
|
|
|
283
408
|
for h in self.hunks[self.current_hunk_idx:]:
|
|
284
409
|
h.accepted = False
|
|
285
410
|
break
|
|
411
|
+
elif choice in kb["undo"]:
|
|
412
|
+
if self.history:
|
|
413
|
+
# Restore previous state
|
|
414
|
+
hunk_idx, prev_state = self.history.pop()
|
|
415
|
+
self.hunks[hunk_idx].accepted = prev_state
|
|
416
|
+
self.current_hunk_idx = hunk_idx
|
|
417
|
+
self.console.print("[yellow]↶ Undone last action[/yellow]")
|
|
418
|
+
self.console.input("Press Enter to continue...")
|
|
419
|
+
else:
|
|
420
|
+
self.console.print("[yellow]No actions to undo[/yellow]")
|
|
421
|
+
self.console.input("Press Enter to continue...")
|
|
286
422
|
elif choice in kb["quit"]:
|
|
287
423
|
self.console.print("[yellow]Quitting without applying changes[/yellow]")
|
|
288
424
|
return None
|