copa-cli 0.4.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {copa_cli-0.4.0 → copa_cli-0.6.0}/PKG-INFO +133 -7
  2. {copa_cli-0.4.0 → copa_cli-0.6.0}/README.md +132 -6
  3. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/cli.py +196 -4
  4. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/cli_internal.py +165 -0
  5. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/cli_llm.py +33 -12
  6. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/config.py +72 -6
  7. copa_cli-0.6.0/copa/copa.zsh +578 -0
  8. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/db.py +3 -0
  9. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa_cli.egg-info/PKG-INFO +133 -7
  10. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa_cli.egg-info/SOURCES.txt +1 -0
  11. {copa_cli-0.4.0 → copa_cli-0.6.0}/pyproject.toml +1 -1
  12. copa_cli-0.6.0/tests/test_modal.py +1045 -0
  13. copa_cli-0.6.0/tests/test_polish.py +211 -0
  14. copa_cli-0.4.0/copa/copa.zsh +0 -197
  15. copa_cli-0.4.0/tests/test_modal.py +0 -216
  16. {copa_cli-0.4.0 → copa_cli-0.6.0}/LICENSE +0 -0
  17. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/__init__.py +0 -0
  18. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/__main__.py +0 -0
  19. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/cli_common.py +0 -0
  20. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/cli_share.py +0 -0
  21. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/evolve.py +0 -0
  22. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/fzf.py +0 -0
  23. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/history.py +0 -0
  24. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/llm.py +0 -0
  25. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/mcp_server.py +0 -0
  26. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/models.py +0 -0
  27. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/scanner.py +0 -0
  28. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/scoring.py +0 -0
  29. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa/sharing.py +0 -0
  30. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa_cli.egg-info/dependency_links.txt +0 -0
  31. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa_cli.egg-info/entry_points.txt +0 -0
  32. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa_cli.egg-info/requires.txt +0 -0
  33. {copa_cli-0.4.0 → copa_cli-0.6.0}/copa_cli.egg-info/top_level.txt +0 -0
  34. {copa_cli-0.4.0 → copa_cli-0.6.0}/setup.cfg +0 -0
  35. {copa_cli-0.4.0 → copa_cli-0.6.0}/tests/test_cli_and_sharing.py +0 -0
  36. {copa_cli-0.4.0 → copa_cli-0.6.0}/tests/test_db.py +0 -0
  37. {copa_cli-0.4.0 → copa_cli-0.6.0}/tests/test_fzf.py +0 -0
  38. {copa_cli-0.4.0 → copa_cli-0.6.0}/tests/test_models.py +0 -0
  39. {copa_cli-0.4.0 → copa_cli-0.6.0}/tests/test_scanner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: copa-cli
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: Command Palette — smart command tracking, ranking, and sharing for your shell
5
5
  Author: Mark Stanford
6
6
  License-Expression: MIT
@@ -52,11 +52,14 @@ Copa tracks the commands you run, ranks them by frequency and recency, and gives
52
52
  - **MCP server** — expose your commands to Claude Code (or any MCP client)
53
53
  - **Zero latency** — precmd hook records usage in the background
54
54
 
55
+ > **Note:** Copa requires **zsh**. It is not compatible with bash, fish, or PowerShell.
56
+
55
57
  ## Install
56
58
 
57
59
  ### Prerequisites
58
60
 
59
61
  - **Python 3.12+**
62
+ - **zsh** — Copa's shell integration (precmd hooks, ZLE widgets, inline suggestions) is zsh-only
60
63
  - **fzf** — required for Ctrl+R command palette
61
64
 
62
65
  ```bash
@@ -210,9 +213,62 @@ This works automatically once `copa.zsh` is sourced — no extra setup needed. T
210
213
 
211
214
  Copa's own CLI completions (`copa li<TAB>` → `list`) continue to work as before via Click's built-in completion.
212
215
 
216
+ ## Inline Suggestions (Ghost Text)
217
+
218
+ Copa shows grey ghost text after your cursor as you type — the best matching command from your database, ranked by frequency and recency. This works like fish shell's autosuggestions or zsh-autosuggestions, with zero plugin dependencies.
219
+
220
+ ### How it works
221
+
222
+ As you type, Copa queries its database for commands starting with your current input and displays the highest-scored match as dim grey text after the cursor. The suggestion updates on every keystroke.
223
+
224
+ ```
225
+ $ git pu█sh origin main ← grey ghost text
226
+ ```
227
+
228
+ ### Keybindings
229
+
230
+ | Key | Suggestion showing | No suggestion |
231
+ |-----|-------------------|---------------|
232
+ | Type chars | Insert char, re-fetch suggestion | Insert char, fetch suggestion |
233
+ | Backspace | Delete char, **latch** (suppress suggestions) | Delete char normally |
234
+ | **Tab** | `tab_accept=1`: accept full suggestion. `tab_accept=2` (default): first Tab highlights suggestion (cyan), second Tab accepts | If latched: unlatch + re-fetch suggestion. Else: normal tab completion |
235
+ | **Down** | Highlight suggestion (enter confirming state) | History navigation |
236
+ | **Right arrow** | Accept one word, re-fetch | Move cursor right |
237
+ | Enter | Clear suggestion, execute | Execute |
238
+ | Esc | Dismiss suggestion | Normal Esc |
239
+ | Up | Clear suggestion, navigate history | History navigation |
240
+ | Ctrl+R | Clear suggestion, open fzf | Open fzf |
241
+
242
+ ### Tab accept mode
243
+
244
+ By default (`tab_accept = 2`), pressing Tab when a suggestion is showing highlights the ghost text (changes from dim grey to cyan/bold) to indicate it's ready to be accepted. Pressing Tab again accepts it into the buffer. Pressing Esc reverts to dim grey without accepting. This two-step flow gives you a visual confirmation before committing.
245
+
246
+ Set `tab_accept = 1` to restore the old behavior where a single Tab directly accepts the suggestion.
247
+
248
+ ### Backspace latch
249
+
250
+ Pressing Backspace clears the current suggestion and **latches** — suppresses further suggestions while you edit. This prevents suggestions from re-appearing as you retype after correcting a mistake. Ctrl+W (backward-kill-word) also latches.
251
+
252
+ Press **Tab** to unlatch and re-enable suggestions. The next new prompt (Enter) also resets the latch automatically.
253
+
254
+ ### Configuration
255
+
256
+ ```toml
257
+ # ~/.copa/config.toml
258
+ [suggest]
259
+ enabled = true # set to false to disable inline suggestions
260
+ min_length = 2 # minimum characters before querying (default: 2)
261
+ tab_accept = 2 # 1 = Tab accepts directly, 2 = Tab opens menu first (default)
262
+ ```
263
+
264
+ Inline suggestions are enabled by default. Set `enabled = false` to disable them entirely (zero performance overhead when disabled).
265
+
213
266
  ## Quick Start
214
267
 
215
268
  ```bash
269
+ # Check your setup
270
+ copa doctor
271
+
216
272
  # Import your shell history
217
273
  copa sync
218
274
 
@@ -222,15 +278,24 @@ copa add "adb shell cmd bluetooth_manager enable" -d "Enable Bluetooth" -g bluet
222
278
  # Add a command with flag documentation
223
279
  copa add "flash_all" -d "Flash AOSP build" -f "--wipe: Wipe userdata" -f "-v: Verbose"
224
280
 
225
- # Create a .copa file from a group (or scaffold an empty one)
226
- copa create -g bluetooth
281
+ # Pin your most important commands to the top
282
+ copa pin 42
283
+
284
+ # Edit a command's metadata
285
+ copa edit 42 -d "New description" -g mygroup
227
286
 
228
287
  # List top commands by score
229
288
  copa list
230
289
 
290
+ # List as JSON (for scripting)
291
+ copa list --json
292
+
231
293
  # Search by keyword
232
294
  copa search bluetooth
233
295
 
296
+ # Create a .copa file from a group (or scaffold an empty one)
297
+ copa create -g bluetooth
298
+
234
299
  # Auto-promote frequent commands from history
235
300
  copa evolve -k 20
236
301
 
@@ -356,7 +421,7 @@ copa create -g bluetooth
356
421
  copa share export bluetooth -o bluetooth.copa
357
422
  ```
358
423
 
359
- Share it with your team (via git, fbsource, or any file share):
424
+ Share it with your team (via git, Slack, or any file share):
360
425
 
361
426
  ```bash
362
427
  copa share load bluetooth.copa
@@ -364,6 +429,27 @@ copa share load /path/to/team/commands.copa
364
429
  copa share sync /path/to/team/copa-files/
365
430
  ```
366
431
 
432
+ ### Example shared sets
433
+
434
+ Copa ships with ready-to-use `.copa` files in the [`examples/`](examples/) directory:
435
+
436
+ | File | Description |
437
+ |------|-------------|
438
+ | [`git.copa`](examples/git.copa) | Essential Git commands |
439
+ | [`docker.copa`](examples/docker.copa) | Docker container management |
440
+ | [`python-dev.copa`](examples/python-dev.copa) | Python development workflow |
441
+ | [`network.copa`](examples/network.copa) | Network diagnostics |
442
+ | [`adb.copa`](examples/adb.copa) | Android Debug Bridge |
443
+ | [`k8s.copa`](examples/k8s.copa) | Kubernetes cluster management |
444
+ | [`sysadmin.copa`](examples/sysadmin.copa) | System administration |
445
+
446
+ Load any of them:
447
+
448
+ ```bash
449
+ copa share load examples/git.copa
450
+ copa share load examples/docker.copa
451
+ ```
452
+
367
453
  ### Filtering by shared set
368
454
 
369
455
  Once you've loaded shared sets, you can scope commands to just that set:
@@ -480,6 +566,41 @@ toggle_header = "ctrl-h" # show/hide key hints
480
566
  [completion]
481
567
  mode = "fallback" # fallback | hybrid | always | never
482
568
  branding = true # show "Copa history" group header
569
+
570
+ # Inline suggestions (ghost text)
571
+ [suggest]
572
+ enabled = true # set to false to disable
573
+ min_length = 2 # minimum chars before querying
574
+ tab_accept = 2 # 1 = accept directly, 2 = open menu first
575
+
576
+ # Composition key behavior (continue vs close)
577
+ # "continue" keys re-open fzf so you can chain another command
578
+ # All other composition keys close fzf immediately
579
+ [composition]
580
+ continue = ["pipe", "chain", "redirect"] # default: |, &&, > re-open fzf
581
+ ```
582
+
583
+ ### Composition key behavior
584
+
585
+ When you press a composition key (like Ctrl-A for `&&`), Copa can either **close fzf** (placing the command + operator in your prompt) or **continue** (appending the operator and re-opening fzf so you can select the next command to chain).
586
+
587
+ By default, "connector" operators re-open fzf:
588
+ - `pipe` (`|`) — continue
589
+ - `chain` (`&&`) — continue
590
+ - `redirect` (`>`) — continue
591
+
592
+ And "terminal" operators close fzf:
593
+ - `background` (`&`) — close
594
+ - `merge_output` (`2>&1`) — close
595
+ - `suppress` (`2>/dev/null`) — close
596
+
597
+ When chaining, the prompt shows your accumulated command: `copa [git pull && ]>`.
598
+
599
+ To revert to the old behavior (all keys close immediately), set:
600
+
601
+ ```toml
602
+ [composition]
603
+ continue = []
483
604
  ```
484
605
 
485
606
  ## CLI Reference
@@ -487,11 +608,15 @@ branding = true # show "Copa history" group header
487
608
  | Command | Purpose |
488
609
  |---------|---------|
489
610
  | `copa add "cmd" -d "desc" -g group -f "flag: desc"` | Save a command (with optional flags) |
490
- | `copa create -g group [-o file]` | Create a .copa file from a group |
491
- | `copa list [-g group] [-s source] [--set name]` | List by score |
492
- | `copa search "query" [-g group] [-s source] [--set name]` | FTS search |
611
+ | `copa edit ID [-d desc] [-g group] [-f flags] [--pin]` | Edit a command's metadata |
493
612
  | `copa remove ID` | Remove a command |
613
+ | `copa pin ID` | Pin a command to the top |
614
+ | `copa unpin ID` | Unpin a command |
615
+ | `copa list [-g group] [-s source] [--set name] [--json]` | List by score |
616
+ | `copa search "query" [-g group] [--set name] [--json]` | FTS search |
617
+ | `copa create -g group [-o file]` | Create a .copa file from a group |
494
618
  | `copa stats` | Usage statistics |
619
+ | `copa doctor` | Check setup and diagnose issues |
495
620
  | `copa sync` | Import from zsh history |
496
621
  | `copa scan [--dir path]` | Import script metadata from $PATH |
497
622
  | `copa evolve [-k 20] [--auto]` | Auto-add frequent commands (with optional LLM descriptions) |
@@ -503,6 +628,7 @@ branding = true # show "Copa history" group header
503
628
  | `copa share list` | List shared sets |
504
629
  | `copa share sync DIR` | Sync .copa files from dir |
505
630
  | `copa import FILE [-g group]` | Import commands from markdown |
631
+ | `copa uninstall` | Remove Copa data and show cleanup steps |
506
632
 
507
633
  ## How Scoring Works
508
634
 
@@ -22,11 +22,14 @@ Copa tracks the commands you run, ranks them by frequency and recency, and gives
22
22
  - **MCP server** — expose your commands to Claude Code (or any MCP client)
23
23
  - **Zero latency** — precmd hook records usage in the background
24
24
 
25
+ > **Note:** Copa requires **zsh**. It is not compatible with bash, fish, or PowerShell.
26
+
25
27
  ## Install
26
28
 
27
29
  ### Prerequisites
28
30
 
29
31
  - **Python 3.12+**
32
+ - **zsh** — Copa's shell integration (precmd hooks, ZLE widgets, inline suggestions) is zsh-only
30
33
  - **fzf** — required for Ctrl+R command palette
31
34
 
32
35
  ```bash
@@ -180,9 +183,62 @@ This works automatically once `copa.zsh` is sourced — no extra setup needed. T
180
183
 
181
184
  Copa's own CLI completions (`copa li<TAB>` → `list`) continue to work as before via Click's built-in completion.
182
185
 
186
+ ## Inline Suggestions (Ghost Text)
187
+
188
+ Copa shows grey ghost text after your cursor as you type — the best matching command from your database, ranked by frequency and recency. This works like fish shell's autosuggestions or zsh-autosuggestions, with zero plugin dependencies.
189
+
190
+ ### How it works
191
+
192
+ As you type, Copa queries its database for commands starting with your current input and displays the highest-scored match as dim grey text after the cursor. The suggestion updates on every keystroke.
193
+
194
+ ```
195
+ $ git pu█sh origin main ← grey ghost text
196
+ ```
197
+
198
+ ### Keybindings
199
+
200
+ | Key | Suggestion showing | No suggestion |
201
+ |-----|-------------------|---------------|
202
+ | Type chars | Insert char, re-fetch suggestion | Insert char, fetch suggestion |
203
+ | Backspace | Delete char, **latch** (suppress suggestions) | Delete char normally |
204
+ | **Tab** | `tab_accept=1`: accept full suggestion. `tab_accept=2` (default): first Tab highlights suggestion (cyan), second Tab accepts | If latched: unlatch + re-fetch suggestion. Else: normal tab completion |
205
+ | **Down** | Highlight suggestion (enter confirming state) | History navigation |
206
+ | **Right arrow** | Accept one word, re-fetch | Move cursor right |
207
+ | Enter | Clear suggestion, execute | Execute |
208
+ | Esc | Dismiss suggestion | Normal Esc |
209
+ | Up | Clear suggestion, navigate history | History navigation |
210
+ | Ctrl+R | Clear suggestion, open fzf | Open fzf |
211
+
212
+ ### Tab accept mode
213
+
214
+ By default (`tab_accept = 2`), pressing Tab when a suggestion is showing highlights the ghost text (changes from dim grey to cyan/bold) to indicate it's ready to be accepted. Pressing Tab again accepts it into the buffer. Pressing Esc reverts to dim grey without accepting. This two-step flow gives you a visual confirmation before committing.
215
+
216
+ Set `tab_accept = 1` to restore the old behavior where a single Tab directly accepts the suggestion.
217
+
218
+ ### Backspace latch
219
+
220
+ Pressing Backspace clears the current suggestion and **latches** — suppresses further suggestions while you edit. This prevents suggestions from re-appearing as you retype after correcting a mistake. Ctrl+W (backward-kill-word) also latches.
221
+
222
+ Press **Tab** to unlatch and re-enable suggestions. The next new prompt (Enter) also resets the latch automatically.
223
+
224
+ ### Configuration
225
+
226
+ ```toml
227
+ # ~/.copa/config.toml
228
+ [suggest]
229
+ enabled = true # set to false to disable inline suggestions
230
+ min_length = 2 # minimum characters before querying (default: 2)
231
+ tab_accept = 2 # 1 = Tab accepts directly, 2 = Tab opens menu first (default)
232
+ ```
233
+
234
+ Inline suggestions are enabled by default. Set `enabled = false` to disable them entirely (zero performance overhead when disabled).
235
+
183
236
  ## Quick Start
184
237
 
185
238
  ```bash
239
+ # Check your setup
240
+ copa doctor
241
+
186
242
  # Import your shell history
187
243
  copa sync
188
244
 
@@ -192,15 +248,24 @@ copa add "adb shell cmd bluetooth_manager enable" -d "Enable Bluetooth" -g bluet
192
248
  # Add a command with flag documentation
193
249
  copa add "flash_all" -d "Flash AOSP build" -f "--wipe: Wipe userdata" -f "-v: Verbose"
194
250
 
195
- # Create a .copa file from a group (or scaffold an empty one)
196
- copa create -g bluetooth
251
+ # Pin your most important commands to the top
252
+ copa pin 42
253
+
254
+ # Edit a command's metadata
255
+ copa edit 42 -d "New description" -g mygroup
197
256
 
198
257
  # List top commands by score
199
258
  copa list
200
259
 
260
+ # List as JSON (for scripting)
261
+ copa list --json
262
+
201
263
  # Search by keyword
202
264
  copa search bluetooth
203
265
 
266
+ # Create a .copa file from a group (or scaffold an empty one)
267
+ copa create -g bluetooth
268
+
204
269
  # Auto-promote frequent commands from history
205
270
  copa evolve -k 20
206
271
 
@@ -326,7 +391,7 @@ copa create -g bluetooth
326
391
  copa share export bluetooth -o bluetooth.copa
327
392
  ```
328
393
 
329
- Share it with your team (via git, fbsource, or any file share):
394
+ Share it with your team (via git, Slack, or any file share):
330
395
 
331
396
  ```bash
332
397
  copa share load bluetooth.copa
@@ -334,6 +399,27 @@ copa share load /path/to/team/commands.copa
334
399
  copa share sync /path/to/team/copa-files/
335
400
  ```
336
401
 
402
+ ### Example shared sets
403
+
404
+ Copa ships with ready-to-use `.copa` files in the [`examples/`](examples/) directory:
405
+
406
+ | File | Description |
407
+ |------|-------------|
408
+ | [`git.copa`](examples/git.copa) | Essential Git commands |
409
+ | [`docker.copa`](examples/docker.copa) | Docker container management |
410
+ | [`python-dev.copa`](examples/python-dev.copa) | Python development workflow |
411
+ | [`network.copa`](examples/network.copa) | Network diagnostics |
412
+ | [`adb.copa`](examples/adb.copa) | Android Debug Bridge |
413
+ | [`k8s.copa`](examples/k8s.copa) | Kubernetes cluster management |
414
+ | [`sysadmin.copa`](examples/sysadmin.copa) | System administration |
415
+
416
+ Load any of them:
417
+
418
+ ```bash
419
+ copa share load examples/git.copa
420
+ copa share load examples/docker.copa
421
+ ```
422
+
337
423
  ### Filtering by shared set
338
424
 
339
425
  Once you've loaded shared sets, you can scope commands to just that set:
@@ -450,6 +536,41 @@ toggle_header = "ctrl-h" # show/hide key hints
450
536
  [completion]
451
537
  mode = "fallback" # fallback | hybrid | always | never
452
538
  branding = true # show "Copa history" group header
539
+
540
+ # Inline suggestions (ghost text)
541
+ [suggest]
542
+ enabled = true # set to false to disable
543
+ min_length = 2 # minimum chars before querying
544
+ tab_accept = 2 # 1 = accept directly, 2 = open menu first
545
+
546
+ # Composition key behavior (continue vs close)
547
+ # "continue" keys re-open fzf so you can chain another command
548
+ # All other composition keys close fzf immediately
549
+ [composition]
550
+ continue = ["pipe", "chain", "redirect"] # default: |, &&, > re-open fzf
551
+ ```
552
+
553
+ ### Composition key behavior
554
+
555
+ When you press a composition key (like Ctrl-A for `&&`), Copa can either **close fzf** (placing the command + operator in your prompt) or **continue** (appending the operator and re-opening fzf so you can select the next command to chain).
556
+
557
+ By default, "connector" operators re-open fzf:
558
+ - `pipe` (`|`) — continue
559
+ - `chain` (`&&`) — continue
560
+ - `redirect` (`>`) — continue
561
+
562
+ And "terminal" operators close fzf:
563
+ - `background` (`&`) — close
564
+ - `merge_output` (`2>&1`) — close
565
+ - `suppress` (`2>/dev/null`) — close
566
+
567
+ When chaining, the prompt shows your accumulated command: `copa [git pull && ]>`.
568
+
569
+ To revert to the old behavior (all keys close immediately), set:
570
+
571
+ ```toml
572
+ [composition]
573
+ continue = []
453
574
  ```
454
575
 
455
576
  ## CLI Reference
@@ -457,11 +578,15 @@ branding = true # show "Copa history" group header
457
578
  | Command | Purpose |
458
579
  |---------|---------|
459
580
  | `copa add "cmd" -d "desc" -g group -f "flag: desc"` | Save a command (with optional flags) |
460
- | `copa create -g group [-o file]` | Create a .copa file from a group |
461
- | `copa list [-g group] [-s source] [--set name]` | List by score |
462
- | `copa search "query" [-g group] [-s source] [--set name]` | FTS search |
581
+ | `copa edit ID [-d desc] [-g group] [-f flags] [--pin]` | Edit a command's metadata |
463
582
  | `copa remove ID` | Remove a command |
583
+ | `copa pin ID` | Pin a command to the top |
584
+ | `copa unpin ID` | Unpin a command |
585
+ | `copa list [-g group] [-s source] [--set name] [--json]` | List by score |
586
+ | `copa search "query" [-g group] [--set name] [--json]` | FTS search |
587
+ | `copa create -g group [-o file]` | Create a .copa file from a group |
464
588
  | `copa stats` | Usage statistics |
589
+ | `copa doctor` | Check setup and diagnose issues |
465
590
  | `copa sync` | Import from zsh history |
466
591
  | `copa scan [--dir path]` | Import script metadata from $PATH |
467
592
  | `copa evolve [-k 20] [--auto]` | Auto-add frequent commands (with optional LLM descriptions) |
@@ -473,6 +598,7 @@ branding = true # show "Copa history" group header
473
598
  | `copa share list` | List shared sets |
474
599
  | `copa share sync DIR` | Sync .copa files from dir |
475
600
  | `copa import FILE [-g group]` | Import commands from markdown |
601
+ | `copa uninstall` | Remove Copa data and show cleanup steps |
476
602
 
477
603
  ## How Scoring Works
478
604
 
@@ -2,14 +2,41 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
6
+ import shutil
5
7
  import sys
6
8
  from pathlib import Path
7
9
 
8
10
  import click
9
11
 
10
12
  from .cli_common import complete_group, complete_shared_set, complete_source, get_db
13
+ from .models import Command
11
14
  from .scoring import rank_commands
12
15
 
16
+
17
+ def _cmd_to_json(cmd: Command) -> dict:
18
+ """Convert a Command to a JSON-serializable dict."""
19
+ d: dict = {
20
+ "id": cmd.id,
21
+ "command": cmd.command,
22
+ "description": cmd.description,
23
+ "frequency": cmd.frequency,
24
+ "score": round(cmd.score, 2),
25
+ "source": cmd.source,
26
+ }
27
+ if cmd.group_name:
28
+ d["group"] = cmd.group_name
29
+ if cmd.shared_set:
30
+ d["shared_set"] = cmd.shared_set
31
+ if cmd.is_pinned:
32
+ d["pinned"] = True
33
+ if cmd.tags:
34
+ d["tags"] = cmd.tags
35
+ if cmd.flags:
36
+ d["flags"] = cmd.flags
37
+ return d
38
+
39
+
13
40
  # --- Main group ---
14
41
 
15
42
 
@@ -127,7 +154,15 @@ def add(command: str, description: str, group: str | None, tag: tuple[str, ...],
127
154
  @click.option("-s", "--source", default=None, help="Filter by source.", shell_complete=complete_source)
128
155
  @click.option("--set", "shared_set", default=None, help="Filter by shared set.", shell_complete=complete_shared_set)
129
156
  @click.option("--needs-desc", is_flag=True, help="Show only commands needing description.")
130
- def list_cmd(group: str | None, limit: int, source: str | None, shared_set: str | None, needs_desc: bool):
157
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
158
+ def list_cmd(
159
+ group: str | None,
160
+ limit: int,
161
+ source: str | None,
162
+ shared_set: str | None,
163
+ needs_desc: bool,
164
+ as_json: bool,
165
+ ):
131
166
  """List commands ranked by score."""
132
167
  db = get_db()
133
168
  commands = db.list_commands(
@@ -139,7 +174,14 @@ def list_cmd(group: str | None, limit: int, source: str | None, shared_set: str
139
174
  )
140
175
  ranked = rank_commands(commands)
141
176
  if not ranked:
142
- click.echo("No commands found.")
177
+ if as_json:
178
+ click.echo("[]")
179
+ else:
180
+ click.echo("No commands found.")
181
+ return
182
+
183
+ if as_json:
184
+ click.echo(json.dumps([_cmd_to_json(c) for c in ranked], indent=2))
143
185
  return
144
186
 
145
187
  for cmd in ranked:
@@ -170,13 +212,21 @@ def list_cmd(group: str | None, limit: int, source: str | None, shared_set: str
170
212
  @click.option("-s", "--source", default=None, help="Filter by source.", shell_complete=complete_source)
171
213
  @click.option("--set", "shared_set", default=None, help="Filter by shared set.", shell_complete=complete_shared_set)
172
214
  @click.option("-n", "--limit", default=20, help="Max results.")
173
- def search(query: str, group: str | None, source: str | None, shared_set: str | None, limit: int):
215
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
216
+ def search(query: str, group: str | None, source: str | None, shared_set: str | None, limit: int, as_json: bool):
174
217
  """Search commands by keyword (FTS)."""
175
218
  db = get_db()
176
219
  commands = db.search_commands(query, group_name=group, source=source, shared_set=shared_set, limit=limit)
177
220
  ranked = rank_commands(commands)
178
221
  if not ranked:
179
- click.echo(f"No commands matching '{query}'.")
222
+ if as_json:
223
+ click.echo("[]")
224
+ else:
225
+ click.echo(f"No commands matching '{query}'.")
226
+ return
227
+
228
+ if as_json:
229
+ click.echo(json.dumps([_cmd_to_json(c) for c in ranked], indent=2))
180
230
  return
181
231
 
182
232
  for cmd in ranked:
@@ -261,6 +311,148 @@ def scan(directory: str | None):
261
311
  click.echo(f"Scanned: {added} scripts added.")
262
312
 
263
313
 
314
+ # --- pin / unpin ---
315
+
316
+
317
+ @cli.command()
318
+ @click.argument("cmd_id", type=int)
319
+ def pin(cmd_id: int):
320
+ """Pin a command so it always appears at the top."""
321
+ db = get_db()
322
+ cmd = db.get_command(cmd_id)
323
+ if not cmd:
324
+ click.echo(f"Command {cmd_id} not found.", err=True)
325
+ sys.exit(1)
326
+ db.pin_command(cmd_id, True)
327
+ click.echo(f"Pinned [{cmd_id}]: {cmd.command}")
328
+
329
+
330
+ @cli.command()
331
+ @click.argument("cmd_id", type=int)
332
+ def unpin(cmd_id: int):
333
+ """Unpin a command."""
334
+ db = get_db()
335
+ cmd = db.get_command(cmd_id)
336
+ if not cmd:
337
+ click.echo(f"Command {cmd_id} not found.", err=True)
338
+ sys.exit(1)
339
+ db.pin_command(cmd_id, False)
340
+ click.echo(f"Unpinned [{cmd_id}]: {cmd.command}")
341
+
342
+
343
+ # --- edit ---
344
+
345
+
346
+ @cli.command()
347
+ @click.argument("cmd_id", type=int)
348
+ @click.option("-d", "--description", default=None, help="New description.")
349
+ @click.option("-g", "--group", default=None, help="New group name (use '' to clear).", shell_complete=complete_group)
350
+ @click.option("-f", "--flag", multiple=True, help="Flag docs as 'flag: description' (repeatable, replaces all flags).")
351
+ @click.option("-p", "--pin/--no-pin", default=None, help="Pin or unpin the command.")
352
+ def edit(cmd_id: int, description: str | None, group: str | None, flag: tuple[str, ...], pin: bool | None):
353
+ """Edit a command's metadata by ID."""
354
+ db = get_db()
355
+ cmd = db.get_command(cmd_id)
356
+ if not cmd:
357
+ click.echo(f"Command {cmd_id} not found.", err=True)
358
+ sys.exit(1)
359
+
360
+ changes = []
361
+ if description is not None:
362
+ db.update_description(cmd_id, description)
363
+ changes.append(f"description: {description}")
364
+ if group is not None:
365
+ group_name = group if group else None
366
+ db.update_group(cmd_id, group_name)
367
+ changes.append(f"group: {group_name or '(none)'}")
368
+ if flag:
369
+ flags: dict[str, str] = {}
370
+ for f in flag:
371
+ parts = f.split(":", 1)
372
+ flag_name = parts[0].strip()
373
+ flag_desc = parts[1].strip() if len(parts) > 1 else ""
374
+ flags[flag_name] = flag_desc
375
+ db.update_flags(cmd_id, flags)
376
+ changes.append(f"flags: {len(flags)} documented")
377
+ if pin is not None:
378
+ db.pin_command(cmd_id, pin)
379
+ changes.append("pinned" if pin else "unpinned")
380
+
381
+ if not changes:
382
+ click.echo(f"[{cmd_id}] {cmd.command} — nothing to change (use -d, -g, -f, or --pin)")
383
+ return
384
+
385
+ click.echo(f"Updated [{cmd_id}]: {cmd.command}")
386
+ for c in changes:
387
+ click.echo(f" → {c}")
388
+
389
+
390
+ # --- doctor ---
391
+
392
+
393
+ @cli.command()
394
+ def doctor():
395
+ """Check Copa setup and diagnose common issues."""
396
+ ok_mark = click.style("OK", fg="green")
397
+ warn_mark = click.style("!!", fg="yellow")
398
+ fail_mark = click.style("FAIL", fg="red")
399
+
400
+ click.echo("Copa Doctor\n")
401
+
402
+ # 1. Database
403
+ db_path = Path.home() / ".copa" / "copa.db"
404
+ if db_path.is_file():
405
+ size = db_path.stat().st_size
406
+ click.echo(f" [{ok_mark}] Database: {db_path} ({size:,} bytes)")
407
+ db = get_db()
408
+ s = db.get_stats()
409
+ click.echo(f" {s['total_commands']} commands, {s['total_groups']} groups, {s['shared_sets']} shared sets")
410
+ else:
411
+ click.echo(f" [{fail_mark}] Database: not found at {db_path}")
412
+ click.echo(" Run: copa _init")
413
+
414
+ # 2. fzf
415
+ if shutil.which("fzf"):
416
+ click.echo(f" [{ok_mark}] fzf: installed")
417
+ else:
418
+ click.echo(f" [{fail_mark}] fzf: not found")
419
+ click.echo(" Install: brew install fzf")
420
+
421
+ # 3. Shell integration
422
+ zshrc = Path.home() / ".zshrc"
423
+ if zshrc.is_file() and "copa init zsh" in zshrc.read_text():
424
+ click.echo(f" [{ok_mark}] Shell integration: found in ~/.zshrc")
425
+ else:
426
+ click.echo(f" [{warn_mark}] Shell integration: not detected in ~/.zshrc")
427
+ click.echo(' Add: eval "$(copa init zsh)"')
428
+
429
+ # 4. LLM backend
430
+ if db_path.is_file():
431
+ db = get_db()
432
+ backend = db.get_meta("llm_backend")
433
+ if backend:
434
+ click.echo(f" [{ok_mark}] LLM backend: {backend}")
435
+ if backend == "ollama":
436
+ model = db.get_meta("ollama_model") or "llama3.2:3b"
437
+ click.echo(f" Model: {model}")
438
+ else:
439
+ click.echo(f" [{warn_mark}] LLM backend: not configured")
440
+ click.echo(" Run: copa configure")
441
+
442
+ # 5. Completion mode
443
+ config_path = Path.home() / ".copa" / "config.toml"
444
+ if config_path.is_file():
445
+ from .config import load_config
446
+
447
+ cfg = load_config(config_path)
448
+ mode = cfg.get("_completion_mode", "fallback")
449
+ click.echo(f" [{ok_mark}] Completion mode: {mode}")
450
+ else:
451
+ click.echo(f" [{ok_mark}] Completion mode: fallback (default)")
452
+
453
+ click.echo()
454
+
455
+
264
456
  # --- Register extracted command modules ---
265
457
 
266
458
  from . import cli_internal, cli_llm, cli_share