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 ADDED
@@ -0,0 +1,3 @@
1
+ """Copa — Command Palette."""
2
+
3
+ __version__ = "0.2.0"
copa/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as python -m copa."""
2
+
3
+ from copa.cli import cli
4
+
5
+ cli()
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)