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