cmdclip 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cmdclip/ai.py +135 -0
- cmdclip/cli.py +430 -0
- cmdclip/storage.py +276 -0
- cmdclip/templates.py +86 -0
- cmdclip-1.0.0.dist-info/METADATA +141 -0
- cmdclip-1.0.0.dist-info/RECORD +10 -0
- cmdclip-1.0.0.dist-info/WHEEL +5 -0
- cmdclip-1.0.0.dist-info/entry_points.txt +2 -0
- cmdclip-1.0.0.dist-info/licenses/LICENSE +674 -0
- cmdclip-1.0.0.dist-info/top_level.txt +1 -0
cmdclip/ai.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ai.py — Groq AI integration for cmdclip
|
|
3
|
+
Provides: explain_command(), suggest_tags()
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_key() -> Optional[str]:
|
|
12
|
+
"""Resolve Groq API key: env var first, then config file."""
|
|
13
|
+
if key := os.environ.get("GROQ_API_KEY"):
|
|
14
|
+
return key
|
|
15
|
+
from cmdclip.storage import get_config
|
|
16
|
+
return get_config().get("groq_api_key")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_client():
|
|
20
|
+
try:
|
|
21
|
+
from groq import Groq
|
|
22
|
+
except ImportError:
|
|
23
|
+
raise ImportError(
|
|
24
|
+
"groq package not installed. Run: pip install groq"
|
|
25
|
+
)
|
|
26
|
+
key = _get_key()
|
|
27
|
+
if not key:
|
|
28
|
+
return None
|
|
29
|
+
return Groq(api_key=key)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def explain_command(cmd: str) -> str:
|
|
33
|
+
"""Return a plain-English explanation of a shell command."""
|
|
34
|
+
client = _get_client()
|
|
35
|
+
if not client:
|
|
36
|
+
return "[!] No Groq API key found. Run: cmdclip config set-key <KEY>"
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
response = client.chat.completions.create(
|
|
40
|
+
model="llama3-8b-8192",
|
|
41
|
+
messages=[
|
|
42
|
+
{
|
|
43
|
+
"role": "system",
|
|
44
|
+
"content": (
|
|
45
|
+
"You are a shell command explainer. "
|
|
46
|
+
"Given a shell command, explain what it does clearly and concisely "
|
|
47
|
+
"in 2-4 sentences. Mention any risks if relevant. "
|
|
48
|
+
"Reply in plain text, no markdown."
|
|
49
|
+
),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"role": "user",
|
|
53
|
+
"content": f"Explain this command:\n\n{cmd}",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
max_tokens=200,
|
|
57
|
+
temperature=0.3,
|
|
58
|
+
)
|
|
59
|
+
return response.choices[0].message.content.strip()
|
|
60
|
+
except Exception as e:
|
|
61
|
+
return f"[!] AI error: {e}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def suggest_tags(cmd: str) -> list[str]:
|
|
65
|
+
"""
|
|
66
|
+
Use AI to suggest relevant tags for a command.
|
|
67
|
+
Returns a list of lowercase tag strings.
|
|
68
|
+
Falls back to simple keyword matching if no API key.
|
|
69
|
+
"""
|
|
70
|
+
client = _get_client()
|
|
71
|
+
|
|
72
|
+
if not client:
|
|
73
|
+
return _fallback_tags(cmd)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
response = client.chat.completions.create(
|
|
77
|
+
model="llama3-8b-8192",
|
|
78
|
+
messages=[
|
|
79
|
+
{
|
|
80
|
+
"role": "system",
|
|
81
|
+
"content": (
|
|
82
|
+
"You are a shell command tagger. "
|
|
83
|
+
"Given a shell command, respond ONLY with a JSON array of 2-4 lowercase tag strings. "
|
|
84
|
+
"Tags should be tool names or categories like: docker, git, network, files, ops, python, ssh, etc. "
|
|
85
|
+
"Example output: [\"docker\", \"containers\", \"ops\"] "
|
|
86
|
+
"No explanation, no markdown, just the JSON array."
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"role": "user",
|
|
91
|
+
"content": f"Suggest tags for:\n\n{cmd}",
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
max_tokens=60,
|
|
95
|
+
temperature=0.2,
|
|
96
|
+
)
|
|
97
|
+
raw = response.choices[0].message.content.strip()
|
|
98
|
+
tags = json.loads(raw)
|
|
99
|
+
if isinstance(tags, list):
|
|
100
|
+
return [str(t).lower().strip() for t in tags if t][:4]
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
return _fallback_tags(cmd)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _fallback_tags(cmd: str) -> list[str]:
|
|
108
|
+
"""Simple keyword-based tag fallback when AI is unavailable."""
|
|
109
|
+
cmd_lower = cmd.lower()
|
|
110
|
+
keyword_map = {
|
|
111
|
+
"docker": ["docker", "containers"],
|
|
112
|
+
"git": ["git", "vcs"],
|
|
113
|
+
"ssh": ["ssh", "remote"],
|
|
114
|
+
"python": ["python"],
|
|
115
|
+
"pip": ["python", "pip"],
|
|
116
|
+
"apt": ["apt", "linux", "packages"],
|
|
117
|
+
"brew": ["brew", "macos", "packages"],
|
|
118
|
+
"npm": ["npm", "node", "js"],
|
|
119
|
+
"curl": ["curl", "network", "http"],
|
|
120
|
+
"wget": ["wget", "network", "download"],
|
|
121
|
+
"ffmpeg": ["ffmpeg", "media"],
|
|
122
|
+
"grep": ["grep", "search", "text"],
|
|
123
|
+
"find": ["find", "files"],
|
|
124
|
+
"rm": ["files", "cleanup"],
|
|
125
|
+
"chmod": ["files", "permissions"],
|
|
126
|
+
"systemctl": ["systemd", "services", "linux"],
|
|
127
|
+
"kubectl": ["kubernetes", "k8s", "ops"],
|
|
128
|
+
"tar": ["archive", "files"],
|
|
129
|
+
"rsync": ["rsync", "files", "sync"],
|
|
130
|
+
}
|
|
131
|
+
tags = []
|
|
132
|
+
for keyword, t in keyword_map.items():
|
|
133
|
+
if keyword in cmd_lower:
|
|
134
|
+
tags.extend(t)
|
|
135
|
+
return list(dict.fromkeys(tags))[:4] or ["general"]
|
cmdclip/cli.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli.py — cmdclip command-line interface
|
|
3
|
+
Built with Typer + Rich for cross-platform terminal UI.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import platform
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.prompt import Confirm, Prompt
|
|
16
|
+
from rich import print as rprint
|
|
17
|
+
|
|
18
|
+
from cmdclip import storage, ai
|
|
19
|
+
from cmdclip.templates import is_template, resolve_template, preview_template
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="cmdclip",
|
|
23
|
+
help="📋 A smart cross-platform command clipboard manager.",
|
|
24
|
+
add_completion=False,
|
|
25
|
+
)
|
|
26
|
+
config_app = typer.Typer(help="Manage cmdclip configuration.")
|
|
27
|
+
app.add_typer(config_app, name="config")
|
|
28
|
+
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
DANGEROUS_PATTERNS = ["rm -rf", "sudo rm", "DROP TABLE", "mkfs", ":(){:|:&};:"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_dangerous(cmd: str) -> bool:
|
|
38
|
+
return any(p in cmd for p in DANGEROUS_PATTERNS)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _copy_to_clipboard(text: str) -> bool:
|
|
42
|
+
"""Copy text to system clipboard. Returns True on success."""
|
|
43
|
+
try:
|
|
44
|
+
import pyperclip
|
|
45
|
+
pyperclip.copy(text)
|
|
46
|
+
return True
|
|
47
|
+
except Exception:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _run_command(cmd: str) -> None:
|
|
52
|
+
"""Execute a shell command on any platform."""
|
|
53
|
+
if platform.system() == "Windows":
|
|
54
|
+
subprocess.run(cmd, shell=True)
|
|
55
|
+
else:
|
|
56
|
+
subprocess.run(cmd, shell=True, executable="/bin/bash")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _print_command_table(commands: list[dict], title: str = "Commands") -> None:
|
|
60
|
+
if not commands:
|
|
61
|
+
console.print("[yellow]No commands found.[/yellow]")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
table = Table(title=title, show_lines=True, header_style="bold cyan")
|
|
65
|
+
table.add_column("ID", style="dim", width=10)
|
|
66
|
+
table.add_column("Name", style="bold", width=16)
|
|
67
|
+
table.add_column("Command", width=38)
|
|
68
|
+
table.add_column("Tags", style="cyan", width=18)
|
|
69
|
+
table.add_column("Uses", justify="right", width=5)
|
|
70
|
+
|
|
71
|
+
for c in commands:
|
|
72
|
+
tags = ", ".join(c.get("tags", []))
|
|
73
|
+
name = c.get("name") or "-"
|
|
74
|
+
uses = str(c.get("use_count", 0))
|
|
75
|
+
cmd_preview = c["cmd"]
|
|
76
|
+
if len(cmd_preview) > 55:
|
|
77
|
+
cmd_preview = cmd_preview[:52] + "..."
|
|
78
|
+
table.add_row(c["id"], name, cmd_preview, tags, uses)
|
|
79
|
+
|
|
80
|
+
console.print(table)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ─── Commands ─────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def add(
|
|
87
|
+
cmd: str = typer.Argument(..., help="The shell command to save."),
|
|
88
|
+
tags: Optional[str] = typer.Option(None, "--tags", "-t", help="Comma-separated tags."),
|
|
89
|
+
note: str = typer.Option("", "--note", "-n", help="Short description."),
|
|
90
|
+
name: str = typer.Option("", "--name", help="Optional short name/alias."),
|
|
91
|
+
no_ai: bool = typer.Option(False, "--no-ai", help="Skip AI tag suggestions."),
|
|
92
|
+
):
|
|
93
|
+
"""Add a new command to your clipboard."""
|
|
94
|
+
tag_list: list[str] = []
|
|
95
|
+
|
|
96
|
+
if tags:
|
|
97
|
+
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
|
98
|
+
elif not no_ai:
|
|
99
|
+
with console.status("[cyan]Suggesting tags via AI...[/cyan]"):
|
|
100
|
+
suggested = ai.suggest_tags(cmd)
|
|
101
|
+
if suggested:
|
|
102
|
+
console.print(f"[cyan]Suggested tags:[/cyan] {', '.join(suggested)}")
|
|
103
|
+
accept = Confirm.ask("Accept these tags?", default=True)
|
|
104
|
+
if accept:
|
|
105
|
+
tag_list = suggested
|
|
106
|
+
else:
|
|
107
|
+
raw = Prompt.ask("Enter tags manually (comma-separated)")
|
|
108
|
+
tag_list = [t.strip() for t in raw.split(",") if t.strip()]
|
|
109
|
+
|
|
110
|
+
template = is_template(cmd)
|
|
111
|
+
entry = storage.add_command(cmd, tag_list, note=note, name=name, template=template)
|
|
112
|
+
|
|
113
|
+
console.print(Panel(
|
|
114
|
+
f"[bold green]✓ Saved![/bold green]\n"
|
|
115
|
+
f"ID: [yellow]{entry['id']}[/yellow]\n"
|
|
116
|
+
f"Cmd: {entry['cmd']}\n"
|
|
117
|
+
f"Tags: {', '.join(entry['tags']) or 'none'}"
|
|
118
|
+
+ (f"\nNote: {note}" if note else "")
|
|
119
|
+
+ ("\n[cyan]📝 Template detected — variables will be filled on run.[/cyan]" if template else ""),
|
|
120
|
+
title="cmdclip",
|
|
121
|
+
border_style="green",
|
|
122
|
+
))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command("list")
|
|
126
|
+
def list_commands(
|
|
127
|
+
tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Filter by tag."),
|
|
128
|
+
query: str = typer.Option("", "--query", "-q", help="Search keyword."),
|
|
129
|
+
):
|
|
130
|
+
"""List all saved commands."""
|
|
131
|
+
results = storage.search_commands(query=query, tag=tag)
|
|
132
|
+
title = "All Commands"
|
|
133
|
+
if tag:
|
|
134
|
+
title += f" [tag: {tag}]"
|
|
135
|
+
if query:
|
|
136
|
+
title += f" [search: {query}]"
|
|
137
|
+
_print_command_table(results, title=title)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command()
|
|
141
|
+
def search(
|
|
142
|
+
query: str = typer.Argument(..., help="Search term."),
|
|
143
|
+
tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Filter by tag."),
|
|
144
|
+
):
|
|
145
|
+
"""Search commands by keyword, tag, or note."""
|
|
146
|
+
results = storage.search_commands(query=query, tag=tag)
|
|
147
|
+
_print_command_table(results, title=f'Search: "{query}"')
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command()
|
|
151
|
+
def run(
|
|
152
|
+
id_: str = typer.Argument(..., metavar="ID", help="Command ID to run."),
|
|
153
|
+
dry_run: bool = typer.Option(False, "--dry-run", "-d", help="Preview only, don't execute."),
|
|
154
|
+
copy: bool = typer.Option(False, "--copy", "-c", help="Copy to clipboard instead of running."),
|
|
155
|
+
):
|
|
156
|
+
"""Run a saved command by ID."""
|
|
157
|
+
entry = storage.get_command_by_id(id_)
|
|
158
|
+
if not entry:
|
|
159
|
+
console.print(f"[red]No command found with ID: {id_}[/red]")
|
|
160
|
+
raise typer.Exit(1)
|
|
161
|
+
|
|
162
|
+
cmd = entry["cmd"]
|
|
163
|
+
|
|
164
|
+
# Resolve template variables
|
|
165
|
+
if entry.get("template") or is_template(cmd):
|
|
166
|
+
console.print(Panel(preview_template(cmd), title="Template", border_style="cyan"))
|
|
167
|
+
console.print("[cyan]Fill in the variables:[/cyan]")
|
|
168
|
+
try:
|
|
169
|
+
cmd = resolve_template(cmd)
|
|
170
|
+
except KeyboardInterrupt:
|
|
171
|
+
console.print("\n[yellow]Cancelled.[/yellow]")
|
|
172
|
+
raise typer.Exit()
|
|
173
|
+
|
|
174
|
+
# Dry-run: show explanation and stop
|
|
175
|
+
if dry_run:
|
|
176
|
+
console.print(Panel(
|
|
177
|
+
f"[bold]Command:[/bold] {cmd}\n\n"
|
|
178
|
+
+ ("[bold yellow]⚠ Contains dangerous pattern![/bold yellow]\n\n" if _is_dangerous(cmd) else "")
|
|
179
|
+
+ "[bold]Explanation:[/bold]\n" + ai.explain_command(cmd),
|
|
180
|
+
title="Dry Run Preview",
|
|
181
|
+
border_style="yellow",
|
|
182
|
+
))
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
# Dangerous check
|
|
186
|
+
if _is_dangerous(cmd):
|
|
187
|
+
console.print(f"[bold red]⚠ WARNING: This command contains a dangerous pattern![/bold red]")
|
|
188
|
+
console.print(f"[dim]{cmd}[/dim]")
|
|
189
|
+
if not Confirm.ask("[red]Are you sure you want to run it?[/red]", default=False):
|
|
190
|
+
console.print("[yellow]Cancelled.[/yellow]")
|
|
191
|
+
raise typer.Exit()
|
|
192
|
+
|
|
193
|
+
if copy:
|
|
194
|
+
if _copy_to_clipboard(cmd):
|
|
195
|
+
console.print(f"[green]✓ Copied to clipboard:[/green] {cmd}")
|
|
196
|
+
else:
|
|
197
|
+
console.print(f"[yellow]Could not access clipboard. Command:[/yellow] {cmd}")
|
|
198
|
+
else:
|
|
199
|
+
console.print(f"[dim]$ {cmd}[/dim]")
|
|
200
|
+
storage.increment_use_count(id_)
|
|
201
|
+
_run_command(cmd)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.command()
|
|
205
|
+
def explain(
|
|
206
|
+
id_: str = typer.Argument(..., metavar="ID", help="Command ID to explain."),
|
|
207
|
+
):
|
|
208
|
+
"""Use AI to explain what a command does."""
|
|
209
|
+
entry = storage.get_command_by_id(id_)
|
|
210
|
+
if not entry:
|
|
211
|
+
console.print(f"[red]No command found with ID: {id_}[/red]")
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
|
|
214
|
+
with console.status("[cyan]Asking AI...[/cyan]"):
|
|
215
|
+
explanation = ai.explain_command(entry["cmd"])
|
|
216
|
+
|
|
217
|
+
console.print(Panel(
|
|
218
|
+
f"[bold]Command:[/bold] {entry['cmd']}\n\n{explanation}",
|
|
219
|
+
title="AI Explanation",
|
|
220
|
+
border_style="cyan",
|
|
221
|
+
))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.command()
|
|
225
|
+
def delete(
|
|
226
|
+
id_: str = typer.Argument(..., metavar="ID", help="Command ID to delete."),
|
|
227
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
|
|
228
|
+
):
|
|
229
|
+
"""Delete a saved command."""
|
|
230
|
+
entry = storage.get_command_by_id(id_)
|
|
231
|
+
if not entry:
|
|
232
|
+
console.print(f"[red]No command found with ID: {id_}[/red]")
|
|
233
|
+
raise typer.Exit(1)
|
|
234
|
+
|
|
235
|
+
console.print(f"[dim]{entry['cmd']}[/dim]")
|
|
236
|
+
if not force and not Confirm.ask(f"Delete [yellow]{id_}[/yellow]?", default=False):
|
|
237
|
+
console.print("[yellow]Cancelled.[/yellow]")
|
|
238
|
+
raise typer.Exit()
|
|
239
|
+
|
|
240
|
+
storage.delete_command(id_)
|
|
241
|
+
console.print(f"[green]✓ Deleted {id_}[/green]")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@app.command()
|
|
245
|
+
def stats():
|
|
246
|
+
"""Show usage statistics for your command clipboard."""
|
|
247
|
+
data = storage.get_stats()
|
|
248
|
+
|
|
249
|
+
if data["total"] == 0:
|
|
250
|
+
console.print("[yellow]No commands saved yet.[/yellow]")
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
console.print(Panel(
|
|
254
|
+
f"[bold]Total commands:[/bold] {data['total']}\n"
|
|
255
|
+
f"[bold]All tags:[/bold] {', '.join(data['all_tags']) or 'none'}",
|
|
256
|
+
title="cmdclip stats",
|
|
257
|
+
border_style="cyan",
|
|
258
|
+
))
|
|
259
|
+
|
|
260
|
+
if data["top"]:
|
|
261
|
+
table = Table(title="Top 5 Most Used", header_style="bold magenta")
|
|
262
|
+
table.add_column("ID", style="dim", width=10)
|
|
263
|
+
table.add_column("Command", width=45)
|
|
264
|
+
table.add_column("Uses", justify="right", width=6)
|
|
265
|
+
table.add_column("Last used", width=22)
|
|
266
|
+
|
|
267
|
+
for c in data["top"]:
|
|
268
|
+
last = c.get("last_used") or "never"
|
|
269
|
+
if last != "never":
|
|
270
|
+
last = last[:19].replace("T", " ")
|
|
271
|
+
table.add_row(c["id"], c["cmd"][:60], str(c.get("use_count", 0)), last)
|
|
272
|
+
|
|
273
|
+
console.print(table)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@app.command()
|
|
277
|
+
def history():
|
|
278
|
+
"""
|
|
279
|
+
Import frequently used commands from shell history.
|
|
280
|
+
Shows commands used 5+ times and lets you pick which to save.
|
|
281
|
+
"""
|
|
282
|
+
with console.status("[cyan]Scanning shell history...[/cyan]"):
|
|
283
|
+
candidates = storage.smart_history_import()
|
|
284
|
+
|
|
285
|
+
if not candidates:
|
|
286
|
+
console.print("[yellow]No frequently-used commands found (or history file not accessible).[/yellow]")
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
console.print(f"[bold cyan]Found {len(candidates)} frequently-used commands:[/bold cyan]\n")
|
|
290
|
+
|
|
291
|
+
to_save = []
|
|
292
|
+
for i, cmd in enumerate(candidates, 1):
|
|
293
|
+
console.print(f"[dim]{i:2}.[/dim] {cmd}")
|
|
294
|
+
if Confirm.ask(" Save this?", default=False):
|
|
295
|
+
to_save.append(cmd)
|
|
296
|
+
|
|
297
|
+
if not to_save:
|
|
298
|
+
console.print("[yellow]Nothing saved.[/yellow]")
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
saved = 0
|
|
302
|
+
for cmd in to_save:
|
|
303
|
+
with console.status(f"[cyan]Tagging: {cmd[:40]}...[/cyan]"):
|
|
304
|
+
tags = ai.suggest_tags(cmd)
|
|
305
|
+
storage.add_command(cmd, tags)
|
|
306
|
+
saved += 1
|
|
307
|
+
|
|
308
|
+
console.print(f"[green]✓ Saved {saved} command(s) from history.[/green]")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@app.command()
|
|
312
|
+
def export(
|
|
313
|
+
output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path."),
|
|
314
|
+
safe: bool = typer.Option(False, "--safe", help="Exclude dangerous commands."),
|
|
315
|
+
):
|
|
316
|
+
"""Export all commands to JSON."""
|
|
317
|
+
import json
|
|
318
|
+
|
|
319
|
+
data = storage._load_db()
|
|
320
|
+
if safe:
|
|
321
|
+
data["commands"] = [
|
|
322
|
+
c for c in data["commands"]
|
|
323
|
+
if not _is_dangerous(c.get("cmd", ""))
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
json_str = json.dumps(data, indent=2, ensure_ascii=False)
|
|
327
|
+
|
|
328
|
+
if output:
|
|
329
|
+
from pathlib import Path
|
|
330
|
+
Path(output).write_text(json_str, encoding="utf-8")
|
|
331
|
+
console.print(f"[green]✓ Exported {len(data['commands'])} commands to {output}[/green]")
|
|
332
|
+
else:
|
|
333
|
+
print(json_str)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@app.command("import")
|
|
337
|
+
def import_commands(
|
|
338
|
+
file: str = typer.Argument(..., help="JSON file to import."),
|
|
339
|
+
safe_mode: bool = typer.Option(True, "--safe/--no-safe", help="Quarantine dangerous commands."),
|
|
340
|
+
):
|
|
341
|
+
"""Import commands from a JSON export file."""
|
|
342
|
+
from pathlib import Path
|
|
343
|
+
|
|
344
|
+
path = Path(file)
|
|
345
|
+
if not path.exists():
|
|
346
|
+
console.print(f"[red]File not found: {file}[/red]")
|
|
347
|
+
raise typer.Exit(1)
|
|
348
|
+
|
|
349
|
+
json_str = path.read_text(encoding="utf-8")
|
|
350
|
+
try:
|
|
351
|
+
imported, quarantined = storage.import_db(json_str, safe_mode=safe_mode)
|
|
352
|
+
except ValueError as e:
|
|
353
|
+
console.print(f"[red]Import failed: {e}[/red]")
|
|
354
|
+
raise typer.Exit(1)
|
|
355
|
+
|
|
356
|
+
console.print(f"[green]✓ Imported {imported} command(s).[/green]")
|
|
357
|
+
if quarantined:
|
|
358
|
+
console.print(
|
|
359
|
+
f"[yellow]⚠ {quarantined} command(s) quarantined (dangerous patterns).[/yellow]\n"
|
|
360
|
+
f"[dim]Review them at: {storage.get_quarantine_path()}[/dim]"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@app.command()
|
|
365
|
+
def share(
|
|
366
|
+
id_: str = typer.Argument(..., metavar="ID", help="Command ID to share."),
|
|
367
|
+
):
|
|
368
|
+
"""Copy a command as a shareable text snippet."""
|
|
369
|
+
entry = storage.get_command_by_id(id_)
|
|
370
|
+
if not entry:
|
|
371
|
+
console.print(f"[red]No command found with ID: {id_}[/red]")
|
|
372
|
+
raise typer.Exit(1)
|
|
373
|
+
|
|
374
|
+
tags_str = " ".join(f"#{t}" for t in entry.get("tags", []))
|
|
375
|
+
note = entry.get("note", "")
|
|
376
|
+
|
|
377
|
+
snippet = f"```\n{entry['cmd']}\n```"
|
|
378
|
+
if note:
|
|
379
|
+
snippet = f"{note}\n{snippet}"
|
|
380
|
+
if tags_str:
|
|
381
|
+
snippet += f"\n{tags_str}"
|
|
382
|
+
snippet += "\n\n— shared via cmdclip (M5 Dev)"
|
|
383
|
+
|
|
384
|
+
if _copy_to_clipboard(snippet):
|
|
385
|
+
console.print("[green]✓ Snippet copied to clipboard — paste it anywhere![/green]")
|
|
386
|
+
else:
|
|
387
|
+
console.print("[bold]Share snippet:[/bold]")
|
|
388
|
+
console.print(snippet)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ─── Config subcommands ────────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
@config_app.command("set-key")
|
|
394
|
+
def config_set_key(
|
|
395
|
+
key: str = typer.Argument(..., help="Your Groq API key."),
|
|
396
|
+
):
|
|
397
|
+
"""Save your Groq API key to local config."""
|
|
398
|
+
storage.set_config("groq_api_key", key)
|
|
399
|
+
console.print("[green]✓ Groq API key saved.[/green]")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@config_app.command("show")
|
|
403
|
+
def config_show():
|
|
404
|
+
"""Show current configuration (keys are masked)."""
|
|
405
|
+
cfg = storage.get_config()
|
|
406
|
+
if not cfg:
|
|
407
|
+
console.print("[yellow]No configuration set.[/yellow]")
|
|
408
|
+
return
|
|
409
|
+
for k, v in cfg.items():
|
|
410
|
+
if "key" in k.lower():
|
|
411
|
+
v = v[:8] + "..." if len(v) > 8 else "***"
|
|
412
|
+
console.print(f"[cyan]{k}[/cyan]: {v}")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@config_app.command("path")
|
|
416
|
+
def config_path():
|
|
417
|
+
"""Show the path to cmdclip data directory."""
|
|
418
|
+
console.print(f"[cyan]Data directory:[/cyan] {storage.get_data_dir()}")
|
|
419
|
+
console.print(f"[cyan]Database:[/cyan] {storage.get_db_path()}")
|
|
420
|
+
console.print(f"[cyan]Config:[/cyan] {storage.get_config_path()}")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# ─── Entry point ──────────────────────────────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
def main():
|
|
426
|
+
app()
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
if __name__ == "__main__":
|
|
430
|
+
main()
|