claude-dev-cli 0.3.1__py3-none-any.whl → 0.5.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 +228 -1
- claude_dev_cli/config.py +65 -5
- claude_dev_cli/history.py +189 -0
- claude_dev_cli/plugins/diff_editor/viewer.py +86 -3
- claude_dev_cli/secure_storage.py +219 -0
- {claude_dev_cli-0.3.1.dist-info → claude_dev_cli-0.5.0.dist-info}/METADATA +28 -6
- {claude_dev_cli-0.3.1.dist-info → claude_dev_cli-0.5.0.dist-info}/RECORD +11 -9
- {claude_dev_cli-0.3.1.dist-info → claude_dev_cli-0.5.0.dist-info}/WHEEL +0 -0
- {claude_dev_cli-0.3.1.dist-info → claude_dev_cli-0.5.0.dist-info}/entry_points.txt +0 -0
- {claude_dev_cli-0.3.1.dist-info → claude_dev_cli-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {claude_dev_cli-0.3.1.dist-info → claude_dev_cli-0.5.0.dist-info}/top_level.txt +0 -0
claude_dev_cli/cli.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Command-line interface for Claude Dev CLI."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import sys
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Optional
|
|
@@ -23,6 +24,7 @@ from claude_dev_cli.commands import (
|
|
|
23
24
|
from claude_dev_cli.usage import UsageTracker
|
|
24
25
|
from claude_dev_cli import toon_utils
|
|
25
26
|
from claude_dev_cli.plugins import load_plugins
|
|
27
|
+
from claude_dev_cli.history import ConversationHistory, Conversation
|
|
26
28
|
|
|
27
29
|
console = Console()
|
|
28
30
|
|
|
@@ -112,11 +114,36 @@ def ask(
|
|
|
112
114
|
|
|
113
115
|
@main.command()
|
|
114
116
|
@click.option('-a', '--api', help='API config to use')
|
|
117
|
+
@click.option('--continue', 'continue_conversation', is_flag=True,
|
|
118
|
+
help='Continue the last conversation')
|
|
119
|
+
@click.option('--save/--no-save', default=True, help='Save conversation history')
|
|
115
120
|
@click.pass_context
|
|
116
|
-
def interactive(
|
|
121
|
+
def interactive(
|
|
122
|
+
ctx: click.Context,
|
|
123
|
+
api: Optional[str],
|
|
124
|
+
continue_conversation: bool,
|
|
125
|
+
save: bool
|
|
126
|
+
) -> None:
|
|
117
127
|
"""Start interactive chat mode."""
|
|
118
128
|
console = ctx.obj['console']
|
|
119
129
|
|
|
130
|
+
# Setup conversation history
|
|
131
|
+
config = Config()
|
|
132
|
+
history_dir = config.config_dir / "history"
|
|
133
|
+
conv_history = ConversationHistory(history_dir)
|
|
134
|
+
|
|
135
|
+
# Load or create conversation
|
|
136
|
+
if continue_conversation:
|
|
137
|
+
conversation = conv_history.get_latest_conversation()
|
|
138
|
+
if conversation:
|
|
139
|
+
console.print(f"[green]↶ Continuing conversation from {conversation.updated_at.strftime('%Y-%m-%d %H:%M')}[/green]")
|
|
140
|
+
console.print(f"[dim]Messages: {len(conversation.messages)}[/dim]\n")
|
|
141
|
+
else:
|
|
142
|
+
console.print("[yellow]No previous conversation found, starting new one[/yellow]\n")
|
|
143
|
+
conversation = Conversation()
|
|
144
|
+
else:
|
|
145
|
+
conversation = Conversation()
|
|
146
|
+
|
|
120
147
|
console.print(Panel.fit(
|
|
121
148
|
"Claude Dev CLI - Interactive Mode\n"
|
|
122
149
|
"Type 'exit' or 'quit' to end\n"
|
|
@@ -127,30 +154,179 @@ def interactive(ctx: click.Context, api: Optional[str]) -> None:
|
|
|
127
154
|
|
|
128
155
|
try:
|
|
129
156
|
client = ClaudeClient(api_config_name=api)
|
|
157
|
+
response_buffer = []
|
|
130
158
|
|
|
131
159
|
while True:
|
|
132
160
|
try:
|
|
133
161
|
user_input = console.input("\n[bold cyan]You:[/bold cyan] ").strip()
|
|
134
162
|
|
|
135
163
|
if user_input.lower() in ['exit', 'quit']:
|
|
164
|
+
if save and conversation.messages:
|
|
165
|
+
conv_history.save_conversation(conversation)
|
|
166
|
+
console.print(f"\n[dim]💾 Saved conversation: {conversation.conversation_id}[/dim]")
|
|
136
167
|
break
|
|
168
|
+
|
|
169
|
+
if user_input.lower() == 'clear':
|
|
170
|
+
conversation = Conversation()
|
|
171
|
+
console.print("[yellow]Conversation cleared[/yellow]")
|
|
172
|
+
continue
|
|
173
|
+
|
|
137
174
|
if not user_input:
|
|
138
175
|
continue
|
|
139
176
|
|
|
177
|
+
# Add user message to history
|
|
178
|
+
conversation.add_message("user", user_input)
|
|
179
|
+
|
|
180
|
+
# Get response
|
|
140
181
|
console.print("\n[bold green]Claude:[/bold green] ", end='')
|
|
182
|
+
response_buffer = []
|
|
141
183
|
for chunk in client.call_streaming(user_input):
|
|
142
184
|
console.print(chunk, end='')
|
|
185
|
+
response_buffer.append(chunk)
|
|
143
186
|
console.print()
|
|
144
187
|
|
|
188
|
+
# Add assistant response to history
|
|
189
|
+
full_response = ''.join(response_buffer)
|
|
190
|
+
conversation.add_message("assistant", full_response)
|
|
191
|
+
|
|
192
|
+
# Auto-save periodically
|
|
193
|
+
if save and len(conversation.messages) % 10 == 0:
|
|
194
|
+
conv_history.save_conversation(conversation)
|
|
195
|
+
|
|
145
196
|
except KeyboardInterrupt:
|
|
146
197
|
console.print("\n\n[yellow]Interrupted. Type 'exit' to quit.[/yellow]")
|
|
147
198
|
continue
|
|
148
199
|
|
|
149
200
|
except Exception as e:
|
|
150
201
|
console.print(f"[red]Error: {e}[/red]")
|
|
202
|
+
if save and conversation.messages:
|
|
203
|
+
conv_history.save_conversation(conversation)
|
|
151
204
|
sys.exit(1)
|
|
152
205
|
|
|
153
206
|
|
|
207
|
+
@main.group()
|
|
208
|
+
def completion() -> None:
|
|
209
|
+
"""Shell completion installation."""
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@completion.command('install')
|
|
214
|
+
@click.option('--shell', type=click.Choice(['bash', 'zsh', 'fish', 'auto']), default='auto',
|
|
215
|
+
help='Shell type (auto-detects if not specified)')
|
|
216
|
+
@click.pass_context
|
|
217
|
+
def completion_install(ctx: click.Context, shell: str) -> None:
|
|
218
|
+
"""Install shell completion for cdc command."""
|
|
219
|
+
console = ctx.obj['console']
|
|
220
|
+
|
|
221
|
+
# Auto-detect shell if needed
|
|
222
|
+
if shell == 'auto':
|
|
223
|
+
shell_path = os.environ.get('SHELL', '')
|
|
224
|
+
if 'zsh' in shell_path:
|
|
225
|
+
shell = 'zsh'
|
|
226
|
+
elif 'bash' in shell_path:
|
|
227
|
+
shell = 'bash'
|
|
228
|
+
elif 'fish' in shell_path:
|
|
229
|
+
shell = 'fish'
|
|
230
|
+
else:
|
|
231
|
+
console.print("[red]Could not auto-detect shell[/red]")
|
|
232
|
+
console.print("Please specify: --shell bash|zsh|fish")
|
|
233
|
+
sys.exit(1)
|
|
234
|
+
|
|
235
|
+
console.print(f"[cyan]Installing completion for {shell}...[/cyan]\n")
|
|
236
|
+
|
|
237
|
+
if shell == 'zsh':
|
|
238
|
+
console.print("Add this to your ~/.zshrc:\n")
|
|
239
|
+
console.print("[yellow]eval \"$(_CDC_COMPLETE=zsh_source cdc)\"[/yellow]\n")
|
|
240
|
+
console.print("Then run: [cyan]source ~/.zshrc[/cyan]")
|
|
241
|
+
elif shell == 'bash':
|
|
242
|
+
console.print("Add this to your ~/.bashrc:\n")
|
|
243
|
+
console.print("[yellow]eval \"$(_CDC_COMPLETE=bash_source cdc)\"[/yellow]\n")
|
|
244
|
+
console.print("Then run: [cyan]source ~/.bashrc[/cyan]")
|
|
245
|
+
elif shell == 'fish':
|
|
246
|
+
console.print("Add this to ~/.config/fish/completions/cdc.fish:\n")
|
|
247
|
+
console.print("[yellow]_CDC_COMPLETE=fish_source cdc | source[/yellow]\n")
|
|
248
|
+
console.print("Then reload: [cyan]exec fish[/cyan]")
|
|
249
|
+
|
|
250
|
+
console.print("\n[green]✓[/green] Instructions displayed above")
|
|
251
|
+
console.print("[dim]Completion will provide command and option suggestions[/dim]")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@completion.command('generate')
|
|
255
|
+
@click.option('--shell', type=click.Choice(['bash', 'zsh', 'fish']), required=True,
|
|
256
|
+
help='Shell type')
|
|
257
|
+
@click.pass_context
|
|
258
|
+
def completion_generate(ctx: click.Context, shell: str) -> None:
|
|
259
|
+
"""Generate completion script for shell."""
|
|
260
|
+
import subprocess
|
|
261
|
+
import sys
|
|
262
|
+
|
|
263
|
+
env_var = f"_CDC_COMPLETE={shell}_source"
|
|
264
|
+
result = subprocess.run(
|
|
265
|
+
[sys.executable, '-m', 'claude_dev_cli.cli'],
|
|
266
|
+
env={**os.environ, '_CDC_COMPLETE': f'{shell}_source'},
|
|
267
|
+
capture_output=True,
|
|
268
|
+
text=True
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if result.returncode == 0:
|
|
272
|
+
click.echo(result.stdout)
|
|
273
|
+
else:
|
|
274
|
+
ctx.obj['console'].print(f"[red]Error generating completion: {result.stderr}[/red]")
|
|
275
|
+
sys.exit(1)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@main.group()
|
|
279
|
+
def history() -> None:
|
|
280
|
+
"""Manage conversation history."""
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@history.command('list')
|
|
285
|
+
@click.option('-n', '--limit', type=int, default=10, help='Number of conversations to show')
|
|
286
|
+
@click.option('-s', '--search', help='Search conversations')
|
|
287
|
+
@click.pass_context
|
|
288
|
+
def history_list(ctx: click.Context, limit: int, search: Optional[str]) -> None:
|
|
289
|
+
"""List conversation history."""
|
|
290
|
+
console = ctx.obj['console']
|
|
291
|
+
config = Config()
|
|
292
|
+
conv_history = ConversationHistory(config.config_dir / "history")
|
|
293
|
+
|
|
294
|
+
conversations = conv_history.list_conversations(limit=limit, search_query=search)
|
|
295
|
+
|
|
296
|
+
if not conversations:
|
|
297
|
+
console.print("[yellow]No conversations found[/yellow]")
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
for conv in conversations:
|
|
301
|
+
summary = conv.get_summary(80)
|
|
302
|
+
console.print(f"\n[cyan]{conv.conversation_id}[/cyan]")
|
|
303
|
+
console.print(f"[dim]{conv.updated_at.strftime('%Y-%m-%d %H:%M')} | {len(conv.messages)} messages[/dim]")
|
|
304
|
+
console.print(f" {summary}")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@history.command('export')
|
|
308
|
+
@click.argument('conversation_id')
|
|
309
|
+
@click.option('--format', type=click.Choice(['markdown', 'json']), default='markdown')
|
|
310
|
+
@click.option('-o', '--output', type=click.Path(), help='Output file')
|
|
311
|
+
@click.pass_context
|
|
312
|
+
def history_export(ctx: click.Context, conversation_id: str, format: str, output: Optional[str]) -> None:
|
|
313
|
+
"""Export a conversation."""
|
|
314
|
+
console = ctx.obj['console']
|
|
315
|
+
config = Config()
|
|
316
|
+
conv_history = ConversationHistory(config.config_dir / "history")
|
|
317
|
+
|
|
318
|
+
content = conv_history.export_conversation(conversation_id, format)
|
|
319
|
+
if not content:
|
|
320
|
+
console.print(f"[red]Conversation {conversation_id} not found[/red]")
|
|
321
|
+
sys.exit(1)
|
|
322
|
+
|
|
323
|
+
if output:
|
|
324
|
+
Path(output).write_text(content)
|
|
325
|
+
console.print(f"[green]✓[/green] Exported to {output}")
|
|
326
|
+
else:
|
|
327
|
+
click.echo(content)
|
|
328
|
+
|
|
329
|
+
|
|
154
330
|
@main.group()
|
|
155
331
|
def config() -> None:
|
|
156
332
|
"""Manage configuration."""
|
|
@@ -182,6 +358,49 @@ def config_add(
|
|
|
182
358
|
make_default=default
|
|
183
359
|
)
|
|
184
360
|
console.print(f"[green]✓[/green] Added API config: {name}")
|
|
361
|
+
|
|
362
|
+
# Show storage method
|
|
363
|
+
storage_method = config.secure_storage.get_storage_method()
|
|
364
|
+
if storage_method == "keyring":
|
|
365
|
+
console.print("[dim]🔐 Stored securely in system keyring[/dim]")
|
|
366
|
+
else:
|
|
367
|
+
console.print("[dim]🔒 Stored in encrypted file (keyring unavailable)[/dim]")
|
|
368
|
+
except Exception as e:
|
|
369
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
370
|
+
sys.exit(1)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@config.command('migrate-keys')
|
|
374
|
+
@click.pass_context
|
|
375
|
+
def config_migrate_keys(ctx: click.Context) -> None:
|
|
376
|
+
"""Manually migrate API keys to secure storage."""
|
|
377
|
+
console = ctx.obj['console']
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
config = Config()
|
|
381
|
+
|
|
382
|
+
# Check if any keys need migration
|
|
383
|
+
api_configs = config._data.get("api_configs", [])
|
|
384
|
+
plaintext_keys = {c["name"]: c.get("api_key", "")
|
|
385
|
+
for c in api_configs
|
|
386
|
+
if c.get("api_key")}
|
|
387
|
+
|
|
388
|
+
if not plaintext_keys:
|
|
389
|
+
console.print("[green]✓[/green] All keys are already in secure storage.")
|
|
390
|
+
storage_method = config.secure_storage.get_storage_method()
|
|
391
|
+
console.print(f"[dim]Using: {storage_method}[/dim]")
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
console.print(f"[yellow]Found {len(plaintext_keys)} plaintext key(s)[/yellow]")
|
|
395
|
+
console.print("Migrating to secure storage...\n")
|
|
396
|
+
|
|
397
|
+
# Trigger migration
|
|
398
|
+
config._auto_migrate_keys()
|
|
399
|
+
|
|
400
|
+
console.print(f"[green]✓[/green] Migrated {len(plaintext_keys)} key(s) to secure storage.")
|
|
401
|
+
storage_method = config.secure_storage.get_storage_method()
|
|
402
|
+
console.print(f"[dim]Using: {storage_method}[/dim]")
|
|
403
|
+
|
|
185
404
|
except Exception as e:
|
|
186
405
|
console.print(f"[red]Error: {e}[/red]")
|
|
187
406
|
sys.exit(1)
|
|
@@ -201,6 +420,14 @@ def config_list(ctx: click.Context) -> None:
|
|
|
201
420
|
console.print("Run 'cdc config add' to add one.")
|
|
202
421
|
return
|
|
203
422
|
|
|
423
|
+
# Show storage method
|
|
424
|
+
storage_method = config.secure_storage.get_storage_method()
|
|
425
|
+
storage_display = {
|
|
426
|
+
"keyring": "🔐 System Keyring (Secure)",
|
|
427
|
+
"encrypted_file": "🔒 Encrypted File (Fallback)"
|
|
428
|
+
}.get(storage_method, storage_method)
|
|
429
|
+
console.print(f"\n[dim]Storage: {storage_display}[/dim]\n")
|
|
430
|
+
|
|
204
431
|
for cfg in api_configs:
|
|
205
432
|
default_marker = " [bold green](default)[/bold green]" if cfg.default else ""
|
|
206
433
|
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."""
|
|
@@ -39,6 +41,12 @@ class Config:
|
|
|
39
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,
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Conversation history management for interactive mode."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional, Dict, Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Message:
|
|
10
|
+
"""Represents a single message in a conversation."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, role: str, content: str, timestamp: Optional[datetime] = None):
|
|
13
|
+
self.role = role # "user" or "assistant"
|
|
14
|
+
self.content = content
|
|
15
|
+
self.timestamp = timestamp or datetime.utcnow()
|
|
16
|
+
|
|
17
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
18
|
+
"""Convert to dictionary for storage."""
|
|
19
|
+
return {
|
|
20
|
+
"role": self.role,
|
|
21
|
+
"content": self.content,
|
|
22
|
+
"timestamp": self.timestamp.isoformat()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Message":
|
|
27
|
+
"""Create from dictionary."""
|
|
28
|
+
return cls(
|
|
29
|
+
role=data["role"],
|
|
30
|
+
content=data["content"],
|
|
31
|
+
timestamp=datetime.fromisoformat(data["timestamp"])
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Conversation:
|
|
36
|
+
"""Represents a conversation with messages."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
conversation_id: Optional[str] = None,
|
|
41
|
+
created_at: Optional[datetime] = None,
|
|
42
|
+
updated_at: Optional[datetime] = None
|
|
43
|
+
):
|
|
44
|
+
self.conversation_id = conversation_id or datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
|
45
|
+
self.created_at = created_at or datetime.utcnow()
|
|
46
|
+
self.updated_at = updated_at or datetime.utcnow()
|
|
47
|
+
self.messages: List[Message] = []
|
|
48
|
+
|
|
49
|
+
def add_message(self, role: str, content: str) -> None:
|
|
50
|
+
"""Add a message to the conversation."""
|
|
51
|
+
message = Message(role, content)
|
|
52
|
+
self.messages.append(message)
|
|
53
|
+
self.updated_at = datetime.utcnow()
|
|
54
|
+
|
|
55
|
+
def get_summary(self, max_length: int = 100) -> str:
|
|
56
|
+
"""Get a summary of the conversation (first user message)."""
|
|
57
|
+
for msg in self.messages:
|
|
58
|
+
if msg.role == "user":
|
|
59
|
+
summary = msg.content[:max_length]
|
|
60
|
+
if len(msg.content) > max_length:
|
|
61
|
+
summary += "..."
|
|
62
|
+
return summary
|
|
63
|
+
return "(empty conversation)"
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
66
|
+
"""Convert to dictionary for storage."""
|
|
67
|
+
return {
|
|
68
|
+
"conversation_id": self.conversation_id,
|
|
69
|
+
"created_at": self.created_at.isoformat(),
|
|
70
|
+
"updated_at": self.updated_at.isoformat(),
|
|
71
|
+
"messages": [msg.to_dict() for msg in self.messages]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Conversation":
|
|
76
|
+
"""Create from dictionary."""
|
|
77
|
+
conv = cls(
|
|
78
|
+
conversation_id=data["conversation_id"],
|
|
79
|
+
created_at=datetime.fromisoformat(data["created_at"]),
|
|
80
|
+
updated_at=datetime.fromisoformat(data["updated_at"])
|
|
81
|
+
)
|
|
82
|
+
conv.messages = [Message.from_dict(msg) for msg in data.get("messages", [])]
|
|
83
|
+
return conv
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ConversationHistory:
|
|
87
|
+
"""Manages conversation history storage and retrieval."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, history_dir: Path):
|
|
90
|
+
self.history_dir = history_dir
|
|
91
|
+
self.history_dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
def _get_conversation_file(self, conversation_id: str) -> Path:
|
|
94
|
+
"""Get the file path for a conversation."""
|
|
95
|
+
return self.history_dir / f"{conversation_id}.json"
|
|
96
|
+
|
|
97
|
+
def save_conversation(self, conversation: Conversation) -> None:
|
|
98
|
+
"""Save a conversation to disk."""
|
|
99
|
+
file_path = self._get_conversation_file(conversation.conversation_id)
|
|
100
|
+
with open(file_path, 'w') as f:
|
|
101
|
+
json.dump(conversation.to_dict(), f, indent=2)
|
|
102
|
+
|
|
103
|
+
def load_conversation(self, conversation_id: str) -> Optional[Conversation]:
|
|
104
|
+
"""Load a conversation from disk."""
|
|
105
|
+
file_path = self._get_conversation_file(conversation_id)
|
|
106
|
+
if not file_path.exists():
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
with open(file_path, 'r') as f:
|
|
111
|
+
data = json.load(f)
|
|
112
|
+
return Conversation.from_dict(data)
|
|
113
|
+
except Exception:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def list_conversations(
|
|
117
|
+
self,
|
|
118
|
+
limit: Optional[int] = None,
|
|
119
|
+
search_query: Optional[str] = None
|
|
120
|
+
) -> List[Conversation]:
|
|
121
|
+
"""List all conversations, optionally filtered and limited."""
|
|
122
|
+
conversations = []
|
|
123
|
+
|
|
124
|
+
for file_path in sorted(self.history_dir.glob("*.json"), reverse=True):
|
|
125
|
+
try:
|
|
126
|
+
with open(file_path, 'r') as f:
|
|
127
|
+
data = json.load(f)
|
|
128
|
+
conv = Conversation.from_dict(data)
|
|
129
|
+
|
|
130
|
+
# Apply search filter if provided
|
|
131
|
+
if search_query:
|
|
132
|
+
search_lower = search_query.lower()
|
|
133
|
+
found = False
|
|
134
|
+
for msg in conv.messages:
|
|
135
|
+
if search_lower in msg.content.lower():
|
|
136
|
+
found = True
|
|
137
|
+
break
|
|
138
|
+
if not found:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
conversations.append(conv)
|
|
142
|
+
|
|
143
|
+
if limit and len(conversations) >= limit:
|
|
144
|
+
break
|
|
145
|
+
except Exception:
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
return conversations
|
|
149
|
+
|
|
150
|
+
def delete_conversation(self, conversation_id: str) -> bool:
|
|
151
|
+
"""Delete a conversation."""
|
|
152
|
+
file_path = self._get_conversation_file(conversation_id)
|
|
153
|
+
if file_path.exists():
|
|
154
|
+
file_path.unlink()
|
|
155
|
+
return True
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
def get_latest_conversation(self) -> Optional[Conversation]:
|
|
159
|
+
"""Get the most recent conversation."""
|
|
160
|
+
conversations = self.list_conversations(limit=1)
|
|
161
|
+
return conversations[0] if conversations else None
|
|
162
|
+
|
|
163
|
+
def export_conversation(
|
|
164
|
+
self,
|
|
165
|
+
conversation_id: str,
|
|
166
|
+
output_format: str = "markdown"
|
|
167
|
+
) -> Optional[str]:
|
|
168
|
+
"""Export a conversation to a specific format."""
|
|
169
|
+
conv = self.load_conversation(conversation_id)
|
|
170
|
+
if not conv:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
if output_format == "markdown":
|
|
174
|
+
lines = [f"# Conversation: {conv.conversation_id}"]
|
|
175
|
+
lines.append(f"\nCreated: {conv.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
176
|
+
lines.append(f"Updated: {conv.updated_at.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
|
177
|
+
|
|
178
|
+
for msg in conv.messages:
|
|
179
|
+
role_display = "**You:**" if msg.role == "user" else "**Claude:**"
|
|
180
|
+
lines.append(f"\n## {role_display}\n")
|
|
181
|
+
lines.append(msg.content)
|
|
182
|
+
lines.append("")
|
|
183
|
+
|
|
184
|
+
return "\n".join(lines)
|
|
185
|
+
|
|
186
|
+
elif output_format == "json":
|
|
187
|
+
return json.dumps(conv.to_dict(), indent=2)
|
|
188
|
+
|
|
189
|
+
return None
|
|
@@ -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
|
|
@@ -271,6 +273,77 @@ class DiffViewer:
|
|
|
271
273
|
|
|
272
274
|
self.console.print(prompt)
|
|
273
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
|
+
|
|
274
347
|
def run(self) -> Optional[str]:
|
|
275
348
|
"""Run the interactive diff viewer.
|
|
276
349
|
|
|
@@ -306,10 +379,20 @@ class DiffViewer:
|
|
|
306
379
|
hunk.accepted = False
|
|
307
380
|
self.current_hunk_idx += 1
|
|
308
381
|
elif choice in kb["edit"]:
|
|
309
|
-
|
|
310
|
-
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
|
|
311
393
|
elif choice in kb["split"]:
|
|
312
|
-
|
|
394
|
+
# Split current hunk
|
|
395
|
+
self._split_hunk(hunk, self.current_hunk_idx)
|
|
313
396
|
self.console.input("Press Enter to continue...")
|
|
314
397
|
elif choice in kb["next"]:
|
|
315
398
|
if self.current_hunk_idx < len(self.hunks) - 1:
|
|
@@ -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
|
+
Version: 0.5.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,6 +26,8 @@ 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"
|
|
31
33
|
Provides-Extra: plugins
|
|
@@ -46,9 +48,10 @@ A powerful command-line tool for developers using Claude AI with multi-API routi
|
|
|
46
48
|
## Features
|
|
47
49
|
|
|
48
50
|
### 🔑 Multi-API Key Management
|
|
51
|
+
- **Secure Storage**: API keys stored in system keyring (macOS Keychain, Linux Secret Service, Windows Credential Locker)
|
|
49
52
|
- Route tasks to different Claude API keys (personal, client, enterprise)
|
|
50
53
|
- Automatic API selection based on project configuration
|
|
51
|
-
-
|
|
54
|
+
- Automatic migration from plaintext to secure storage
|
|
52
55
|
|
|
53
56
|
### 🧪 Developer Tools
|
|
54
57
|
- **Test Generation**: Automatic pytest test generation for Python code
|
|
@@ -98,13 +101,17 @@ pip install claude-dev-cli[toon]
|
|
|
98
101
|
# Add your personal API key
|
|
99
102
|
export PERSONAL_ANTHROPIC_API_KEY="sk-ant-..."
|
|
100
103
|
cdc config add personal --default --description "My personal API key"
|
|
104
|
+
# 🔐 Stored securely in system keyring
|
|
101
105
|
|
|
102
106
|
# Add client's API key
|
|
103
107
|
export CLIENT_ANTHROPIC_API_KEY="sk-ant-..."
|
|
104
108
|
cdc config add client --description "Client's Enterprise API"
|
|
105
109
|
|
|
106
|
-
# List configured APIs
|
|
110
|
+
# List configured APIs (shows storage method)
|
|
107
111
|
cdc config list
|
|
112
|
+
|
|
113
|
+
# Manually migrate existing keys (automatic on first run)
|
|
114
|
+
cdc config migrate-keys
|
|
108
115
|
```
|
|
109
116
|
|
|
110
117
|
### 2. Basic Usage
|
|
@@ -186,22 +193,37 @@ cat large_data.json | cdc toon encode | cdc ask "analyze this data"
|
|
|
186
193
|
|
|
187
194
|
## Configuration
|
|
188
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
|
+
|
|
189
211
|
### Global Configuration
|
|
190
212
|
|
|
191
|
-
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):
|
|
192
214
|
|
|
193
215
|
```json
|
|
194
216
|
{
|
|
195
217
|
"api_configs": [
|
|
196
218
|
{
|
|
197
219
|
"name": "personal",
|
|
198
|
-
"api_key": "
|
|
220
|
+
"api_key": "", // Empty - actual key in secure storage
|
|
199
221
|
"description": "My personal API key",
|
|
200
222
|
"default": true
|
|
201
223
|
},
|
|
202
224
|
{
|
|
203
225
|
"name": "client",
|
|
204
|
-
"api_key": "
|
|
226
|
+
"api_key": "", // Empty - actual key in secure storage
|
|
205
227
|
"description": "Client's Enterprise API",
|
|
206
228
|
"default": false
|
|
207
229
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
claude_dev_cli/__init__.py,sha256=2ulyIQ3E-s6wBTKyeXAlqHMVA73zUGdaaNUsFiJ-nqs,469
|
|
2
|
-
claude_dev_cli/cli.py,sha256=
|
|
2
|
+
claude_dev_cli/cli.py,sha256=WBJdA1KKA0scT-gOE5Sd39HhtJr7uyc3qfn_-rYXTGc,25019
|
|
3
3
|
claude_dev_cli/commands.py,sha256=RKGx2rv56PM6eErvA2uoQ20hY8babuI5jav8nCUyUOk,3964
|
|
4
|
-
claude_dev_cli/config.py,sha256
|
|
4
|
+
claude_dev_cli/config.py,sha256=RGX0sKplHUsrJJmU-4FuWWjoTbQVgWaMT8DgRUofrR4,8134
|
|
5
5
|
claude_dev_cli/core.py,sha256=yaLjEixDvPzvUy4fJ2UB7nMpPPLyKACjR-RuM-1OQBY,4780
|
|
6
|
+
claude_dev_cli/history.py,sha256=iQlqgTnXCsyCq5q-XaDl7V5MyPKQ3bx7o_k76-xWSAA,6863
|
|
7
|
+
claude_dev_cli/secure_storage.py,sha256=TK3WOaU7a0yTOtzdP_t_28fDRp2lovANNAC6MBdm4nQ,7096
|
|
6
8
|
claude_dev_cli/templates.py,sha256=lKxH943ySfUKgyHaWa4W3LVv91SgznKgajRtSRp_4UY,2260
|
|
7
9
|
claude_dev_cli/toon_utils.py,sha256=S3px2UvmNEaltmTa5K-h21n2c0CPvYjZc9mc7kHGqNQ,2828
|
|
8
10
|
claude_dev_cli/usage.py,sha256=32rs0_dUn6ihha3vCfT3rwnvel_-sED7jvLpO7gu-KQ,7446
|
|
@@ -10,10 +12,10 @@ claude_dev_cli/plugins/__init__.py,sha256=BdiZlylBzEgnwK2tuEdn8cITxhAZRVbTnDbWhd
|
|
|
10
12
|
claude_dev_cli/plugins/base.py,sha256=H4HQet1I-a3WLCfE9F06Lp8NuFvVoIlou7sIgyJFK-c,1417
|
|
11
13
|
claude_dev_cli/plugins/diff_editor/__init__.py,sha256=gqR5S2TyIVuq-sK107fegsutQ7Z-sgAIEbtc71FhXIM,101
|
|
12
14
|
claude_dev_cli/plugins/diff_editor/plugin.py,sha256=M1bUoqpasD3ZNQo36Fu_8g92uySPZyG_ujMbj5UplsU,3073
|
|
13
|
-
claude_dev_cli/plugins/diff_editor/viewer.py,sha256=
|
|
14
|
-
claude_dev_cli-0.
|
|
15
|
-
claude_dev_cli-0.
|
|
16
|
-
claude_dev_cli-0.
|
|
17
|
-
claude_dev_cli-0.
|
|
18
|
-
claude_dev_cli-0.
|
|
19
|
-
claude_dev_cli-0.
|
|
15
|
+
claude_dev_cli/plugins/diff_editor/viewer.py,sha256=1IOXIKw_01ppJx5C1dQt9Kr6U1TdAHT8_iUT5r_q0NM,17169
|
|
16
|
+
claude_dev_cli-0.5.0.dist-info/licenses/LICENSE,sha256=DGueuJwMJtMwgLO5mWlS0TaeBrFwQuNpNZ22PU9J2bw,1062
|
|
17
|
+
claude_dev_cli-0.5.0.dist-info/METADATA,sha256=GCboIRGV9N6gFFiwotWuCFMqMbhJZs4Gk6pyztZX984,11288
|
|
18
|
+
claude_dev_cli-0.5.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
19
|
+
claude_dev_cli-0.5.0.dist-info/entry_points.txt,sha256=zymgUIIVpFTARkFmxAuW2A4BQsNITh_L0uU-XunytHg,85
|
|
20
|
+
claude_dev_cli-0.5.0.dist-info/top_level.txt,sha256=m7MF6LOIuTe41IT5Fgt0lc-DK1EgM4gUU_IZwWxK0pg,15
|
|
21
|
+
claude_dev_cli-0.5.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|