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/models.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Dataclasses for Copa entities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Command:
|
|
11
|
+
"""A tracked command."""
|
|
12
|
+
|
|
13
|
+
id: int = 0
|
|
14
|
+
command: str = ""
|
|
15
|
+
description: str = ""
|
|
16
|
+
frequency: int = 0
|
|
17
|
+
last_used: float = 0.0
|
|
18
|
+
first_added: float = 0.0
|
|
19
|
+
source: str = "manual" # history|manual|shared|scan|auto
|
|
20
|
+
group_name: str | None = None
|
|
21
|
+
shared_set: str | None = None
|
|
22
|
+
is_pinned: bool = False
|
|
23
|
+
needs_description: bool = False
|
|
24
|
+
tags: list[str] = field(default_factory=list)
|
|
25
|
+
flags: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
score: float = 0.0 # computed at query time
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_row(cls, row: dict) -> Command:
|
|
30
|
+
cmd = cls(
|
|
31
|
+
id=row["id"],
|
|
32
|
+
command=row["command"],
|
|
33
|
+
description=row.get("description", ""),
|
|
34
|
+
frequency=row.get("frequency", 0),
|
|
35
|
+
last_used=row.get("last_used", 0.0),
|
|
36
|
+
first_added=row.get("first_added", 0.0),
|
|
37
|
+
source=row.get("source", "manual"),
|
|
38
|
+
group_name=row.get("group_name"),
|
|
39
|
+
shared_set=row.get("shared_set"),
|
|
40
|
+
is_pinned=bool(row.get("is_pinned", 0)),
|
|
41
|
+
needs_description=bool(row.get("needs_description", 0)),
|
|
42
|
+
)
|
|
43
|
+
flags_raw = row.get("flags", "")
|
|
44
|
+
if flags_raw:
|
|
45
|
+
try:
|
|
46
|
+
cmd.flags = json.loads(flags_raw)
|
|
47
|
+
except (json.JSONDecodeError, TypeError):
|
|
48
|
+
cmd.flags = {}
|
|
49
|
+
return cmd
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict:
|
|
52
|
+
d = {
|
|
53
|
+
"command": self.command,
|
|
54
|
+
"description": self.description,
|
|
55
|
+
"tags": self.tags,
|
|
56
|
+
}
|
|
57
|
+
if self.flags:
|
|
58
|
+
d["flags"] = self.flags
|
|
59
|
+
return d
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class SharedSet:
|
|
64
|
+
"""A shared command set."""
|
|
65
|
+
|
|
66
|
+
name: str = ""
|
|
67
|
+
description: str = ""
|
|
68
|
+
source_path: str | None = None
|
|
69
|
+
loaded_at: float = 0.0
|
|
70
|
+
version: str = "1.0"
|
|
71
|
+
author: str = ""
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_row(cls, row: dict) -> SharedSet:
|
|
75
|
+
return cls(
|
|
76
|
+
name=row["name"],
|
|
77
|
+
description=row.get("description", ""),
|
|
78
|
+
source_path=row.get("source_path"),
|
|
79
|
+
loaded_at=row.get("loaded_at", 0.0),
|
|
80
|
+
version=row.get("version", "1.0"),
|
|
81
|
+
author=row.get("author", ""),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class CopaFile:
|
|
87
|
+
"""Represents a .copa export/import file."""
|
|
88
|
+
|
|
89
|
+
copa_version: str = "1.0"
|
|
90
|
+
name: str = ""
|
|
91
|
+
description: str = ""
|
|
92
|
+
author: str = ""
|
|
93
|
+
commands: list[dict] = field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict:
|
|
96
|
+
return {
|
|
97
|
+
"copa_version": self.copa_version,
|
|
98
|
+
"name": self.name,
|
|
99
|
+
"description": self.description,
|
|
100
|
+
"author": self.author,
|
|
101
|
+
"commands": self.commands,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_dict(cls, data: dict) -> CopaFile:
|
|
106
|
+
return cls(
|
|
107
|
+
copa_version=data.get("copa_version", "1.0"),
|
|
108
|
+
name=data.get("name", ""),
|
|
109
|
+
description=data.get("description", ""),
|
|
110
|
+
author=data.get("author", ""),
|
|
111
|
+
commands=data.get("commands", []),
|
|
112
|
+
)
|
copa/scanner.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Scanner for executable script metadata across $PATH."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .db import Database
|
|
10
|
+
|
|
11
|
+
# --- Pass 1: #@ Protocol headers (highest priority) ---
|
|
12
|
+
PROTOCOL_DESC = re.compile(r"^#@\s*[Dd]escription:\s*(.+)$")
|
|
13
|
+
PROTOCOL_USAGE = re.compile(r"^#@\s*[Uu]sage:\s*(.+)$")
|
|
14
|
+
PROTOCOL_PURPOSE = re.compile(r"^#@\s*[Pp]urpose:\s*(.+)$")
|
|
15
|
+
PROTOCOL_FLAG = re.compile(r"^#@\s*[Ff]lag:\s*(.+)$")
|
|
16
|
+
|
|
17
|
+
# --- Pass 2: Legacy fallback patterns ---
|
|
18
|
+
LEGACY_PATTERNS = [
|
|
19
|
+
re.compile(r"^#\s*[Dd]escription:\s*(.+)$"),
|
|
20
|
+
re.compile(r"^#\s*[Pp]urpose:\s*(.+)$"),
|
|
21
|
+
re.compile(r"^#\s*[Uu]sage:\s*(.+)$"),
|
|
22
|
+
re.compile(r'^"""\s*(.+?)(?:""")?$'), # Python docstring one-liner
|
|
23
|
+
re.compile(r"^#\s*(?![@!])(.{10,80})$"), # Generic comment (skip @ prefix lines)
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def extract_description(path: Path) -> tuple[str, dict[str, str]]:
|
|
28
|
+
"""Extract a description and flags from a script file's header comments.
|
|
29
|
+
|
|
30
|
+
Uses a two-pass approach:
|
|
31
|
+
Pass 1 — #@ protocol headers (highest priority)
|
|
32
|
+
Pass 2 — Legacy comment patterns (fallback)
|
|
33
|
+
|
|
34
|
+
Returns (description_string, flags_dict).
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
with open(path, "r", errors="replace") as f:
|
|
38
|
+
lines = []
|
|
39
|
+
for i, line in enumerate(f):
|
|
40
|
+
if i >= 50: # Check first 50 lines for protocol headers
|
|
41
|
+
break
|
|
42
|
+
lines.append(line.rstrip())
|
|
43
|
+
except OSError:
|
|
44
|
+
return "", {}
|
|
45
|
+
|
|
46
|
+
# --- Pass 1: Protocol headers ---
|
|
47
|
+
description = ""
|
|
48
|
+
usage = ""
|
|
49
|
+
purpose = ""
|
|
50
|
+
flags: dict[str, str] = {}
|
|
51
|
+
|
|
52
|
+
for line in lines:
|
|
53
|
+
m = PROTOCOL_DESC.match(line)
|
|
54
|
+
if m:
|
|
55
|
+
description = m.group(1).strip()
|
|
56
|
+
continue
|
|
57
|
+
m = PROTOCOL_USAGE.match(line)
|
|
58
|
+
if m:
|
|
59
|
+
usage = m.group(1).strip()
|
|
60
|
+
continue
|
|
61
|
+
m = PROTOCOL_PURPOSE.match(line)
|
|
62
|
+
if m:
|
|
63
|
+
purpose = m.group(1).strip()
|
|
64
|
+
continue
|
|
65
|
+
m = PROTOCOL_FLAG.match(line)
|
|
66
|
+
if m:
|
|
67
|
+
flag_text = m.group(1).strip()
|
|
68
|
+
# "#@ Flag: -w, --wipe: Wipe userdata" → {"-w, --wipe": "Wipe userdata"}
|
|
69
|
+
parts = flag_text.split(":", 1)
|
|
70
|
+
flag_name = parts[0].strip()
|
|
71
|
+
flag_desc = parts[1].strip() if len(parts) > 1 else ""
|
|
72
|
+
flags[flag_name] = flag_desc
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
if description:
|
|
76
|
+
parts = [description]
|
|
77
|
+
if usage:
|
|
78
|
+
parts.append(f"Usage: {usage}")
|
|
79
|
+
if purpose:
|
|
80
|
+
parts.append(f"Purpose: {purpose}")
|
|
81
|
+
return " | ".join(parts), flags
|
|
82
|
+
|
|
83
|
+
# --- Pass 2: Legacy fallbacks ---
|
|
84
|
+
for line in lines:
|
|
85
|
+
for pattern in LEGACY_PATTERNS:
|
|
86
|
+
m = pattern.match(line)
|
|
87
|
+
if m:
|
|
88
|
+
desc = m.group(1).strip()
|
|
89
|
+
if desc and not desc.startswith("!") and len(desc) > 5:
|
|
90
|
+
return desc, flags
|
|
91
|
+
|
|
92
|
+
return "", flags
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _scan_single_directory(db: Database, directory: Path) -> int:
|
|
96
|
+
"""Scan one directory for executable scripts and add them to Copa.
|
|
97
|
+
|
|
98
|
+
Returns number of scripts added.
|
|
99
|
+
"""
|
|
100
|
+
if not directory.is_dir():
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
added = 0
|
|
104
|
+
try:
|
|
105
|
+
entries = sorted(directory.iterdir())
|
|
106
|
+
except PermissionError:
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
for entry in entries:
|
|
110
|
+
try:
|
|
111
|
+
is_file = entry.is_file()
|
|
112
|
+
is_exec = os.access(entry, os.X_OK)
|
|
113
|
+
except (PermissionError, OSError):
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if is_file and is_exec:
|
|
117
|
+
name = entry.name
|
|
118
|
+
# Skip hidden files and common non-script files
|
|
119
|
+
if name.startswith(".") or name.endswith((".md", ".txt", ".log")):
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
description, flags = extract_description(entry)
|
|
123
|
+
if not db.command_exists(name):
|
|
124
|
+
db.add_command(
|
|
125
|
+
command=name,
|
|
126
|
+
description=description,
|
|
127
|
+
source="scan",
|
|
128
|
+
flags=flags if flags else None,
|
|
129
|
+
)
|
|
130
|
+
added += 1
|
|
131
|
+
|
|
132
|
+
return added
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def scan_directory(db: Database, directory: Path | None = None) -> int:
|
|
136
|
+
"""Scan directories for executable scripts and add them to Copa.
|
|
137
|
+
|
|
138
|
+
If directory is given, scans only that directory.
|
|
139
|
+
Otherwise, scans all directories on $PATH.
|
|
140
|
+
|
|
141
|
+
Returns number of scripts added.
|
|
142
|
+
"""
|
|
143
|
+
if directory is not None:
|
|
144
|
+
return _scan_single_directory(db, directory)
|
|
145
|
+
|
|
146
|
+
# Default: scan all $PATH directories
|
|
147
|
+
path_dirs = os.environ.get("PATH", "").split(os.pathsep)
|
|
148
|
+
added = 0
|
|
149
|
+
for dir_str in path_dirs:
|
|
150
|
+
d = Path(dir_str)
|
|
151
|
+
if d.is_dir():
|
|
152
|
+
added += _scan_single_directory(db, d)
|
|
153
|
+
return added
|
copa/scoring.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Scoring algorithm: frequency + recency hybrid."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .models import Command
|
|
9
|
+
|
|
10
|
+
# Tuning constants
|
|
11
|
+
FREQ_WEIGHT = 2.0
|
|
12
|
+
RECENCY_WEIGHT = 8.0
|
|
13
|
+
HALF_LIFE_SECONDS = 3 * 24 * 3600 # 3 days
|
|
14
|
+
PIN_BONUS = 1000.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compute_score(cmd: Command, now: float | None = None) -> float:
|
|
18
|
+
"""Compute hybrid score: 2.0*log(1+freq) + 8.0*0.5^(age/3days).
|
|
19
|
+
|
|
20
|
+
Pinned commands get +1000 bonus.
|
|
21
|
+
"""
|
|
22
|
+
if now is None:
|
|
23
|
+
now = time.time()
|
|
24
|
+
|
|
25
|
+
freq_score = FREQ_WEIGHT * math.log(1 + cmd.frequency)
|
|
26
|
+
|
|
27
|
+
age_seconds = max(0, now - cmd.last_used) if cmd.last_used > 0 else HALF_LIFE_SECONDS * 10
|
|
28
|
+
recency_score = RECENCY_WEIGHT * (0.5 ** (age_seconds / HALF_LIFE_SECONDS))
|
|
29
|
+
|
|
30
|
+
score = freq_score + recency_score
|
|
31
|
+
|
|
32
|
+
if cmd.is_pinned:
|
|
33
|
+
score += PIN_BONUS
|
|
34
|
+
|
|
35
|
+
return score
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def rank_commands(commands: list[Command], now: float | None = None) -> list[Command]:
|
|
39
|
+
"""Score and sort commands by descending score."""
|
|
40
|
+
if now is None:
|
|
41
|
+
now = time.time()
|
|
42
|
+
for cmd in commands:
|
|
43
|
+
cmd.score = compute_score(cmd, now)
|
|
44
|
+
commands.sort(key=lambda c: c.score, reverse=True)
|
|
45
|
+
return commands
|
copa/sharing.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Shared command set management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .db import Database
|
|
12
|
+
from .models import CopaFile, SharedSet
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def find_fbsource_root() -> Path | None:
|
|
16
|
+
"""Find the fbsource checkout root."""
|
|
17
|
+
# Check env var first
|
|
18
|
+
env_root = os.environ.get("FBSOURCE_ROOT")
|
|
19
|
+
if env_root:
|
|
20
|
+
p = Path(env_root)
|
|
21
|
+
if p.is_dir():
|
|
22
|
+
return p
|
|
23
|
+
|
|
24
|
+
# Try hg root from common locations
|
|
25
|
+
for candidate in [Path.home() / "fbsource", Path("/data/users") / os.getenv("USER", "") / "fbsource"]:
|
|
26
|
+
if candidate.is_dir():
|
|
27
|
+
return candidate
|
|
28
|
+
|
|
29
|
+
# Try hg root
|
|
30
|
+
try:
|
|
31
|
+
result = subprocess.run(
|
|
32
|
+
["hg", "root"],
|
|
33
|
+
capture_output=True,
|
|
34
|
+
text=True,
|
|
35
|
+
timeout=5,
|
|
36
|
+
)
|
|
37
|
+
if result.returncode == 0:
|
|
38
|
+
p = Path(result.stdout.strip())
|
|
39
|
+
if p.is_dir():
|
|
40
|
+
return p
|
|
41
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_copa_path(source: str) -> Path | None:
|
|
48
|
+
"""Resolve a .copa source path.
|
|
49
|
+
|
|
50
|
+
Accepts:
|
|
51
|
+
- Absolute/relative file paths
|
|
52
|
+
- fbsource-relative paths (e.g., arvr/agios/supra/SupraCommands)
|
|
53
|
+
"""
|
|
54
|
+
# Direct file path
|
|
55
|
+
p = Path(source).expanduser()
|
|
56
|
+
if p.exists():
|
|
57
|
+
return p
|
|
58
|
+
|
|
59
|
+
# Add .copa extension if missing
|
|
60
|
+
if not source.endswith(".copa"):
|
|
61
|
+
p_ext = Path(source + ".copa").expanduser()
|
|
62
|
+
if p_ext.exists():
|
|
63
|
+
return p_ext
|
|
64
|
+
|
|
65
|
+
# fbsource-relative
|
|
66
|
+
root = find_fbsource_root()
|
|
67
|
+
if root:
|
|
68
|
+
fbpath = root / source
|
|
69
|
+
if fbpath.exists():
|
|
70
|
+
return fbpath
|
|
71
|
+
if not source.endswith(".copa"):
|
|
72
|
+
fbpath_ext = root / (source + ".copa")
|
|
73
|
+
if fbpath_ext.exists():
|
|
74
|
+
return fbpath_ext
|
|
75
|
+
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_copa_file(path: Path) -> CopaFile:
|
|
80
|
+
"""Load a .copa JSON file."""
|
|
81
|
+
data = json.loads(path.read_text())
|
|
82
|
+
return CopaFile.from_dict(data)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def export_group(db: Database, group_name: str, author: str = "") -> CopaFile:
|
|
86
|
+
"""Export a group as a CopaFile."""
|
|
87
|
+
commands = db.list_commands(group_name=group_name, limit=10000)
|
|
88
|
+
copa_file = CopaFile(
|
|
89
|
+
name=group_name,
|
|
90
|
+
description=f"Exported from Copa group '{group_name}'",
|
|
91
|
+
author=author,
|
|
92
|
+
commands=[cmd.to_dict() for cmd in commands],
|
|
93
|
+
)
|
|
94
|
+
return copa_file
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def import_shared_set(db: Database, copa_file: CopaFile, source_path: str | None = None) -> int:
|
|
98
|
+
"""Import commands from a CopaFile into the database.
|
|
99
|
+
|
|
100
|
+
Returns number of commands imported.
|
|
101
|
+
"""
|
|
102
|
+
# Register the shared set
|
|
103
|
+
ss = SharedSet(
|
|
104
|
+
name=copa_file.name,
|
|
105
|
+
description=copa_file.description,
|
|
106
|
+
source_path=source_path,
|
|
107
|
+
loaded_at=time.time(),
|
|
108
|
+
version=copa_file.copa_version,
|
|
109
|
+
author=copa_file.author,
|
|
110
|
+
)
|
|
111
|
+
db.upsert_shared_set(ss)
|
|
112
|
+
|
|
113
|
+
count = 0
|
|
114
|
+
for cmd_data in copa_file.commands:
|
|
115
|
+
command = cmd_data.get("command", "").strip()
|
|
116
|
+
if not command:
|
|
117
|
+
continue
|
|
118
|
+
description = cmd_data.get("description", "")
|
|
119
|
+
tags = cmd_data.get("tags", [])
|
|
120
|
+
flags = cmd_data.get("flags")
|
|
121
|
+
|
|
122
|
+
db.add_command(
|
|
123
|
+
command=command,
|
|
124
|
+
description=description,
|
|
125
|
+
source="shared",
|
|
126
|
+
group_name=copa_file.name,
|
|
127
|
+
shared_set=copa_file.name,
|
|
128
|
+
tags=tags,
|
|
129
|
+
flags=flags if flags else None,
|
|
130
|
+
)
|
|
131
|
+
count += 1
|
|
132
|
+
|
|
133
|
+
return count
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def sync_directory(db: Database, directory: str) -> dict[str, int]:
|
|
137
|
+
"""Sync all .copa files from a directory tree.
|
|
138
|
+
|
|
139
|
+
Returns dict of {filename: commands_imported}.
|
|
140
|
+
"""
|
|
141
|
+
path = resolve_copa_path(directory)
|
|
142
|
+
if not path or not path.is_dir():
|
|
143
|
+
return {}
|
|
144
|
+
|
|
145
|
+
results = {}
|
|
146
|
+
for copa_path in sorted(path.rglob("*.copa")):
|
|
147
|
+
try:
|
|
148
|
+
copa_file = load_copa_file(copa_path)
|
|
149
|
+
count = import_shared_set(db, copa_file, source_path=str(copa_path))
|
|
150
|
+
results[copa_path.name] = count
|
|
151
|
+
except (json.JSONDecodeError, KeyError):
|
|
152
|
+
results[copa_path.name] = -1 # error indicator
|
|
153
|
+
|
|
154
|
+
return results
|