claude-dev-cli 0.3.0__py3-none-any.whl → 0.4.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.

claude_dev_cli/cli.py CHANGED
@@ -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}")
claude_dev_cli/config.py CHANGED
@@ -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
- self.config_dir = self.CONFIG_DIR
38
- self.config_file = self.CONFIG_FILE
39
- self.usage_log = self.USAGE_LOG
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=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
- return APIConfig(**config)
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
- return APIConfig(**config)
152
+ config_data = config
153
+ break
123
154
 
124
- return None
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
- return [APIConfig(**c) for c in self._data.get("api_configs", [])]
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,
claude_dev_cli/core.py CHANGED
@@ -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
- project_profile = self.config.get_project_profile()
21
- if project_profile:
22
- api_config_name = project_profile.api_config
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:
@@ -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
- for line in hunk.original_lines:
203
- self.console.print(f"[red]- {line}[/red]", end="")
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
- for line in hunk.proposed_lines:
209
- self.console.print(f"[green]+ {line}[/green]", end="")
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
- self.console.print("[yellow]Edit mode not yet implemented[/yellow]")
268
- self.console.input("Press Enter to continue...")
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
- self.console.print("[yellow]Split mode not yet implemented[/yellow]")
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
@@ -0,0 +1,219 @@
1
+ """Secure storage for API keys using system keyring.
2
+
3
+ Supports:
4
+ - macOS: Keychain
5
+ - Linux: Secret Service API (GNOME Keyring, KWallet)
6
+ - Windows: Windows Credential Locker
7
+
8
+ Falls back to encrypted file storage if keyring is unavailable.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ try:
17
+ import keyring
18
+ from keyring.errors import KeyringError
19
+ KEYRING_AVAILABLE = True
20
+ except ImportError:
21
+ KEYRING_AVAILABLE = False
22
+
23
+ from cryptography.fernet import Fernet
24
+
25
+
26
+ class SecureStorage:
27
+ """Secure storage for API keys with cross-platform support."""
28
+
29
+ SERVICE_NAME = "claude-dev-cli"
30
+
31
+ def __init__(self, config_dir: Path, force_encrypted_file: bool = False):
32
+ """Initialize secure storage.
33
+
34
+ Args:
35
+ config_dir: Directory to store encrypted fallback files
36
+ force_encrypted_file: Force use of encrypted file storage (for testing)
37
+ """
38
+ self.config_dir = config_dir
39
+ self.encrypted_file = config_dir / "keys.enc"
40
+ self.key_file = config_dir / ".keyfile"
41
+
42
+ # Check if we should use keyring (disabled in test environments)
43
+ # Detect test environment by checking for pytest or TESTING env var
44
+ in_test = (
45
+ force_encrypted_file or
46
+ 'pytest' in os.environ.get('_', '') or
47
+ os.environ.get('PYTEST_CURRENT_TEST') is not None or
48
+ os.environ.get('TESTING') == '1'
49
+ )
50
+
51
+ if in_test:
52
+ # Always use encrypted file in tests to avoid Keychain prompts
53
+ self.use_keyring = False
54
+ else:
55
+ # Check if keyring backend is available in production
56
+ self.use_keyring = KEYRING_AVAILABLE and self._test_keyring()
57
+
58
+ if not self.use_keyring:
59
+ # Initialize fallback encryption
60
+ self._ensure_encryption_key()
61
+
62
+ def _test_keyring(self) -> bool:
63
+ """Test if keyring backend is working.
64
+
65
+ Returns:
66
+ True if keyring is functional, False otherwise
67
+ """
68
+ try:
69
+ # Try to get/set a test value
70
+ test_key = f"{self.SERVICE_NAME}_test"
71
+ keyring.set_password(self.SERVICE_NAME, test_key, "test")
72
+ result = keyring.get_password(self.SERVICE_NAME, test_key)
73
+ keyring.delete_password(self.SERVICE_NAME, test_key)
74
+ return result == "test"
75
+ except (KeyringError, Exception):
76
+ return False
77
+
78
+ def _ensure_encryption_key(self) -> None:
79
+ """Ensure encryption key exists for fallback storage."""
80
+ if not self.key_file.exists():
81
+ # Generate a new encryption key
82
+ key = Fernet.generate_key()
83
+ self.key_file.write_bytes(key)
84
+ # Secure the key file (Unix-like systems)
85
+ if hasattr(os, 'chmod'):
86
+ os.chmod(self.key_file, 0o600)
87
+
88
+ def _get_cipher(self) -> Fernet:
89
+ """Get Fernet cipher for fallback encryption."""
90
+ key = self.key_file.read_bytes()
91
+ return Fernet(key)
92
+
93
+ def _load_encrypted_keys(self) -> dict:
94
+ """Load keys from encrypted fallback file."""
95
+ if not self.encrypted_file.exists():
96
+ return {}
97
+
98
+ try:
99
+ cipher = self._get_cipher()
100
+ encrypted_data = self.encrypted_file.read_bytes()
101
+ decrypted_data = cipher.decrypt(encrypted_data)
102
+ return json.loads(decrypted_data.decode())
103
+ except Exception:
104
+ # If decryption fails, return empty dict
105
+ return {}
106
+
107
+ def _save_encrypted_keys(self, keys: dict) -> None:
108
+ """Save keys to encrypted fallback file."""
109
+ cipher = self._get_cipher()
110
+ data = json.dumps(keys).encode()
111
+ encrypted_data = cipher.encrypt(data)
112
+ self.encrypted_file.write_bytes(encrypted_data)
113
+
114
+ # Secure the encrypted file
115
+ if hasattr(os, 'chmod'):
116
+ os.chmod(self.encrypted_file, 0o600)
117
+
118
+ def store_key(self, name: str, api_key: str) -> None:
119
+ """Store an API key securely.
120
+
121
+ Args:
122
+ name: Name/identifier for the API key
123
+ api_key: The API key to store
124
+ """
125
+ if self.use_keyring:
126
+ try:
127
+ keyring.set_password(self.SERVICE_NAME, name, api_key)
128
+ return
129
+ except KeyringError:
130
+ # Fall back to encrypted file if keyring fails
131
+ pass
132
+
133
+ # Use encrypted file storage
134
+ keys = self._load_encrypted_keys()
135
+ keys[name] = api_key
136
+ self._save_encrypted_keys(keys)
137
+
138
+ def get_key(self, name: str) -> Optional[str]:
139
+ """Retrieve an API key.
140
+
141
+ Args:
142
+ name: Name/identifier for the API key
143
+
144
+ Returns:
145
+ The API key or None if not found
146
+ """
147
+ if self.use_keyring:
148
+ try:
149
+ return keyring.get_password(self.SERVICE_NAME, name)
150
+ except KeyringError:
151
+ # Fall back to encrypted file
152
+ pass
153
+
154
+ # Use encrypted file storage
155
+ keys = self._load_encrypted_keys()
156
+ return keys.get(name)
157
+
158
+ def delete_key(self, name: str) -> bool:
159
+ """Delete an API key.
160
+
161
+ Args:
162
+ name: Name/identifier for the API key
163
+
164
+ Returns:
165
+ True if deleted, False if not found
166
+ """
167
+ if self.use_keyring:
168
+ try:
169
+ keyring.delete_password(self.SERVICE_NAME, name)
170
+ return True
171
+ except KeyringError:
172
+ # Fall back to encrypted file
173
+ pass
174
+
175
+ # Use encrypted file storage
176
+ keys = self._load_encrypted_keys()
177
+ if name in keys:
178
+ del keys[name]
179
+ self._save_encrypted_keys(keys)
180
+ return True
181
+ return False
182
+
183
+ def list_keys(self) -> list[str]:
184
+ """List all stored key names.
185
+
186
+ Returns:
187
+ List of key names
188
+ """
189
+ if self.use_keyring:
190
+ # Keyring doesn't provide a list operation
191
+ # We need to maintain a separate index
192
+ # For now, fall through to encrypted file
193
+ pass
194
+
195
+ keys = self._load_encrypted_keys()
196
+ return list(keys.keys())
197
+
198
+ def get_storage_method(self) -> str:
199
+ """Get the current storage method being used.
200
+
201
+ Returns:
202
+ 'keyring' or 'encrypted_file'
203
+ """
204
+ return "keyring" if self.use_keyring else "encrypted_file"
205
+
206
+ def migrate_from_plaintext(self, plaintext_keys: dict[str, str]) -> int:
207
+ """Migrate keys from plaintext config to secure storage.
208
+
209
+ Args:
210
+ plaintext_keys: Dictionary of name -> api_key
211
+
212
+ Returns:
213
+ Number of keys migrated
214
+ """
215
+ count = 0
216
+ for name, api_key in plaintext_keys.items():
217
+ self.store_key(name, api_key)
218
+ count += 1
219
+ return count
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-dev-cli
3
- Version: 0.3.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
- - Environment variable support for secure key management
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": "sk-ant-...",
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": "sk-ant-...",
226
+ "api_key": "", // Empty - actual key in secure storage
202
227
  "description": "Client's Enterprise API",
203
228
  "default": false
204
229
  }
@@ -1,8 +1,9 @@
1
1
  claude_dev_cli/__init__.py,sha256=2ulyIQ3E-s6wBTKyeXAlqHMVA73zUGdaaNUsFiJ-nqs,469
2
- claude_dev_cli/cli.py,sha256=ekJpBU3d0k9DrE5VoJdKGNgPBsmdB4415up_Yhx_fw0,16174
2
+ claude_dev_cli/cli.py,sha256=KeBRudNvKU7RzO_m1w50WyiKwAMEp3U9LNal0R_FEGk,18194
3
3
  claude_dev_cli/commands.py,sha256=RKGx2rv56PM6eErvA2uoQ20hY8babuI5jav8nCUyUOk,3964
4
- claude_dev_cli/config.py,sha256=YwJjVkW9S1O_iq_2O6YCjYtuFWUCmP18zA7esKDwkKU,5776
5
- claude_dev_cli/core.py,sha256=97rR9BuNfnhJxFrd7dTdApGyPh6MeGNArcRmaiOY69I,4443
4
+ claude_dev_cli/config.py,sha256=RGX0sKplHUsrJJmU-4FuWWjoTbQVgWaMT8DgRUofrR4,8134
5
+ claude_dev_cli/core.py,sha256=yaLjEixDvPzvUy4fJ2UB7nMpPPLyKACjR-RuM-1OQBY,4780
6
+ claude_dev_cli/secure_storage.py,sha256=TK3WOaU7a0yTOtzdP_t_28fDRp2lovANNAC6MBdm4nQ,7096
6
7
  claude_dev_cli/templates.py,sha256=lKxH943ySfUKgyHaWa4W3LVv91SgznKgajRtSRp_4UY,2260
7
8
  claude_dev_cli/toon_utils.py,sha256=S3px2UvmNEaltmTa5K-h21n2c0CPvYjZc9mc7kHGqNQ,2828
8
9
  claude_dev_cli/usage.py,sha256=32rs0_dUn6ihha3vCfT3rwnvel_-sED7jvLpO7gu-KQ,7446
@@ -10,10 +11,10 @@ claude_dev_cli/plugins/__init__.py,sha256=BdiZlylBzEgnwK2tuEdn8cITxhAZRVbTnDbWhd
10
11
  claude_dev_cli/plugins/base.py,sha256=H4HQet1I-a3WLCfE9F06Lp8NuFvVoIlou7sIgyJFK-c,1417
11
12
  claude_dev_cli/plugins/diff_editor/__init__.py,sha256=gqR5S2TyIVuq-sK107fegsutQ7Z-sgAIEbtc71FhXIM,101
12
13
  claude_dev_cli/plugins/diff_editor/plugin.py,sha256=M1bUoqpasD3ZNQo36Fu_8g92uySPZyG_ujMbj5UplsU,3073
13
- claude_dev_cli/plugins/diff_editor/viewer.py,sha256=wm8TG-aOrCV0f1NaL-Jwi93UaksfApESQpjmPPRIQTs,11597
14
- claude_dev_cli-0.3.0.dist-info/licenses/LICENSE,sha256=DGueuJwMJtMwgLO5mWlS0TaeBrFwQuNpNZ22PU9J2bw,1062
15
- claude_dev_cli-0.3.0.dist-info/METADATA,sha256=lR6Z4dFk6UaXzPHBwLX9hi7eiKEHaI9I0Hcg1yVCR9w,10325
16
- claude_dev_cli-0.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
17
- claude_dev_cli-0.3.0.dist-info/entry_points.txt,sha256=zymgUIIVpFTARkFmxAuW2A4BQsNITh_L0uU-XunytHg,85
18
- claude_dev_cli-0.3.0.dist-info/top_level.txt,sha256=m7MF6LOIuTe41IT5Fgt0lc-DK1EgM4gUU_IZwWxK0pg,15
19
- claude_dev_cli-0.3.0.dist-info/RECORD,,
14
+ claude_dev_cli/plugins/diff_editor/viewer.py,sha256=1IOXIKw_01ppJx5C1dQt9Kr6U1TdAHT8_iUT5r_q0NM,17169
15
+ claude_dev_cli-0.4.0.dist-info/licenses/LICENSE,sha256=DGueuJwMJtMwgLO5mWlS0TaeBrFwQuNpNZ22PU9J2bw,1062
16
+ claude_dev_cli-0.4.0.dist-info/METADATA,sha256=tfugybDPK3KyZTBcm8PHlEeb_ZidSWtRaKAwIBfIn70,11288
17
+ claude_dev_cli-0.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
18
+ claude_dev_cli-0.4.0.dist-info/entry_points.txt,sha256=zymgUIIVpFTARkFmxAuW2A4BQsNITh_L0uU-XunytHg,85
19
+ claude_dev_cli-0.4.0.dist-info/top_level.txt,sha256=m7MF6LOIuTe41IT5Fgt0lc-DK1EgM4gUU_IZwWxK0pg,15
20
+ claude_dev_cli-0.4.0.dist-info/RECORD,,