code-aide 1.0.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.
code_aide/prereqs.py ADDED
@@ -0,0 +1,179 @@
1
+ """Prerequisite and environment checks for tool installation."""
2
+
3
+ import os
4
+ import platform
5
+ import subprocess
6
+ import sys
7
+ from typing import List, Optional
8
+
9
+ from code_aide.constants import PACKAGE_MANAGERS, TOOLS
10
+ from code_aide.console import (
11
+ command_exists,
12
+ error,
13
+ info,
14
+ run_command,
15
+ success,
16
+ warning,
17
+ )
18
+
19
+
20
+ def detect_package_manager() -> Optional[str]:
21
+ """Detect the Linux distribution and return package manager name."""
22
+ if platform.system() != "Linux":
23
+ return None
24
+
25
+ for pkg_mgr_name, config in PACKAGE_MANAGERS.items():
26
+ if command_exists(config["detect_command"]):
27
+ return pkg_mgr_name
28
+
29
+ return None
30
+
31
+
32
+ def install_nodejs_npm() -> bool:
33
+ """Install Node.js and npm using the system package manager."""
34
+ pkg_mgr_name = detect_package_manager()
35
+
36
+ if not pkg_mgr_name:
37
+ error("Could not detect package manager. Please install Node.js manually:")
38
+ for manager_name, config in PACKAGE_MANAGERS.items():
39
+ install_cmd = " ".join(config["install_command"] + config["packages"])
40
+ print(f" {config['description']}: {install_cmd}")
41
+ print(" Or visit: https://nodejs.org/")
42
+ return False
43
+
44
+ config = PACKAGE_MANAGERS[pkg_mgr_name]
45
+ info(f"Detected package manager: {pkg_mgr_name} ({config['description']})")
46
+ info("Installing Node.js and npm...")
47
+
48
+ try:
49
+ for pre_cmd in config["pre_install"]:
50
+ info(f"Running: {' '.join(pre_cmd)}")
51
+ run_command(pre_cmd, check=True, capture=False)
52
+
53
+ install_cmd = config["install_command"] + config["packages"]
54
+ run_command(install_cmd, check=True, capture=False)
55
+
56
+ if not command_exists("npm"):
57
+ error("npm installation completed but npm command not found in PATH")
58
+ error("You may need to restart your shell or add npm to your PATH")
59
+ return False
60
+
61
+ success("Node.js and npm installed successfully")
62
+ return True
63
+ except subprocess.CalledProcessError as exc:
64
+ stderr_msg = (
65
+ getattr(exc, "stderr", None) or getattr(exc, "stdout", None) or str(exc)
66
+ )
67
+ error(f"Failed to install Node.js and npm: {stderr_msg}")
68
+ return False
69
+ except Exception as exc:
70
+ error(f"Failed to install Node.js and npm: {exc}")
71
+ return False
72
+
73
+
74
+ def check_prerequisites(
75
+ tools_to_install: List[str], install_prereqs: bool = False
76
+ ) -> None:
77
+ """Check if prerequisites are met, optionally installing them."""
78
+ needed_prereqs = set()
79
+ tools_needing_node = []
80
+
81
+ for tool_name in tools_to_install:
82
+ tool_config = TOOLS.get(tool_name)
83
+ if not tool_config:
84
+ continue
85
+
86
+ needed_prereqs.update(tool_config.get("prerequisites", []))
87
+ if tool_config.get("min_node_version"):
88
+ tools_needing_node.append((tool_name, tool_config["min_node_version"]))
89
+
90
+ if "npm" in needed_prereqs:
91
+ if not command_exists("npm"):
92
+ if install_prereqs:
93
+ info("npm not found, attempting to install prerequisites...")
94
+ if not install_nodejs_npm():
95
+ sys.exit(1)
96
+ else:
97
+ error("npm is required but not installed.")
98
+ error("Please install Node.js and npm first:")
99
+ for manager_name, config in PACKAGE_MANAGERS.items():
100
+ install_cmd = " ".join(
101
+ config["install_command"] + config["packages"]
102
+ )
103
+ print(f" {config['description']}: {install_cmd}")
104
+ print(" Or visit: https://nodejs.org/")
105
+ print("\nOr use -p/--install-prerequisites to install automatically")
106
+ sys.exit(1)
107
+
108
+ try:
109
+ npm_version = run_command(["npm", "--version"]).stdout.strip()
110
+ info(f"Prerequisites check passed (npm found: {npm_version})")
111
+ except subprocess.CalledProcessError:
112
+ error("Failed to check npm version")
113
+ sys.exit(1)
114
+
115
+ for tool_name, min_version in tools_needing_node:
116
+ try:
117
+ node_version_output = run_command(["node", "--version"]).stdout.strip()
118
+ version_str = node_version_output.lstrip("v")
119
+ version_parts = version_str.replace("-", ".").split(".")
120
+ if not version_parts or not version_parts[0].isdigit():
121
+ raise ValueError(f"Invalid version format: {node_version_output}")
122
+ node_major_version = int(version_parts[0])
123
+ if node_major_version < min_version:
124
+ if install_prereqs:
125
+ warning(
126
+ f"Node.js version {node_version_output} is below v{min_version} "
127
+ f"required for {TOOLS[tool_name]['name']}"
128
+ )
129
+ warning(
130
+ "You may need to upgrade Node.js manually or use a Node "
131
+ "version manager"
132
+ )
133
+ warning("See: https://nodejs.org/ or https://github.com/nvm-sh/nvm")
134
+ else:
135
+ error(
136
+ f"{TOOLS[tool_name]['name']} requires Node.js version "
137
+ f"{min_version} or higher."
138
+ )
139
+ error(f"Current version: {node_version_output}")
140
+ error("Please upgrade Node.js: https://nodejs.org/")
141
+ sys.exit(1)
142
+ except (subprocess.CalledProcessError, ValueError, IndexError) as exc:
143
+ error(f"Failed to check Node.js version: {exc}")
144
+ sys.exit(1)
145
+
146
+
147
+ def is_tool_installed(tool_name: str) -> bool:
148
+ """Check if a tool is installed."""
149
+ tool_config = TOOLS.get(tool_name)
150
+ if not tool_config:
151
+ return False
152
+ return command_exists(tool_config["command"])
153
+
154
+
155
+ def check_path_directories() -> None:
156
+ """Check if common binary installation directories are in PATH and warn if not."""
157
+ current_path = os.environ.get("PATH", "")
158
+ path_entries = current_path.split(":")
159
+
160
+ common_dirs = [
161
+ os.path.expanduser("~/.local/bin"),
162
+ os.path.expanduser("~/.npm-packages/bin"),
163
+ os.path.expanduser("~/.amp/bin"),
164
+ ]
165
+
166
+ missing_dirs = []
167
+ for dir_path in common_dirs:
168
+ if dir_path not in path_entries and os.path.isdir(dir_path):
169
+ missing_dirs.append(dir_path)
170
+
171
+ if missing_dirs:
172
+ warning("The following directories exist but are not in your PATH:")
173
+ for dir_path in missing_dirs:
174
+ print(f" {dir_path}")
175
+ print()
176
+ print("To use installed tools, add to ~/.bashrc or ~/.zshrc:")
177
+ for dir_path in missing_dirs:
178
+ print(f' export PATH="{dir_path}:$PATH"')
179
+ print()
code_aide/status.py ADDED
@@ -0,0 +1,86 @@
1
+ """Status helpers for installed tools and version reporting."""
2
+
3
+ import subprocess
4
+ from typing import Any, Dict, Optional
5
+
6
+ from code_aide.constants import Colors
7
+ from code_aide.prereqs import is_tool_installed
8
+ from code_aide.versions import (
9
+ extract_version_from_string,
10
+ normalize_version,
11
+ status_version_matches_latest,
12
+ version_is_newer,
13
+ )
14
+
15
+
16
+ def print_system_version_status(
17
+ cli_version: str,
18
+ latest_version: Optional[str],
19
+ pkg_info: Dict[str, Optional[str]],
20
+ ) -> None:
21
+ """Print version status for a system-package-managed tool."""
22
+ installed_ver = extract_version_from_string(cli_version)
23
+ avail_ver = pkg_info.get("available_version")
24
+ avail_date = pkg_info.get("available_date")
25
+
26
+ pkg_up_to_date = True
27
+ if installed_ver and avail_ver:
28
+ pkg_up_to_date = installed_ver == normalize_version(
29
+ avail_ver
30
+ ) or version_is_newer(installed_ver, normalize_version(avail_ver))
31
+
32
+ if pkg_up_to_date:
33
+ print(f" Version: {cli_version} {Colors.GREEN}(up to date){Colors.NC}")
34
+ else:
35
+ print(
36
+ f" Version: {cli_version} {Colors.YELLOW}(package has {avail_ver})"
37
+ f"{Colors.NC}"
38
+ )
39
+
40
+ if avail_ver:
41
+ date_suffix = f", {avail_date}" if avail_date else ""
42
+ pkg_name = pkg_info.get("package") or "system"
43
+ if latest_version and not status_version_matches_latest(
44
+ avail_ver, latest_version
45
+ ):
46
+ print(
47
+ f" Packaged: {avail_ver} ({pkg_name}{date_suffix}) "
48
+ f"{Colors.YELLOW}(upstream: {latest_version}){Colors.NC}"
49
+ )
50
+ else:
51
+ print(f" Packaged: {avail_ver} ({pkg_name}{date_suffix})")
52
+
53
+
54
+ def get_tool_status(tool_name: str, tool_config: Dict[str, Any]) -> Dict[str, Any]:
55
+ """Get status information for a specific tool."""
56
+ status_info = {
57
+ "installed": is_tool_installed(tool_name),
58
+ "version": None,
59
+ "user": None,
60
+ "usage": None,
61
+ "errors": [],
62
+ }
63
+
64
+ if not status_info["installed"]:
65
+ return status_info
66
+
67
+ command = tool_config["command"]
68
+ version_args = tool_config.get("version_args", ["--version"])
69
+ cmd = [command] + version_args
70
+ try:
71
+ result = subprocess.run(
72
+ cmd,
73
+ capture_output=True,
74
+ text=True,
75
+ timeout=10,
76
+ check=False,
77
+ stdin=subprocess.DEVNULL,
78
+ )
79
+ if result.returncode == 0 and result.stdout.strip():
80
+ status_info["version"] = result.stdout.strip().split("\n")[0]
81
+ except subprocess.TimeoutExpired:
82
+ status_info["errors"].append("Version check timed out after 10s")
83
+ except Exception:
84
+ pass
85
+
86
+ return status_info
code_aide/versions.py ADDED
@@ -0,0 +1,356 @@
1
+ """Version parsing and upstream update-check helpers."""
2
+
3
+ import email.utils
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import urllib.request
8
+ from datetime import datetime, timezone
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from code_aide import __version__
12
+ from code_aide.constants import Colors
13
+
14
+
15
+ def fetch_url(url: str, timeout: int = 30) -> tuple:
16
+ """Fetch content from a URL. Returns (bytes, last_modified_str)."""
17
+ req = urllib.request.Request(
18
+ url, headers={"User-Agent": f"code-aide/{__version__}"}
19
+ )
20
+ with urllib.request.urlopen(req, timeout=timeout) as response:
21
+ content = response.read()
22
+ last_modified = response.headers.get("Last-Modified")
23
+ return content, last_modified
24
+
25
+
26
+ def parse_http_date(date_str: Optional[str]) -> Optional[str]:
27
+ """Parse an HTTP date header into YYYY-MM-DD format."""
28
+ if not date_str:
29
+ return None
30
+ try:
31
+ parsed = email.utils.parsedate_to_datetime(date_str)
32
+ return parsed.strftime("%Y-%m-%d")
33
+ except Exception:
34
+ return None
35
+
36
+
37
+ def parse_iso_date(date_str: Optional[str]) -> Optional[str]:
38
+ """Parse an ISO 8601 date string into YYYY-MM-DD format."""
39
+ if not date_str:
40
+ return None
41
+ try:
42
+ dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
43
+ return dt.strftime("%Y-%m-%d")
44
+ except Exception:
45
+ return None
46
+
47
+
48
+ def normalize_version(version: str) -> str:
49
+ """Normalize a version string for storage and comparison."""
50
+ return version.lstrip("v")
51
+
52
+
53
+ def status_version_matches_latest(status_version: str, latest_version: str) -> bool:
54
+ """Return True when a tool-reported version string matches latest_version."""
55
+ if not status_version or not latest_version:
56
+ return False
57
+
58
+ latest_norm = normalize_version(latest_version.strip())
59
+ status_text = status_version.strip()
60
+
61
+ if normalize_version(status_text) == latest_norm:
62
+ return True
63
+
64
+ patterns = [
65
+ r"\d{4}\.\d{2}\.\d{2}-[0-9a-f]+",
66
+ r"[vV]?\d+(?:\.\d+)+(?:[-+][0-9A-Za-z._-]+)?",
67
+ ]
68
+ for pattern in patterns:
69
+ for match in re.finditer(pattern, status_text):
70
+ if normalize_version(match.group(0)) == latest_norm:
71
+ return True
72
+
73
+ return False
74
+
75
+
76
+ def extract_version_from_string(version_string: str) -> Optional[str]:
77
+ """Extract a normalized version number from a tool version output string."""
78
+ if not version_string:
79
+ return None
80
+
81
+ text = version_string.strip()
82
+
83
+ normalized = normalize_version(text)
84
+ if re.match(r"^\d+(?:\.\d+)+$", normalized):
85
+ return normalized
86
+
87
+ patterns = [
88
+ r"\d{4}\.\d{2}\.\d{2}-[0-9a-f]+",
89
+ r"[vV]?\d+(?:\.\d+)+(?:[-+][0-9A-Za-z._-]+)?",
90
+ ]
91
+ for pattern in patterns:
92
+ match = re.search(pattern, text)
93
+ if match:
94
+ return normalize_version(match.group(0))
95
+
96
+ return None
97
+
98
+
99
+ def version_is_newer(version_a: str, version_b: str) -> bool:
100
+ """Return True if version_a is strictly newer than version_b."""
101
+
102
+ def parse_components(version: str) -> list:
103
+ parts = re.split(r"[.\-]", version)
104
+ result = []
105
+ for part in parts:
106
+ try:
107
+ result.append((0, int(part)))
108
+ except ValueError:
109
+ result.append((1, part))
110
+ return result
111
+
112
+ a_parts = parse_components(version_a)
113
+ b_parts = parse_components(version_b)
114
+
115
+ return a_parts > b_parts
116
+
117
+
118
+ def check_npm_tool(
119
+ tool_name: str, tool_config: Dict[str, Any], verbose: bool = False
120
+ ) -> Dict[str, Any]:
121
+ """Check an npm tool for the latest version and publish date."""
122
+ package = tool_config["npm_package"]
123
+ result: Dict[str, Any] = {
124
+ "tool": tool_name,
125
+ "type": "npm",
126
+ "version": "-",
127
+ "date": "-",
128
+ "status": "unknown",
129
+ "update": None,
130
+ }
131
+
132
+ try:
133
+ url = f"https://registry.npmjs.org/{package}"
134
+ raw, _ = fetch_url(url)
135
+ data = json.loads(raw)
136
+
137
+ latest_version = data.get("dist-tags", {}).get("latest", "?")
138
+ result["version"] = latest_version
139
+
140
+ time_info = data.get("time", {})
141
+ publish_date = time_info.get(latest_version)
142
+ if publish_date:
143
+ result["date"] = parse_iso_date(publish_date) or "-"
144
+
145
+ result["status"] = "ok"
146
+ except Exception as exc:
147
+ result["status"] = "error"
148
+ if verbose:
149
+ result["version"] = f"error: {exc}"
150
+
151
+ return result
152
+
153
+
154
+ def extract_script_date(
155
+ version_info: Optional[str], last_modified: Optional[str]
156
+ ) -> Optional[str]:
157
+ """Extract a date from a script tool's version string or HTTP header."""
158
+ if version_info:
159
+ epoch_match = re.search(r"\.(\d{10,})", version_info)
160
+ if epoch_match:
161
+ try:
162
+ dt = datetime.fromtimestamp(int(epoch_match.group(1)), tz=timezone.utc)
163
+ return dt.strftime("%Y-%m-%d")
164
+ except (ValueError, OSError):
165
+ pass
166
+ match = re.match(r"(\d{4})\.(\d{2})\.(\d{2})", version_info)
167
+ if match:
168
+ return f"{match.group(1)}-{match.group(2)}-{match.group(3)}"
169
+ return parse_http_date(last_modified)
170
+
171
+
172
+ def extract_script_version(
173
+ tool_name: str,
174
+ tool_config: Dict[str, Any],
175
+ script_content: bytes,
176
+ ) -> Optional[str]:
177
+ """Try to extract a version string from script content or version URL."""
178
+ version_url = tool_config.get("version_url")
179
+ if version_url:
180
+ try:
181
+ version_data, _ = fetch_url(version_url)
182
+ version_str = version_data.decode("utf-8").strip()
183
+ if "<" not in version_str and len(version_str) < 50:
184
+ if not version_str.startswith("v"):
185
+ version_str = f"v{version_str}"
186
+ return version_str
187
+ except Exception:
188
+ pass
189
+
190
+ text = script_content.decode("utf-8", errors="replace")
191
+
192
+ if tool_name == "cursor":
193
+ match = re.search(r"(\d{4}\.\d{2}\.\d{2}-[0-9a-f]+)", text)
194
+ if match:
195
+ return match.group(1)
196
+
197
+ for pattern in [
198
+ r'VERSION="([^"]+)"',
199
+ r"VERSION='([^']+)'",
200
+ r"VERSION=(\S+)",
201
+ ]:
202
+ match = re.search(pattern, text)
203
+ if match:
204
+ return match.group(1)
205
+
206
+ return None
207
+
208
+
209
+ def check_script_tool(
210
+ tool_name: str, tool_config: Dict[str, Any], verbose: bool = False
211
+ ) -> Dict[str, Any]:
212
+ """Check a script/direct_download tool for SHA256 changes, version, and date."""
213
+ install_url = tool_config["install_url"]
214
+ current_sha256 = tool_config.get("install_sha256", "")
215
+
216
+ result: Dict[str, Any] = {
217
+ "tool": tool_name,
218
+ "type": tool_config.get("install_type", "script"),
219
+ "version": "-",
220
+ "date": "-",
221
+ "sha256_current": current_sha256[:12] + "..." if current_sha256 else "none",
222
+ "sha256_latest": "-",
223
+ "status": "unknown",
224
+ "update": None,
225
+ }
226
+
227
+ try:
228
+ script_content, last_modified = fetch_url(install_url)
229
+ actual_sha256 = hashlib.sha256(script_content).hexdigest()
230
+
231
+ if verbose:
232
+ result["sha256_current"] = current_sha256 or "none"
233
+ result["sha256_latest"] = actual_sha256
234
+ else:
235
+ result["sha256_latest"] = actual_sha256[:12] + "..."
236
+
237
+ version_info = extract_script_version(tool_name, tool_config, script_content)
238
+ if version_info:
239
+ result["version"] = version_info
240
+
241
+ date_str = extract_script_date(version_info, last_modified)
242
+ if date_str:
243
+ result["date"] = date_str
244
+
245
+ if actual_sha256 == current_sha256:
246
+ result["status"] = "ok"
247
+ else:
248
+ result["status"] = "changed"
249
+ result["update"] = {"install_sha256": actual_sha256}
250
+
251
+ except Exception as exc:
252
+ result["status"] = "error"
253
+ if verbose:
254
+ result["version"] = f"error: {exc}"
255
+
256
+ return result
257
+
258
+
259
+ def format_check_status(status: str) -> str:
260
+ """Format an update-check status string with color."""
261
+ if status == "ok":
262
+ return f"{Colors.GREEN}ok{Colors.NC}"
263
+ if status == "changed":
264
+ return f"{Colors.YELLOW}changed{Colors.NC}"
265
+ if status == "error":
266
+ return f"{Colors.RED}error{Colors.NC}"
267
+ return status
268
+
269
+
270
+ def format_check_backend(check_type: str) -> str:
271
+ """Format update-check backend labels for display."""
272
+ if check_type == "npm":
273
+ return "npm-registry"
274
+ if check_type in ("script", "direct_download"):
275
+ return "script-url"
276
+ if check_type == "self_managed":
277
+ return "npm-registry"
278
+ return check_type
279
+
280
+
281
+ def print_check_results_table(
282
+ results: List[Dict[str, Any]], verbose: bool = False
283
+ ) -> None:
284
+ """Print update-check results as a formatted table."""
285
+ if verbose:
286
+ headers = [
287
+ "Tool",
288
+ "Check",
289
+ "Version",
290
+ "Date",
291
+ "Current SHA256",
292
+ "Latest SHA256",
293
+ "Status",
294
+ ]
295
+ else:
296
+ headers = ["Tool", "Check", "Version", "Date", "Status"]
297
+
298
+ rows = []
299
+ for result in results:
300
+ if verbose:
301
+ rows.append(
302
+ [
303
+ result["tool"],
304
+ format_check_backend(result["type"]),
305
+ result.get("version", "-"),
306
+ result.get("date", "-"),
307
+ result.get("sha256_current", "-"),
308
+ result.get("sha256_latest", "-"),
309
+ result["status"],
310
+ ]
311
+ )
312
+ else:
313
+ rows.append(
314
+ [
315
+ result["tool"],
316
+ format_check_backend(result["type"]),
317
+ result.get("version", "-"),
318
+ result.get("date", "-"),
319
+ result["status"],
320
+ ]
321
+ )
322
+
323
+ widths = [len(header) for header in headers]
324
+ for row in rows:
325
+ for i, cell in enumerate(row):
326
+ plain = re.sub(r"\033\[[^m]*m", "", str(cell))
327
+ widths[i] = max(widths[i], len(plain))
328
+
329
+ header_line = " ".join(header.ljust(widths[i]) for i, header in enumerate(headers))
330
+ print(f"\n{Colors.BOLD}{header_line}{Colors.NC}")
331
+ print(" ".join("-" * width for width in widths))
332
+
333
+ for row in rows:
334
+ cells = []
335
+ for i, cell in enumerate(row):
336
+ if i == len(row) - 1:
337
+ cells.append(format_check_status(cell))
338
+ else:
339
+ cells.append(str(cell).ljust(widths[i]))
340
+ print(" ".join(cells))
341
+
342
+ print()
343
+
344
+
345
+ def apply_sha256_updates(
346
+ config: Dict[str, Any], results: List[Dict[str, Any]]
347
+ ) -> List[str]:
348
+ """Apply pending SHA256 updates to the config dict."""
349
+ updated = []
350
+ for result in results:
351
+ if result["update"]:
352
+ tool_name = result["tool"]
353
+ for key, value in result["update"].items():
354
+ config["tools"][tool_name][key] = value
355
+ updated.append(tool_name)
356
+ return updated