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/fzf.py ADDED
@@ -0,0 +1,235 @@
1
+ """fzf integration for Copa."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from datetime import datetime
9
+
10
+ from .db import Database
11
+ from .models import Command
12
+ from .scoring import rank_commands
13
+
14
+
15
+ def has_fzf() -> bool:
16
+ """Check if fzf is installed."""
17
+ return shutil.which("fzf") is not None
18
+
19
+
20
+ # ANSI escape codes
21
+ _DIM = "\033[2m"
22
+ _MAGENTA = "\033[35m"
23
+ _YELLOW = "\033[33m"
24
+ _RESET = "\033[0m"
25
+
26
+
27
+ def format_lines(commands: list[Command]) -> list[str]:
28
+ """Format commands for fzf display with aligned columns.
29
+
30
+ Layout: {id} ┃ {command (padded)} ┃ {pin}{group_badge} {freq} ┃ {search_text}
31
+ Field 1 (ID) is hidden by fzf --with-nth '2..3'.
32
+ Field 2 (command) is extracted by cut -d'┃' -f2 in copa.zsh.
33
+ Field 3 (metadata) is visible but not extracted.
34
+ Field 4 (description+flags) is hidden but searchable by fzf.
35
+ """
36
+ if not commands:
37
+ return []
38
+
39
+ # Compute column widths from the full list
40
+ max_cmd = min(max(len(c.command) for c in commands), 60)
41
+ max_grp = max(
42
+ (len(f"[{c.group_name}]") for c in commands if c.group_name),
43
+ default=0,
44
+ )
45
+
46
+ lines = []
47
+ for cmd in commands:
48
+ # Field 1: hidden ID
49
+ id_field = f"{cmd.id:>5}"
50
+
51
+ # Field 2: command text, padded for column alignment
52
+ cmd_text = cmd.command
53
+ if len(cmd_text) > 60:
54
+ cmd_text = cmd_text[:57] + "..."
55
+ cmd_field = f" {cmd_text:<{max_cmd}} "
56
+
57
+ # Field 3: metadata — pin indicator, group badge, frequency
58
+ pin = f"{_YELLOW}*{_RESET} " if cmd.is_pinned else " "
59
+
60
+ if cmd.group_name:
61
+ badge = f"[{cmd.group_name}]"
62
+ padded_badge = f"{badge:>{max_grp}}"
63
+ grp = f"{_DIM}{_MAGENTA}{padded_badge}{_RESET}"
64
+ else:
65
+ grp = " " * max_grp
66
+
67
+ freq_str = f"{cmd.frequency}×"
68
+ freq = f"{_DIM}{freq_str:>6}{_RESET}"
69
+
70
+ meta_field = f" {pin}{grp} {freq}"
71
+
72
+ # Field 4: hidden searchable text (description, usage, purpose, flags)
73
+ search_text = cmd.description or ""
74
+ if cmd.flags:
75
+ search_text += " " + " ".join(f"{k} {v}" for k, v in cmd.flags.items())
76
+
77
+ lines.append(f"{id_field} ┃{cmd_field}┃{meta_field}┃ {search_text}")
78
+
79
+ return lines
80
+
81
+
82
+ def _parse_description(desc: str) -> dict[str, str]:
83
+ """Parse a structured description string into its components.
84
+
85
+ Handles both plain descriptions and structured format:
86
+ "Description text | Usage: X | Purpose: Y"
87
+
88
+ Returns dict with keys: description, usage, purpose.
89
+ """
90
+ result = {"description": "", "usage": "", "purpose": ""}
91
+ if not desc:
92
+ return result
93
+
94
+ # Split on " | " and check for known prefixes
95
+ parts = [p.strip() for p in desc.split(" | ")]
96
+ for part in parts:
97
+ if part.startswith("Usage: "):
98
+ result["usage"] = part[7:]
99
+ elif part.startswith("Purpose: "):
100
+ result["purpose"] = part[9:]
101
+ elif not result["description"]:
102
+ result["description"] = part
103
+
104
+ return result
105
+
106
+
107
+ def format_preview(cmd: Command) -> str:
108
+ """Format a rich preview for fzf preview pane."""
109
+ lines = []
110
+ lines.append(f"Command: {cmd.command}")
111
+
112
+ parsed = _parse_description(cmd.description)
113
+ lines.append(f"Description: {parsed['description'] or '(none)'}")
114
+ if parsed["usage"]:
115
+ lines.append(f"Usage: {parsed['usage']}")
116
+ if parsed["purpose"]:
117
+ lines.append(f"Purpose: {parsed['purpose']}")
118
+
119
+ if cmd.flags:
120
+ lines.append("")
121
+ lines.append("Flags:")
122
+ for flag, desc in cmd.flags.items():
123
+ lines.append(f" {flag:20s} {desc}")
124
+ lines.append("")
125
+
126
+ lines.append(f"Score: {cmd.score:.1f}")
127
+ lines.append(f"Frequency: {cmd.frequency}")
128
+ if cmd.last_used > 0:
129
+ dt = datetime.fromtimestamp(cmd.last_used)
130
+ lines.append(f"Last used: {dt.strftime('%Y-%m-%d %H:%M')}")
131
+ if cmd.first_added > 0:
132
+ dt = datetime.fromtimestamp(cmd.first_added)
133
+ lines.append(f"First added: {dt.strftime('%Y-%m-%d %H:%M')}")
134
+ lines.append(f"Source: {cmd.source}")
135
+ if cmd.group_name:
136
+ lines.append(f"Group: {cmd.group_name}")
137
+ if cmd.shared_set:
138
+ lines.append(f"Shared set: {cmd.shared_set}")
139
+ if cmd.is_pinned:
140
+ lines.append("Pinned: yes")
141
+ if cmd.tags:
142
+ lines.append(f"Tags: {', '.join(cmd.tags)}")
143
+ return "\n".join(lines)
144
+
145
+
146
+ def fzf_list(
147
+ db: Database,
148
+ mode: str = "all",
149
+ group: str | None = None,
150
+ shared_set: str | None = None,
151
+ ) -> list[str]:
152
+ """Generate fzf-compatible output lines.
153
+
154
+ Modes: all, frequent, recent, group, set
155
+ """
156
+ if mode == "set" and shared_set:
157
+ commands = db.list_commands(shared_set=shared_set, limit=500)
158
+ elif mode == "group" and group:
159
+ commands = db.list_commands(group_name=group, limit=500)
160
+ elif shared_set:
161
+ commands = db.list_commands(shared_set=shared_set, limit=500)
162
+ else:
163
+ commands = db.get_all_commands()
164
+
165
+ ranked = rank_commands(commands)
166
+
167
+ if mode == "recent":
168
+ ranked.sort(key=lambda c: c.last_used, reverse=True)
169
+ elif mode == "frequent":
170
+ ranked.sort(key=lambda c: c.frequency, reverse=True)
171
+
172
+ return format_lines(ranked)
173
+
174
+
175
+ def run_fzf(db: Database, mode: str = "all", group: str | None = None) -> str | None:
176
+ """Run fzf with Copa commands. Returns selected command text or None."""
177
+ if not has_fzf():
178
+ print("Error: fzf is not installed. Install with: brew install fzf", file=sys.stderr)
179
+ return None
180
+
181
+ lines = fzf_list(db, mode=mode, group=group)
182
+ if not lines:
183
+ print("No commands found.", file=sys.stderr)
184
+ return None
185
+
186
+ input_text = "\n".join(lines)
187
+
188
+ # Find the copa executable for preview
189
+ copa_bin = shutil.which("copa") or sys.argv[0]
190
+ preview_cmd = f"{copa_bin} _preview {{1}}"
191
+
192
+ try:
193
+ result = subprocess.run(
194
+ [
195
+ "fzf",
196
+ "--ansi",
197
+ "--delimiter",
198
+ "┃",
199
+ "--with-nth",
200
+ "2..3",
201
+ "--preview",
202
+ preview_cmd,
203
+ "--preview-window",
204
+ "right:40%:wrap",
205
+ "--header",
206
+ f"Copa [{mode}] — Tab to cycle modes",
207
+ "--prompt",
208
+ "copa> ",
209
+ "--height",
210
+ "80%",
211
+ "--layout",
212
+ "reverse",
213
+ "--bind",
214
+ "enter:accept",
215
+ ],
216
+ input=input_text,
217
+ capture_output=True,
218
+ text=True,
219
+ )
220
+ except FileNotFoundError:
221
+ print("Error: fzf not found", file=sys.stderr)
222
+ return None
223
+
224
+ if result.returncode != 0:
225
+ return None
226
+
227
+ selected = result.stdout.strip()
228
+ if not selected:
229
+ return None
230
+
231
+ # Extract command text (second field after ┃)
232
+ parts = selected.split("┃")
233
+ if len(parts) >= 2:
234
+ return parts[1].strip()
235
+ return selected.strip()
copa/history.py ADDED
@@ -0,0 +1,132 @@
1
+ """Zsh history ingestion for Copa."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+ from collections import Counter
8
+ from pathlib import Path
9
+
10
+ from .db import Database
11
+
12
+ # Default zsh history file
13
+ DEFAULT_HISTORY = Path.home() / ".zsh_history"
14
+
15
+ # Extended history format: : timestamp:0;command
16
+ EXTENDED_RE = re.compile(r"^:\s*(\d+):\d+;(.+)$")
17
+
18
+
19
+ def parse_zsh_history(
20
+ history_path: Path | None = None,
21
+ ) -> list[tuple[str, float]]:
22
+ """Parse zsh history, returning (command, timestamp) tuples.
23
+
24
+ Handles both plain and extended history formats.
25
+ Multi-line commands (ending with \\) are joined.
26
+ """
27
+ if history_path is None:
28
+ history_path = DEFAULT_HISTORY
29
+
30
+ if not history_path.exists():
31
+ return []
32
+
33
+ entries: list[tuple[str, float]] = []
34
+
35
+ try:
36
+ raw = history_path.read_bytes()
37
+ # Zsh history may use meta-encoding; decode with replacement
38
+ text = raw.decode("utf-8", errors="replace")
39
+ except OSError:
40
+ return []
41
+
42
+ lines = text.splitlines()
43
+ i = 0
44
+ while i < len(lines):
45
+ line = lines[i]
46
+
47
+ # Extended format
48
+ m = EXTENDED_RE.match(line)
49
+ if m:
50
+ ts = float(m.group(1))
51
+ cmd = m.group(2)
52
+ # Handle continuation lines
53
+ while cmd.endswith("\\") and i + 1 < len(lines):
54
+ i += 1
55
+ cmd = cmd[:-1] + "\n" + lines[i]
56
+ entries.append((cmd.strip(), ts))
57
+ elif line.strip():
58
+ # Plain format — no timestamp
59
+ cmd = line.strip()
60
+ while cmd.endswith("\\") and i + 1 < len(lines):
61
+ i += 1
62
+ cmd = cmd[:-1] + "\n" + lines[i]
63
+ entries.append((cmd.strip(), 0.0))
64
+
65
+ i += 1
66
+
67
+ return entries
68
+
69
+
70
+ def sync_history(
71
+ db: Database,
72
+ history_path: Path | None = None,
73
+ ) -> int:
74
+ """Ingest zsh history into Copa database.
75
+
76
+ Returns the number of new commands added.
77
+ """
78
+ entries = parse_zsh_history(history_path)
79
+ if not entries:
80
+ return 0
81
+
82
+ # Count frequencies
83
+ freq: Counter[str] = Counter()
84
+ latest_ts: dict[str, float] = {}
85
+ for cmd, ts in entries:
86
+ freq[cmd] += 1
87
+ if ts > latest_ts.get(cmd, 0):
88
+ latest_ts[cmd] = ts
89
+
90
+ added = 0
91
+ now = time.time()
92
+ cur = db.conn.cursor()
93
+
94
+ for cmd, count in freq.items():
95
+ # Skip trivially short or empty commands
96
+ if len(cmd) < 2:
97
+ continue
98
+
99
+ ts = latest_ts.get(cmd, now)
100
+ existing = cur.execute(
101
+ "SELECT id, frequency FROM commands WHERE command = ? AND group_name IS NULL",
102
+ (cmd,),
103
+ ).fetchone()
104
+
105
+ if existing:
106
+ # Update frequency and last_used if history is newer
107
+ cur.execute(
108
+ """UPDATE commands
109
+ SET frequency = MAX(frequency, ?),
110
+ last_used = MAX(last_used, ?)
111
+ WHERE id = ?""",
112
+ (count, ts, existing["id"]),
113
+ )
114
+ else:
115
+ cur.execute(
116
+ """INSERT INTO commands
117
+ (command, frequency, last_used, first_added, source)
118
+ VALUES (?, ?, ?, ?, 'history')""",
119
+ (cmd, count, ts, ts),
120
+ )
121
+ added += 1
122
+
123
+ db.conn.commit()
124
+ return added
125
+
126
+
127
+ def get_history_frequencies(
128
+ history_path: Path | None = None,
129
+ ) -> Counter[str]:
130
+ """Get command frequencies from zsh history."""
131
+ entries = parse_zsh_history(history_path)
132
+ return Counter(cmd for cmd, _ in entries)
copa/llm.py ADDED
@@ -0,0 +1,128 @@
1
+ """LLM backend abstraction for Copa description generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+
8
+ PROMPT_TEMPLATE = (
9
+ "Given this shell command, write a short description (under 15 words) "
10
+ "of what it does. Output only the description.\n\n"
11
+ "Command: {command}"
12
+ )
13
+
14
+
15
+ def generate_description(command: str, backend: str = "claude", model: str | None = None) -> str | None:
16
+ """Generate a description for a command using the configured LLM backend.
17
+
18
+ Returns the generated description, or None on failure.
19
+ """
20
+ prompt = PROMPT_TEMPLATE.format(command=command)
21
+
22
+ if backend == "claude":
23
+ return _generate_claude(prompt)
24
+ elif backend == "ollama":
25
+ return _generate_ollama(prompt, model or "llama3.2:3b")
26
+ else:
27
+ return None
28
+
29
+
30
+ def _generate_claude(prompt: str) -> str | None:
31
+ """Generate using the claude CLI."""
32
+ if not shutil.which("claude"):
33
+ return None
34
+
35
+ try:
36
+ result = subprocess.run(
37
+ ["claude", "-p", prompt],
38
+ capture_output=True,
39
+ text=True,
40
+ timeout=30,
41
+ )
42
+ if result.returncode == 0 and result.stdout.strip():
43
+ return _clean_response(result.stdout.strip())
44
+ except (subprocess.TimeoutExpired, OSError):
45
+ pass
46
+
47
+ return None
48
+
49
+
50
+ def _generate_ollama(prompt: str, model: str) -> str | None:
51
+ """Generate using ollama HTTP API."""
52
+ try:
53
+ import requests
54
+ except ImportError:
55
+ return None
56
+
57
+ try:
58
+ resp = requests.post(
59
+ "http://localhost:11434/api/generate",
60
+ json={"model": model, "prompt": prompt, "stream": False},
61
+ timeout=30,
62
+ )
63
+ if resp.status_code == 200:
64
+ data = resp.json()
65
+ response_text = data.get("response", "").strip()
66
+ if response_text:
67
+ return _clean_response(response_text)
68
+ except Exception:
69
+ pass
70
+
71
+ return None
72
+
73
+
74
+ def _clean_response(text: str) -> str:
75
+ """Clean up an LLM response — strip quotes, trailing punctuation, etc."""
76
+ text = text.strip().strip('"').strip("'")
77
+ # Remove leading "Description: " if the model echoed the prompt format
78
+ for prefix in ("Description:", "description:"):
79
+ if text.lower().startswith(prefix.lower()):
80
+ text = text[len(prefix) :].strip()
81
+ # Truncate to first line only
82
+ text = text.split("\n")[0].strip()
83
+ return text
84
+
85
+
86
+ def check_ollama_available() -> tuple[bool, str]:
87
+ """Check if ollama is installed and running.
88
+
89
+ Returns (is_ready, message).
90
+ """
91
+ if not shutil.which("ollama"):
92
+ return False, "ollama is not installed. Install from https://ollama.com"
93
+
94
+ try:
95
+ import requests
96
+ except ImportError:
97
+ return False, "requests package not installed. Run: pip install copa[ollama]"
98
+
99
+ try:
100
+ resp = requests.get("http://localhost:11434/api/tags", timeout=5)
101
+ if resp.status_code == 200:
102
+ return True, "ollama is running"
103
+ except Exception:
104
+ pass
105
+
106
+ return False, "ollama is not running. Start with: ollama serve"
107
+
108
+
109
+ def check_ollama_model(model: str) -> tuple[bool, list[str]]:
110
+ """Check if a specific model is available in ollama.
111
+
112
+ Returns (model_available, list_of_available_models).
113
+ """
114
+ try:
115
+ import requests
116
+ except ImportError:
117
+ return False, []
118
+
119
+ try:
120
+ resp = requests.get("http://localhost:11434/api/tags", timeout=5)
121
+ if resp.status_code == 200:
122
+ data = resp.json()
123
+ models = [m["name"] for m in data.get("models", [])]
124
+ return model in models, models
125
+ except Exception:
126
+ pass
127
+
128
+ return False, []
copa/mcp_server.py ADDED
@@ -0,0 +1,159 @@
1
+ """FastMCP server for Copa — exposes commands to Claude Code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .db import Database
6
+ from .scoring import rank_commands
7
+
8
+
9
+ def create_mcp_server():
10
+ """Create and configure the FastMCP server."""
11
+ from mcp.server.fastmcp import FastMCP
12
+
13
+ mcp = FastMCP("Copa")
14
+ db = Database()
15
+ db.init_db()
16
+
17
+ @mcp.tool()
18
+ def copa_search(query: str, group: str | None = None, limit: int = 20) -> str:
19
+ """Search Copa commands by keyword. Returns matching commands with descriptions."""
20
+ commands = db.search_commands(query, group_name=group, limit=limit)
21
+ ranked = rank_commands(commands)
22
+ if not ranked:
23
+ return f"No commands found matching '{query}'."
24
+ lines = []
25
+ for cmd in ranked:
26
+ parts = [f"[{cmd.id}] {cmd.command}"]
27
+ if cmd.description:
28
+ parts.append(f" → {cmd.description}")
29
+ if cmd.group_name:
30
+ parts.append(f" group: {cmd.group_name}")
31
+ if cmd.tags:
32
+ parts.append(f" tags: {', '.join(cmd.tags)}")
33
+ lines.append("\n".join(parts))
34
+ return "\n\n".join(lines)
35
+
36
+ @mcp.tool()
37
+ def copa_list_commands(group: str | None = None, limit: int = 20) -> str:
38
+ """List Copa commands ranked by usage score. Optionally filter by group."""
39
+ if group:
40
+ commands = db.list_commands(group_name=group, limit=limit)
41
+ else:
42
+ commands = db.list_commands(limit=limit)
43
+ ranked = rank_commands(commands)
44
+ if not ranked:
45
+ return "No commands found."
46
+ lines = []
47
+ for cmd in ranked:
48
+ badge = ""
49
+ if cmd.shared_set:
50
+ badge = " [shared]"
51
+ elif cmd.group_name:
52
+ badge = f" [{cmd.group_name}]"
53
+ desc = f" — {cmd.description}" if cmd.description else ""
54
+ lines.append(f"[{cmd.id}] {cmd.command}{desc}{badge} ({cmd.frequency}×)")
55
+ return "\n".join(lines)
56
+
57
+ @mcp.tool()
58
+ def copa_list_groups() -> str:
59
+ """List all Copa command groups."""
60
+ groups = db.get_groups()
61
+ if not groups:
62
+ return "No groups found."
63
+ return "\n".join(f"- {g}" for g in groups)
64
+
65
+ @mcp.tool()
66
+ def copa_get_stats() -> str:
67
+ """Get Copa usage statistics."""
68
+ stats = db.get_stats()
69
+ lines = [
70
+ f"Total commands: {stats['total_commands']}",
71
+ f"Total uses: {stats['total_uses']}",
72
+ f"Groups: {stats['total_groups']}",
73
+ f"Shared sets: {stats['shared_sets']}",
74
+ f"Pinned: {stats['pinned']}",
75
+ f"Need description: {stats['needs_description']}",
76
+ ]
77
+ if stats.get("by_source"):
78
+ lines.append("By source:")
79
+ for source, count in stats["by_source"].items():
80
+ lines.append(f" {source}: {count}")
81
+ return "\n".join(lines)
82
+
83
+ @mcp.tool()
84
+ def copa_add_command(
85
+ command: str,
86
+ description: str = "",
87
+ group: str | None = None,
88
+ tags: list[str] | None = None,
89
+ ) -> str:
90
+ """Add a command to Copa with optional description, group, and tags."""
91
+ cmd_id = db.add_command(
92
+ command=command,
93
+ description=description,
94
+ group_name=group,
95
+ tags=tags,
96
+ )
97
+ return f"Added command [{cmd_id}]: {command}"
98
+
99
+ @mcp.tool()
100
+ def copa_update_description(command_id: int, description: str) -> str:
101
+ """Update the description of a Copa command."""
102
+ cmd = db.get_command(command_id)
103
+ if not cmd:
104
+ return f"Command {command_id} not found."
105
+ db.update_description(command_id, description)
106
+ return f"Updated [{command_id}] {cmd.command}: {description}"
107
+
108
+ @mcp.tool()
109
+ def copa_create_group(name: str, commands: list[dict] | None = None) -> str:
110
+ """Create a Copa group and optionally add commands to it.
111
+
112
+ Each command in the list should have 'command' and optionally 'description' and 'tags'.
113
+ """
114
+ count = 0
115
+ if commands:
116
+ for cmd_data in commands:
117
+ cmd_text = cmd_data.get("command", "").strip()
118
+ if not cmd_text:
119
+ continue
120
+ db.add_command(
121
+ command=cmd_text,
122
+ description=cmd_data.get("description", ""),
123
+ group_name=name,
124
+ tags=cmd_data.get("tags"),
125
+ )
126
+ count += 1
127
+ return f"Group '{name}' created with {count} commands."
128
+
129
+ @mcp.tool()
130
+ def copa_bulk_add(commands: list[dict], group: str | None = None) -> str:
131
+ """Bulk add commands to Copa.
132
+
133
+ Each item should have 'command' and optionally 'description' and 'tags'.
134
+ """
135
+ count = 0
136
+ for cmd_data in commands:
137
+ cmd_text = cmd_data.get("command", "").strip()
138
+ if not cmd_text:
139
+ continue
140
+ db.add_command(
141
+ command=cmd_text,
142
+ description=cmd_data.get("description", ""),
143
+ group_name=group,
144
+ tags=cmd_data.get("tags"),
145
+ )
146
+ count += 1
147
+ return f"Added {count} commands."
148
+
149
+ return mcp
150
+
151
+
152
+ def main():
153
+ """Run the MCP server."""
154
+ mcp = create_mcp_server()
155
+ mcp.run(transport="stdio")
156
+
157
+
158
+ if __name__ == "__main__":
159
+ main()