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 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()