copa-cli 0.2.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.
- copa/__init__.py +3 -0
- copa/__main__.py +5 -0
- copa/cli.py +216 -0
- copa/cli_common.py +43 -0
- copa/cli_internal.py +334 -0
- copa/cli_llm.py +212 -0
- copa/cli_share.py +256 -0
- copa/config.py +188 -0
- copa/db.py +464 -0
- copa/evolve.py +111 -0
- copa/fzf.py +235 -0
- copa/history.py +132 -0
- copa/llm.py +128 -0
- copa/mcp_server.py +159 -0
- copa/models.py +112 -0
- copa/scanner.py +153 -0
- copa/scoring.py +45 -0
- copa/sharing.py +154 -0
- copa_cli-0.2.0.dist-info/METADATA +465 -0
- copa_cli-0.2.0.dist-info/RECORD +24 -0
- copa_cli-0.2.0.dist-info/WHEEL +5 -0
- copa_cli-0.2.0.dist-info/entry_points.txt +2 -0
- copa_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- copa_cli-0.2.0.dist-info/top_level.txt +1 -0
copa/__init__.py
ADDED
copa/__main__.py
ADDED
copa/cli.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Click CLI for Copa — Command Palette."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from .cli_common import complete_group, complete_shared_set, complete_source, get_db
|
|
11
|
+
from .scoring import rank_commands
|
|
12
|
+
|
|
13
|
+
# --- Main group ---
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
@click.version_option(package_name="copa-cli")
|
|
18
|
+
def cli():
|
|
19
|
+
"""Copa — Command Palette. Smart command tracking, ranking, and sharing."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --- add ---
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@cli.command()
|
|
27
|
+
@click.argument("command")
|
|
28
|
+
@click.option("-d", "--description", default="", help="Description of the command.")
|
|
29
|
+
@click.option("-g", "--group", default=None, help="Group name.", shell_complete=complete_group)
|
|
30
|
+
@click.option("-t", "--tag", multiple=True, help="Tags (can be repeated).")
|
|
31
|
+
@click.option("-p", "--pin", is_flag=True, help="Pin this command.")
|
|
32
|
+
@click.option("-f", "--flag", multiple=True, help="Flag docs as 'flag: description' (repeatable).")
|
|
33
|
+
def add(command: str, description: str, group: str | None, tag: tuple[str, ...], pin: bool, flag: tuple[str, ...]):
|
|
34
|
+
"""Save a command with optional description and group."""
|
|
35
|
+
db = get_db()
|
|
36
|
+
|
|
37
|
+
# Parse --flag options into a dict
|
|
38
|
+
flags: dict[str, str] = {}
|
|
39
|
+
for f in flag:
|
|
40
|
+
parts = f.split(":", 1)
|
|
41
|
+
flag_name = parts[0].strip()
|
|
42
|
+
flag_desc = parts[1].strip() if len(parts) > 1 else ""
|
|
43
|
+
flags[flag_name] = flag_desc
|
|
44
|
+
|
|
45
|
+
cmd_id = db.add_command(
|
|
46
|
+
command=command,
|
|
47
|
+
description=description,
|
|
48
|
+
group_name=group,
|
|
49
|
+
tags=list(tag) if tag else None,
|
|
50
|
+
flags=flags if flags else None,
|
|
51
|
+
)
|
|
52
|
+
if pin:
|
|
53
|
+
db.pin_command(cmd_id, True)
|
|
54
|
+
click.echo(f"Added [{cmd_id}]: {command}")
|
|
55
|
+
if description:
|
|
56
|
+
click.echo(f" → {description}")
|
|
57
|
+
if flags:
|
|
58
|
+
click.echo(f" flags: {len(flags)} documented")
|
|
59
|
+
if group:
|
|
60
|
+
click.echo(f" group: {group}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- list ---
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@cli.command("list")
|
|
67
|
+
@click.option("-g", "--group", default=None, help="Filter by group.", shell_complete=complete_group)
|
|
68
|
+
@click.option("-n", "--limit", default=20, help="Number of commands to show.")
|
|
69
|
+
@click.option("-s", "--source", default=None, help="Filter by source.", shell_complete=complete_source)
|
|
70
|
+
@click.option("--set", "shared_set", default=None, help="Filter by shared set.", shell_complete=complete_shared_set)
|
|
71
|
+
@click.option("--needs-desc", is_flag=True, help="Show only commands needing description.")
|
|
72
|
+
def list_cmd(group: str | None, limit: int, source: str | None, shared_set: str | None, needs_desc: bool):
|
|
73
|
+
"""List commands ranked by score."""
|
|
74
|
+
db = get_db()
|
|
75
|
+
commands = db.list_commands(
|
|
76
|
+
group_name=group,
|
|
77
|
+
limit=limit,
|
|
78
|
+
source=source,
|
|
79
|
+
needs_description=True if needs_desc else None,
|
|
80
|
+
shared_set=shared_set,
|
|
81
|
+
)
|
|
82
|
+
ranked = rank_commands(commands)
|
|
83
|
+
if not ranked:
|
|
84
|
+
click.echo("No commands found.")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
for cmd in ranked:
|
|
88
|
+
badge = ""
|
|
89
|
+
if cmd.shared_set:
|
|
90
|
+
badge = click.style(f" [shared:{cmd.shared_set}]", fg="cyan")
|
|
91
|
+
elif cmd.group_name:
|
|
92
|
+
badge = click.style(f" [{cmd.group_name}]", fg="magenta")
|
|
93
|
+
if cmd.is_pinned:
|
|
94
|
+
badge += click.style(" [pinned]", fg="yellow")
|
|
95
|
+
|
|
96
|
+
desc = ""
|
|
97
|
+
if cmd.description:
|
|
98
|
+
desc = click.style(f" — {cmd.description}", dim=True)
|
|
99
|
+
|
|
100
|
+
freq = click.style(f" ({cmd.frequency}×)", dim=True)
|
|
101
|
+
score_str = click.style(f" s={cmd.score:.1f}", dim=True)
|
|
102
|
+
|
|
103
|
+
click.echo(f" [{cmd.id:>4}] {cmd.command}{desc}{badge}{freq}{score_str}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- search ---
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@cli.command()
|
|
110
|
+
@click.argument("query")
|
|
111
|
+
@click.option("-g", "--group", default=None, help="Filter by group.", shell_complete=complete_group)
|
|
112
|
+
@click.option("-s", "--source", default=None, help="Filter by source.", shell_complete=complete_source)
|
|
113
|
+
@click.option("--set", "shared_set", default=None, help="Filter by shared set.", shell_complete=complete_shared_set)
|
|
114
|
+
@click.option("-n", "--limit", default=20, help="Max results.")
|
|
115
|
+
def search(query: str, group: str | None, source: str | None, shared_set: str | None, limit: int):
|
|
116
|
+
"""Search commands by keyword (FTS)."""
|
|
117
|
+
db = get_db()
|
|
118
|
+
commands = db.search_commands(query, group_name=group, source=source, shared_set=shared_set, limit=limit)
|
|
119
|
+
ranked = rank_commands(commands)
|
|
120
|
+
if not ranked:
|
|
121
|
+
click.echo(f"No commands matching '{query}'.")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
for cmd in ranked:
|
|
125
|
+
badge = ""
|
|
126
|
+
if cmd.group_name:
|
|
127
|
+
badge = click.style(f" [{cmd.group_name}]", fg="magenta")
|
|
128
|
+
desc = ""
|
|
129
|
+
if cmd.description:
|
|
130
|
+
desc = click.style(f" — {cmd.description}", dim=True)
|
|
131
|
+
click.echo(f" [{cmd.id:>4}] {cmd.command}{desc}{badge}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# --- remove ---
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@cli.command()
|
|
138
|
+
@click.argument("cmd_id", type=int)
|
|
139
|
+
def remove(cmd_id: int):
|
|
140
|
+
"""Remove a command by ID."""
|
|
141
|
+
db = get_db()
|
|
142
|
+
cmd = db.get_command(cmd_id)
|
|
143
|
+
if not cmd:
|
|
144
|
+
click.echo(f"Command {cmd_id} not found.", err=True)
|
|
145
|
+
sys.exit(1)
|
|
146
|
+
db.remove_command(cmd_id)
|
|
147
|
+
click.echo(f"Removed [{cmd_id}]: {cmd.command}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --- stats ---
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@cli.command()
|
|
154
|
+
def stats():
|
|
155
|
+
"""Show usage statistics."""
|
|
156
|
+
db = get_db()
|
|
157
|
+
s = db.get_stats()
|
|
158
|
+
click.echo(f"Commands: {s['total_commands']}")
|
|
159
|
+
click.echo(f"Total uses: {s['total_uses']}")
|
|
160
|
+
click.echo(f"Groups: {s['total_groups']}")
|
|
161
|
+
click.echo(f"Shared sets: {s['shared_sets']}")
|
|
162
|
+
click.echo(f"Pinned: {s['pinned']}")
|
|
163
|
+
click.echo(f"Need desc: {s['needs_description']}")
|
|
164
|
+
if s.get("by_source"):
|
|
165
|
+
click.echo("By source:")
|
|
166
|
+
for source, count in s["by_source"].items():
|
|
167
|
+
click.echo(f" {source}: {count}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --- sync ---
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@cli.command()
|
|
174
|
+
@click.option("--history", type=click.Path(exists=True), default=None, help="Path to zsh history file.")
|
|
175
|
+
def sync(history: str | None):
|
|
176
|
+
"""Backfill from ~/.zsh_history."""
|
|
177
|
+
from .history import sync_history
|
|
178
|
+
|
|
179
|
+
db = get_db()
|
|
180
|
+
history_path = Path(history) if history else None
|
|
181
|
+
added = sync_history(db, history_path)
|
|
182
|
+
click.echo(f"Synced: {added} new commands from history.")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --- scan ---
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@cli.command()
|
|
189
|
+
@click.option(
|
|
190
|
+
"--dir",
|
|
191
|
+
"directory",
|
|
192
|
+
type=click.Path(exists=True),
|
|
193
|
+
default=None,
|
|
194
|
+
help="Directory to scan (default: all $PATH directories).",
|
|
195
|
+
)
|
|
196
|
+
def scan(directory: str | None):
|
|
197
|
+
"""Import script metadata from $PATH (supports #@ Description/Usage headers)."""
|
|
198
|
+
from .scanner import scan_directory
|
|
199
|
+
|
|
200
|
+
db = get_db()
|
|
201
|
+
dir_path = Path(directory) if directory else None
|
|
202
|
+
added = scan_directory(db, dir_path)
|
|
203
|
+
click.echo(f"Scanned: {added} scripts added.")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# --- Register extracted command modules ---
|
|
207
|
+
|
|
208
|
+
from . import cli_internal, cli_llm, cli_share
|
|
209
|
+
|
|
210
|
+
cli_llm.register(cli)
|
|
211
|
+
cli_share.register(cli)
|
|
212
|
+
cli_internal.register(cli)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
cli()
|
copa/cli_common.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Shared utilities for Copa CLI modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from click.shell_completion import CompletionItem
|
|
6
|
+
|
|
7
|
+
from .db import Database
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_db() -> Database:
|
|
11
|
+
db = Database()
|
|
12
|
+
db.init_db()
|
|
13
|
+
return db
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# --- Shell completion helpers ---
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def complete_group(ctx, param, incomplete):
|
|
20
|
+
"""Complete group names from the database."""
|
|
21
|
+
try:
|
|
22
|
+
db = get_db()
|
|
23
|
+
return [CompletionItem(g) for g in db.get_groups() if g.startswith(incomplete)]
|
|
24
|
+
except Exception:
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def complete_shared_set(ctx, param, incomplete):
|
|
29
|
+
"""Complete shared set names from the database."""
|
|
30
|
+
try:
|
|
31
|
+
db = get_db()
|
|
32
|
+
return [CompletionItem(s.name) for s in db.get_shared_sets() if s.name.startswith(incomplete)]
|
|
33
|
+
except Exception:
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def complete_source(ctx, param, incomplete):
|
|
38
|
+
"""Complete source values from the database."""
|
|
39
|
+
try:
|
|
40
|
+
db = get_db()
|
|
41
|
+
return [CompletionItem(s) for s in db.get_sources() if s.startswith(incomplete)]
|
|
42
|
+
except Exception:
|
|
43
|
+
return []
|
copa/cli_internal.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Hidden/internal CLI commands for Copa shell integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from .cli_common import complete_group, complete_shared_set, get_db
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _open_tty():
|
|
13
|
+
"""Open /dev/tty with echo enabled for use inside fzf execute() bindings.
|
|
14
|
+
|
|
15
|
+
fzf disables terminal echo before launching execute() subcommands.
|
|
16
|
+
We re-enable it so users can see what they type.
|
|
17
|
+
|
|
18
|
+
Returns (tty_file, original_termios) or (None, None) on failure.
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
tty = open("/dev/tty", "r+")
|
|
22
|
+
except OSError:
|
|
23
|
+
return None, None
|
|
24
|
+
|
|
25
|
+
old_attrs = None
|
|
26
|
+
try:
|
|
27
|
+
import termios
|
|
28
|
+
|
|
29
|
+
fd = tty.fileno()
|
|
30
|
+
old_attrs = termios.tcgetattr(fd)
|
|
31
|
+
new_attrs = termios.tcgetattr(fd)
|
|
32
|
+
# Enable echo (ECHO) and canonical mode (ICANON) for line-buffered input
|
|
33
|
+
new_attrs[3] |= termios.ECHO | termios.ICANON
|
|
34
|
+
termios.tcsetattr(fd, termios.TCSANOW, new_attrs)
|
|
35
|
+
except (ImportError, termios.error):
|
|
36
|
+
pass # termios not available (non-Unix) — proceed without echo fix
|
|
37
|
+
|
|
38
|
+
return tty, old_attrs
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _close_tty(tty, old_attrs):
|
|
42
|
+
"""Restore terminal attributes and close the tty file."""
|
|
43
|
+
if tty is None:
|
|
44
|
+
return
|
|
45
|
+
if old_attrs is not None:
|
|
46
|
+
try:
|
|
47
|
+
import termios
|
|
48
|
+
|
|
49
|
+
termios.tcsetattr(tty.fileno(), termios.TCSANOW, old_attrs)
|
|
50
|
+
except (ImportError, termios.error):
|
|
51
|
+
pass
|
|
52
|
+
tty.close()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@click.command("_record", hidden=True)
|
|
56
|
+
@click.argument("command")
|
|
57
|
+
def record(command: str):
|
|
58
|
+
"""Record a command usage (called by precmd hook)."""
|
|
59
|
+
db = get_db()
|
|
60
|
+
db.record_usage(command)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@click.command("_init", hidden=True)
|
|
64
|
+
def init():
|
|
65
|
+
"""Initialize the Copa database."""
|
|
66
|
+
get_db()
|
|
67
|
+
click.echo("Copa database initialized.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@click.command("fzf-list", hidden=True)
|
|
71
|
+
@click.option("--mode", default="all", type=click.Choice(["all", "frequent", "recent", "group", "set"]))
|
|
72
|
+
@click.option("--group", default=None, shell_complete=complete_group)
|
|
73
|
+
@click.option("--set", "shared_set", default=None, help="Filter by shared set.", shell_complete=complete_shared_set)
|
|
74
|
+
def fzf_list_cmd(mode: str, group: str | None, shared_set: str | None):
|
|
75
|
+
"""Output formatted lines for fzf."""
|
|
76
|
+
from .fzf import fzf_list
|
|
77
|
+
|
|
78
|
+
db = get_db()
|
|
79
|
+
lines = fzf_list(db, mode=mode, group=group, shared_set=shared_set)
|
|
80
|
+
for line in lines:
|
|
81
|
+
click.echo(line)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@click.command("_preview", hidden=True)
|
|
85
|
+
@click.argument("cmd_id", type=int)
|
|
86
|
+
def preview(cmd_id: int):
|
|
87
|
+
"""Rich preview for fzf preview pane."""
|
|
88
|
+
from .fzf import format_preview
|
|
89
|
+
|
|
90
|
+
db = get_db()
|
|
91
|
+
cmd = db.get_command(cmd_id)
|
|
92
|
+
if not cmd:
|
|
93
|
+
click.echo(f"Command {cmd_id} not found.")
|
|
94
|
+
return
|
|
95
|
+
from .scoring import compute_score
|
|
96
|
+
|
|
97
|
+
cmd.score = compute_score(cmd)
|
|
98
|
+
click.echo(format_preview(cmd))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@click.command("_set-group", hidden=True)
|
|
102
|
+
@click.argument("cmd_id", type=int)
|
|
103
|
+
def set_group(cmd_id: int):
|
|
104
|
+
"""Assign or change the group for a command (called by fzf execute binding)."""
|
|
105
|
+
db = get_db()
|
|
106
|
+
cmd = db.get_command(cmd_id)
|
|
107
|
+
if not cmd:
|
|
108
|
+
click.echo(f"Command {cmd_id} not found.", err=True)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
tty, old_attrs = _open_tty()
|
|
112
|
+
|
|
113
|
+
def tty_write(msg: str):
|
|
114
|
+
if tty:
|
|
115
|
+
tty.write(msg + "\n")
|
|
116
|
+
tty.flush()
|
|
117
|
+
else:
|
|
118
|
+
click.echo(msg)
|
|
119
|
+
|
|
120
|
+
def tty_read(prompt: str) -> str:
|
|
121
|
+
if tty:
|
|
122
|
+
tty.write(prompt)
|
|
123
|
+
tty.flush()
|
|
124
|
+
return tty.readline().rstrip("\n")
|
|
125
|
+
return input(prompt)
|
|
126
|
+
|
|
127
|
+
tty_write(f" Command: {cmd.command}")
|
|
128
|
+
current = cmd.group_name or "(none)"
|
|
129
|
+
tty_write(f" Current group: {current}")
|
|
130
|
+
|
|
131
|
+
groups = db.get_groups()
|
|
132
|
+
if groups:
|
|
133
|
+
tty_write(f" Existing groups: {', '.join(groups)}")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
name = tty_read(" Group name (empty=clear, q=cancel): ").strip()
|
|
137
|
+
except (EOFError, KeyboardInterrupt):
|
|
138
|
+
_close_tty(tty, old_attrs)
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
if name.lower() == "q":
|
|
142
|
+
_close_tty(tty, old_attrs)
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
group_name = name if name else None
|
|
146
|
+
ok = db.update_group(cmd.id, group_name)
|
|
147
|
+
if ok:
|
|
148
|
+
label = group_name or "(none)"
|
|
149
|
+
tty_write(click.style(f" → group set to: {label}", fg="green"))
|
|
150
|
+
else:
|
|
151
|
+
tty_write(click.style(f" ✗ command already exists in group '{group_name}'", fg="red"))
|
|
152
|
+
|
|
153
|
+
_close_tty(tty, old_attrs)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@click.command("_set-flags", hidden=True)
|
|
157
|
+
@click.argument("cmd_id", type=int)
|
|
158
|
+
def set_flags(cmd_id: int):
|
|
159
|
+
"""Add or edit flags for a command (called by fzf execute binding)."""
|
|
160
|
+
db = get_db()
|
|
161
|
+
cmd = db.get_command(cmd_id)
|
|
162
|
+
if not cmd:
|
|
163
|
+
click.echo(f"Command {cmd_id} not found.", err=True)
|
|
164
|
+
sys.exit(1)
|
|
165
|
+
|
|
166
|
+
tty, old_attrs = _open_tty()
|
|
167
|
+
|
|
168
|
+
def tty_write(msg: str):
|
|
169
|
+
if tty:
|
|
170
|
+
tty.write(msg + "\n")
|
|
171
|
+
tty.flush()
|
|
172
|
+
else:
|
|
173
|
+
click.echo(msg)
|
|
174
|
+
|
|
175
|
+
def tty_read(prompt: str) -> str:
|
|
176
|
+
if tty:
|
|
177
|
+
tty.write(prompt)
|
|
178
|
+
tty.flush()
|
|
179
|
+
return tty.readline().rstrip("\n")
|
|
180
|
+
return input(prompt)
|
|
181
|
+
|
|
182
|
+
tty_write(f" Command: {cmd.command}")
|
|
183
|
+
|
|
184
|
+
flags = dict(cmd.flags) # copy existing flags
|
|
185
|
+
|
|
186
|
+
if flags:
|
|
187
|
+
tty_write(" Current flags:")
|
|
188
|
+
for flag, desc in flags.items():
|
|
189
|
+
tty_write(f" {flag}: {desc}")
|
|
190
|
+
else:
|
|
191
|
+
tty_write(" No flags yet.")
|
|
192
|
+
|
|
193
|
+
tty_write(" Add flags (empty flag name = done, q = cancel):")
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
while True:
|
|
197
|
+
flag_name = tty_read(" Flag name: ").strip()
|
|
198
|
+
if not flag_name:
|
|
199
|
+
break
|
|
200
|
+
if flag_name.lower() == "q":
|
|
201
|
+
_close_tty(tty, old_attrs)
|
|
202
|
+
return
|
|
203
|
+
flag_desc = tty_read(" Description: ").strip()
|
|
204
|
+
flags[flag_name] = flag_desc
|
|
205
|
+
tty_write(click.style(f" + {flag_name}: {flag_desc}", fg="green"))
|
|
206
|
+
except (EOFError, KeyboardInterrupt):
|
|
207
|
+
_close_tty(tty, old_attrs)
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
if flags != cmd.flags:
|
|
211
|
+
db.update_flags(cmd.id, flags)
|
|
212
|
+
tty_write(click.style(f" → {len(flags)} flag(s) saved", fg="green"))
|
|
213
|
+
else:
|
|
214
|
+
tty_write(" No changes.")
|
|
215
|
+
|
|
216
|
+
_close_tty(tty, old_attrs)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@click.command("_list-groups", hidden=True)
|
|
220
|
+
def list_groups():
|
|
221
|
+
"""Output group names for fzf group picker."""
|
|
222
|
+
db = get_db()
|
|
223
|
+
click.echo("(all)")
|
|
224
|
+
for g in db.get_groups():
|
|
225
|
+
click.echo(g)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@click.command("_next-group", hidden=True)
|
|
229
|
+
@click.argument("current", default="(all)")
|
|
230
|
+
def next_group(current: str):
|
|
231
|
+
"""Output the next group in the cycle: (all) → g1 → g2 → ... → (all)."""
|
|
232
|
+
db = get_db()
|
|
233
|
+
groups = ["(all)"] + db.get_groups()
|
|
234
|
+
try:
|
|
235
|
+
idx = groups.index(current)
|
|
236
|
+
except ValueError:
|
|
237
|
+
idx = -1
|
|
238
|
+
next_idx = (idx + 1) % len(groups)
|
|
239
|
+
click.echo(groups[next_idx])
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@click.command("_complete-word", hidden=True)
|
|
243
|
+
@click.argument("words", nargs=-1)
|
|
244
|
+
def complete_word(words):
|
|
245
|
+
"""Return tab-completion candidates for a partial command line."""
|
|
246
|
+
if not words:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
db = get_db()
|
|
250
|
+
|
|
251
|
+
# All words except the last form the prefix; the last word is the incomplete token
|
|
252
|
+
if len(words) == 1:
|
|
253
|
+
# Single word: return unique first words from all commands matching the token
|
|
254
|
+
token = words[0]
|
|
255
|
+
cur = db.conn.cursor()
|
|
256
|
+
cur.execute("SELECT command, frequency FROM commands ORDER BY frequency DESC")
|
|
257
|
+
seen: set[str] = set()
|
|
258
|
+
for row in cur.fetchall():
|
|
259
|
+
first_word = row["command"].split()[0] if row["command"].split() else ""
|
|
260
|
+
if first_word and first_word.startswith(token) and first_word not in seen:
|
|
261
|
+
seen.add(first_word)
|
|
262
|
+
click.echo(first_word)
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
prefix_words = list(words[:-1])
|
|
266
|
+
token = words[-1]
|
|
267
|
+
|
|
268
|
+
# Build a LIKE pattern from the prefix words
|
|
269
|
+
# Escape SQL LIKE wildcards in prefix
|
|
270
|
+
prefix = " ".join(prefix_words)
|
|
271
|
+
escaped_prefix = prefix.replace("%", "\\%").replace("_", "\\_")
|
|
272
|
+
like_pattern = escaped_prefix + " %"
|
|
273
|
+
|
|
274
|
+
cur = db.conn.cursor()
|
|
275
|
+
cur.execute(
|
|
276
|
+
"SELECT command, frequency FROM commands WHERE command LIKE ? ESCAPE '\\' ORDER BY frequency DESC",
|
|
277
|
+
(like_pattern,),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
word_pos = len(prefix_words)
|
|
281
|
+
seen: set[str] = set()
|
|
282
|
+
for row in cur.fetchall():
|
|
283
|
+
parts = row["command"].split()
|
|
284
|
+
if len(parts) > word_pos:
|
|
285
|
+
candidate = parts[word_pos]
|
|
286
|
+
if candidate.startswith(token) and candidate not in seen:
|
|
287
|
+
seen.add(candidate)
|
|
288
|
+
click.echo(candidate)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@click.command("mcp", hidden=True)
|
|
292
|
+
def mcp_cmd():
|
|
293
|
+
"""Run the MCP server (stdio transport)."""
|
|
294
|
+
from .mcp_server import main
|
|
295
|
+
|
|
296
|
+
main()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@click.command(hidden=True)
|
|
300
|
+
@click.argument("shell", type=click.Choice(["zsh", "bash", "fish"]))
|
|
301
|
+
def completion(shell: str):
|
|
302
|
+
"""Output shell completion script."""
|
|
303
|
+
import os
|
|
304
|
+
import subprocess
|
|
305
|
+
|
|
306
|
+
env = os.environ.copy()
|
|
307
|
+
env["_COPA_COMPLETE"] = f"{shell}_source"
|
|
308
|
+
result = subprocess.run(["copa"], env=env, capture_output=True, text=True)
|
|
309
|
+
click.echo(result.stdout)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@click.command("_fzf-config", hidden=True)
|
|
313
|
+
def fzf_config_cmd():
|
|
314
|
+
"""Output zsh keybinding config for fzf."""
|
|
315
|
+
from .config import emit_zsh_config, load_config
|
|
316
|
+
|
|
317
|
+
config = load_config()
|
|
318
|
+
click.echo(emit_zsh_config(config))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def register(cli):
|
|
322
|
+
"""Register internal commands with the CLI group."""
|
|
323
|
+
cli.add_command(record)
|
|
324
|
+
cli.add_command(init)
|
|
325
|
+
cli.add_command(fzf_list_cmd)
|
|
326
|
+
cli.add_command(preview)
|
|
327
|
+
cli.add_command(set_group)
|
|
328
|
+
cli.add_command(set_flags)
|
|
329
|
+
cli.add_command(list_groups)
|
|
330
|
+
cli.add_command(next_group)
|
|
331
|
+
cli.add_command(complete_word)
|
|
332
|
+
cli.add_command(mcp_cmd)
|
|
333
|
+
cli.add_command(completion)
|
|
334
|
+
cli.add_command(fzf_config_cmd)
|