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