Coverage for .claude/hooks/moai/lib/project.py: 0.00%

227 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-19 08:00 +0900

1#!/usr/bin/env python3 

2"""Project metadata utilities 

3 

4Project information inquiry (language, Git, SPEC progress, etc.) 

5""" 

6 

7import json 

8import signal 

9import socket 

10import subprocess 

11from contextlib import contextmanager 

12from pathlib import Path 

13from typing import Any 

14 

15# Cache directory for version check results 

16CACHE_DIR_NAME = ".moai/cache" 

17 

18 

19def find_project_root(start_path: str | Path = ".") -> Path: 

20 """Find MoAI-ADK project root by searching upward for .moai/config/config.json 

21 

22 Traverses up the directory tree until it finds .moai/config/config.json or CLAUDE.md, 

23 which indicates the project root. This ensures cache and other files are 

24 always created in the correct location, regardless of where hooks execute. 

25 

26 Args: 

27 start_path: Starting directory (default: current directory) 

28 

29 Returns: 

30 Project root Path. If not found, returns start_path as absolute path. 

31 

32 Examples: 

33 >>> find_project_root(".") 

34 Path("/Users/user/my-project") 

35 >>> find_project_root(".claude/hooks/alfred") 

36 Path("/Users/user/my-project") # Found root 3 levels up 

37 

38 Notes: 

39 - Searches for .moai/config/config.json first (most reliable) 

40 - Falls back to CLAUDE.md if config.json not found 

41 - Max depth: 10 levels up (prevent infinite loop) 

42 - Returns absolute path for consistency 

43 

44 TDD History: 

45 - RED: 4 test scenarios (root, nested, not found, symlinks) 

46 - GREEN: Minimal upward search with .moai/config/config.json detection 

47 - REFACTOR: Add CLAUDE.md fallback, max depth limit, absolute path return 

48 """ 

49 current = Path(start_path).resolve() 

50 max_depth = 10 # Prevent infinite loop 

51 

52 for _ in range(max_depth): 

53 # Check for .moai/config/config.json (primary indicator) 

54 if (current / ".moai" / "config.json").exists(): 

55 return current 

56 

57 # Check for CLAUDE.md (secondary indicator) 

58 if (current / "CLAUDE.md").exists(): 

59 return current 

60 

61 # Move up one level 

62 parent = current.parent 

63 if parent == current: # Reached filesystem root 

64 break 

65 current = parent 

66 

67 # Not found - return start_path as absolute 

68 return Path(start_path).resolve() 

69 

70 

71class TimeoutError(Exception): 

72 """Signal-based timeout exception""" 

73 

74 pass 

75 

76 

77@contextmanager 

78def timeout_handler(seconds: int): 

79 """Hard timeout using SIGALRM (works on Unix systems including macOS) 

80 

81 This uses kernel-level signal to interrupt ANY blocking operation, 

82 even if subprocess.run() timeout fails on macOS. 

83 

84 Args: 

85 seconds: Timeout duration in seconds 

86 

87 Raises: 

88 TimeoutError: If operation exceeds timeout 

89 """ 

90 

91 def _handle_timeout(signum, frame): 

92 raise TimeoutError(f"Operation timed out after {seconds} seconds") 

93 

94 # Set the signal handler 

95 old_handler = signal.signal(signal.SIGALRM, _handle_timeout) 

96 signal.alarm(seconds) 

97 try: 

98 yield 

99 finally: 

100 signal.alarm(0) # Disable alarm 

101 signal.signal(signal.SIGALRM, old_handler) 

102 

103 

104def detect_language(cwd: str) -> str: 

105 """Detect project language (supports 20 items languages) 

106 

107 Browse the File system to detect your project's main development language. 

108 First, check configuration files such as pyproject.toml and tsconfig.json. 

109 Apply TypeScript first principles (if tsconfig.json exists). 

110 

111 Args: 

112 cwd: Project root directory path (both absolute and relative paths are possible) 

113 

114 Returns: 

115 str: Detected language name (lowercase). If detection fails, "Unknown Language" is returned. 

116 Supported languages: python, typescript, javascript, java, go, rust, 

117 dart, swift, kotlin, php, ruby, elixir, scala, 

118 clojure, cpp, c, csharp, haskell, shell, lua 

119 

120 Examples: 

121 >>> detect_language("/path/to/python/project") 

122 'python' 

123 >>> detect_language("/path/to/typescript/project") 

124 'typescript' 

125 >>> detect_language("/path/to/unknown/project") 

126 'Unknown Language' 

127 

128 TDD History: 

129 - RED: Write a 21 items language detection test (20 items language + 1 items unknown) 

130 - GREEN: 20 items language + unknown implementation, all tests passed 

131 - REFACTOR: Optimize file inspection order, apply TypeScript priority principle 

132 """ 

133 cwd_path = Path(cwd) 

134 

135 # Language detection mapping 

136 language_files = { 

137 "pyproject.toml": "python", 

138 "tsconfig.json": "typescript", 

139 "package.json": "javascript", 

140 "pom.xml": "java", 

141 "go.mod": "go", 

142 "Cargo.toml": "rust", 

143 "pubspec.yaml": "dart", 

144 "Package.swift": "swift", 

145 "build.gradle.kts": "kotlin", 

146 "composer.json": "php", 

147 "Gemfile": "ruby", 

148 "mix.exs": "elixir", 

149 "build.sbt": "scala", 

150 "project.clj": "clojure", 

151 "CMakeLists.txt": "cpp", 

152 "Makefile": "c", 

153 } 

154 

155 # Check standard language files 

156 for file_name, language in language_files.items(): 

157 if (cwd_path / file_name).exists(): 

158 # Special handling for package.json - prefer typescript if tsconfig exists 

159 if file_name == "package.json" and (cwd_path / "tsconfig.json").exists(): 

160 return "typescript" 

161 return language 

162 

163 # Check for C# project files (*.csproj) 

164 if any(cwd_path.glob("*.csproj")): 

165 return "csharp" 

166 

167 # Check for Haskell project files (*.cabal) 

168 if any(cwd_path.glob("*.cabal")): 

169 return "haskell" 

170 

171 # Check for Shell scripts (*.sh) 

172 if any(cwd_path.glob("*.sh")): 

173 return "shell" 

174 

175 # Check for Lua files (*.lua) 

176 if any(cwd_path.glob("*.lua")): 

177 return "lua" 

178 

179 return "Unknown Language" 

180 

181 

182def _run_git_command(args: list[str], cwd: str, timeout: int = 2) -> str: 

183 """Git command execution with HARD timeout protection 

184 

185 Safely execute Git commands and return output. 

186 Uses SIGALRM (kernel-level interrupt) to handle macOS subprocess timeout bug. 

187 Eliminates code duplication and provides consistent error handling. 

188 

189 Args: 

190 args: Git command argument list (git adds automatically) 

191 cwd: Execution directory path 

192 timeout: Timeout in seconds (default: 2 seconds) 

193 

194 Returns: 

195 str: Git command output (stdout, removing leading and trailing spaces) 

196 

197 Raises: 

198 subprocess.TimeoutExpired: Timeout exceeded (via TimeoutError) 

199 subprocess.CalledProcessError: Git command failed 

200 

201 Examples: 

202 >>> _run_git_command(["branch", "--show-current"], ".") 

203 'main' 

204 

205 TDD History: 

206 - RED: Git command hang scenario test 

207 - GREEN: SIGALRM-based timeout implementation 

208 - REFACTOR: Exception conversion to subprocess.TimeoutExpired 

209 """ 

210 try: 

211 with timeout_handler(timeout): 

212 result = subprocess.run( 

213 ["git"] + args, 

214 cwd=cwd, 

215 capture_output=True, 

216 text=True, 

217 check=False, # Don't raise on non-zero exit - we'll check manually 

218 ) 

219 

220 # Check exit code manually 

221 if result.returncode != 0: 

222 raise subprocess.CalledProcessError( 

223 result.returncode, ["git"] + args, result.stdout, result.stderr 

224 ) 

225 

226 return result.stdout.strip() 

227 

228 except TimeoutError: 

229 # Convert to subprocess.TimeoutExpired for consistent error handling 

230 raise subprocess.TimeoutExpired(["git"] + args, timeout) 

231 

232 

233def get_git_info(cwd: str) -> dict[str, Any]: 

234 """Gather Git repository information 

235 

236 View the current status of a Git repository. 

237 Returns the branch name, commit hash, number of changes, and last commit message. 

238 If it is not a Git repository, it returns an empty dictionary. 

239 

240 Args: 

241 cwd: Project root directory path 

242 

243 Returns: 

244 Git information dictionary. Includes the following keys: 

245 - branch: Current branch name (str) 

246 - commit: Current commit hash (str, full hash) 

247 - changes: Number of changed files (int, staged + unstaged) 

248 - last_commit: Last commit message (str, subject only) 

249 

250 Empty dictionary {} if it is not a Git repository or the query fails. 

251 

252 Examples: 

253 >>> get_git_info("/path/to/git/repo") 

254 {'branch': 'main', 'commit': 'abc123...', 'changes': 3, 'last_commit': 'Fix bug'} 

255 >>> get_git_info("/path/to/non-git") 

256 {} 

257 

258 Notes: 

259 - Timeout: 2 seconds for each Git command 

260 - Security: Safe execution with subprocess.run(shell=False) 

261 - Error handling: Returns an empty dictionary in case of all exceptions 

262 - Commit message limited to 50 characters for display purposes 

263 

264 TDD History: 

265 - RED: 3 items scenario test (Git repo, non-Git, error) 

266 - GREEN: Implementation of subprocess-based Git command execution 

267 - REFACTOR: Add timeout (2 seconds), strengthen exception handling, remove duplicates with helper function 

268 - UPDATE: Added last_commit message field for SessionStart display 

269 """ 

270 try: 

271 # Check if it's a git repository 

272 _run_git_command(["rev-parse", "--git-dir"], cwd) 

273 

274 # Get branch name, commit hash, and changes 

275 branch = _run_git_command(["branch", "--show-current"], cwd) 

276 commit = _run_git_command(["rev-parse", "HEAD"], cwd) 

277 status_output = _run_git_command(["status", "--short"], cwd) 

278 changes = len([line for line in status_output.splitlines() if line]) 

279 

280 # Get last commit message (subject only, limited to 50 chars) 

281 last_commit = _run_git_command(["log", "-1", "--format=%s"], cwd) 

282 if len(last_commit) > 50: 

283 last_commit = last_commit[:47] + "..." 

284 

285 return { 

286 "branch": branch, 

287 "commit": commit, 

288 "changes": changes, 

289 "last_commit": last_commit, 

290 } 

291 

292 except (subprocess.TimeoutExpired, subprocess.CalledProcessError, FileNotFoundError): 

293 return {} 

294 

295 

296def count_specs(cwd: str) -> dict[str, int]: 

297 """SPEC File count and progress calculation 

298 

299 Browse the .moai/specs/ directory to find the number of SPEC Files and 

300 Counts the number of SPECs with status: completed. 

301 

302 Args: 

303 cwd: Project root directory path (or any subdirectory, will search upward) 

304 

305 Returns: 

306 SPEC progress dictionary. Includes the following keys: 

307 - completed: Number of completed SPECs (int) 

308 - total: total number of SPECs (int) 

309 - percentage: completion percentage (int, 0~100) 

310 

311 All 0 if .moai/specs/ directory does not exist 

312 

313 Examples: 

314 >>> count_specs("/path/to/project") 

315 {'completed': 2, 'total': 5, 'percentage': 40} 

316 >>> count_specs("/path/to/no-specs") 

317 {'completed': 0, 'total': 0, 'percentage': 0} 

318 

319 Notes: 

320 - SPEC File Location: .moai/specs/SPEC-{ID}/spec.md 

321 - Completion condition: Include "status: completed" in YAML front matter 

322 - If parsing fails, the SPEC is considered incomplete. 

323 - Automatically finds project root to locate .moai/specs/ 

324 

325 TDD History: 

326 - RED: 5 items scenario test (0/0, 2/5, 5/5, no directory, parsing error) 

327 - GREEN: SPEC search with Path.iterdir(), YAML parsing implementation 

328 - REFACTOR: Strengthened exception handling, improved percentage calculation safety 

329 - UPDATE: Add project root detection for consistent path resolution 

330 """ 

331 # Find project root to ensure we read specs from correct location 

332 project_root = find_project_root(cwd) 

333 specs_dir = project_root / ".moai" / "specs" 

334 

335 if not specs_dir.exists(): 

336 return {"completed": 0, "total": 0, "percentage": 0} 

337 

338 completed = 0 

339 total = 0 

340 

341 for spec_dir in specs_dir.iterdir(): 

342 if not spec_dir.is_dir() or not spec_dir.name.startswith("SPEC-"): 

343 continue 

344 

345 spec_file = spec_dir / "spec.md" 

346 if not spec_file.exists(): 

347 continue 

348 

349 total += 1 

350 

351 # Parse YAML front matter 

352 try: 

353 content = spec_file.read_text() 

354 if content.startswith("---"): 

355 yaml_end = content.find("---", 3) 

356 if yaml_end > 0: 

357 yaml_content = content[3:yaml_end] 

358 if "status: completed" in yaml_content: 

359 completed += 1 

360 except (OSError, UnicodeDecodeError): 

361 # File read failure or encoding error - considered incomplete 

362 pass 

363 

364 percentage = int(completed / total * 100) if total > 0 else 0 

365 

366 return { 

367 "completed": completed, 

368 "total": total, 

369 "percentage": percentage, 

370 } 

371 

372 

373def get_project_language(cwd: str) -> str: 

374 """Determine the primary project language (prefers config.json). 

375 

376 Args: 

377 cwd: Project root directory (or any subdirectory, will search upward). 

378 

379 Returns: 

380 Language string in lower-case. 

381 

382 Notes: 

383 - Reads ``.moai/config/config.json`` first for a quick answer. 

384 - Falls back to ``detect_language`` if configuration is missing. 

385 - Automatically finds project root to locate .moai/config/config.json 

386 """ 

387 # Find project root to ensure we read config from correct location 

388 project_root = find_project_root(cwd) 

389 config_path = project_root / ".moai" / "config.json" 

390 if config_path.exists(): 

391 try: 

392 config = json.loads(config_path.read_text()) 

393 lang = config.get("language", "") 

394 if lang: 

395 return lang 

396 except (OSError, json.JSONDecodeError): 

397 # Fall back to detection on parse errors 

398 pass 

399 

400 # Fall back to the original language detection routine (use project root) 

401 return detect_language(str(project_root)) 

402 

403 

404def _validate_project_structure(cwd: str) -> bool: 

405 """Validate that project has required MoAI-ADK structure 

406 

407 Args: 

408 cwd: Project root directory path 

409 

410 Returns: 

411 bool: True if .moai/config/config.json exists, False otherwise 

412 """ 

413 project_root = find_project_root(cwd) 

414 return (project_root / ".moai" / "config.json").exists() 

415 

416 

417def get_version_check_config(cwd: str) -> dict[str, Any]: 

418 """Read version check configuration from .moai/config/config.json 

419 

420 Returns version check settings with sensible defaults. 

421 Supports frequency-based cache TTL configuration. 

422 

423 Args: 

424 cwd: Project root directory path 

425 

426 Returns: 

427 dict with keys: 

428 - "enabled": Boolean (default: True) 

429 - "frequency": "always" | "daily" | "weekly" | "never" (default: "daily") 

430 - "cache_ttl_hours": TTL in hours based on frequency 

431 

432 Frequency to TTL mapping: 

433 - "always": 0 hours (no caching) 

434 - "daily": 24 hours 

435 - "weekly": 168 hours (7 days) 

436 - "never": infinity (never check) 

437 

438 TDD History: 

439 - RED: 8 test scenarios (defaults, custom, disabled, TTL, etc.) 

440 - GREEN: Minimal config reading with defaults 

441 - REFACTOR: Add validation and error handling 

442 """ 

443 # TTL mapping by frequency 

444 ttl_by_frequency = {"always": 0, "daily": 24, "weekly": 168, "never": float("inf")} 

445 

446 # Default configuration 

447 defaults = {"enabled": True, "frequency": "daily", "cache_ttl_hours": 24} 

448 

449 # Find project root to ensure we read config from correct location 

450 project_root = find_project_root(cwd) 

451 config_path = project_root / ".moai" / "config.json" 

452 if not config_path.exists(): 

453 return defaults 

454 

455 try: 

456 config = json.loads(config_path.read_text()) 

457 

458 # Extract moai.version_check section 

459 moai_config = config.get("moai", {}) 

460 version_check_config = moai_config.get("version_check", {}) 

461 

462 # Read enabled flag (default: True) 

463 enabled = version_check_config.get("enabled", defaults["enabled"]) 

464 

465 # Read frequency (default: "daily") 

466 frequency = moai_config.get("update_check_frequency", defaults["frequency"]) 

467 

468 # Validate frequency 

469 if frequency not in ttl_by_frequency: 

470 frequency = defaults["frequency"] 

471 

472 # Calculate TTL from frequency 

473 cache_ttl_hours = ttl_by_frequency[frequency] 

474 

475 # Allow explicit cache_ttl_hours override 

476 if "cache_ttl_hours" in version_check_config: 

477 cache_ttl_hours = version_check_config["cache_ttl_hours"] 

478 

479 return {"enabled": enabled, "frequency": frequency, "cache_ttl_hours": cache_ttl_hours} 

480 

481 except (OSError, json.JSONDecodeError, KeyError): 

482 # Config read or parse error - return defaults 

483 return defaults 

484 

485 

486def is_network_available(timeout_seconds: float = 0.1) -> bool: 

487 """Quick network availability check using socket. 

488 

489 Does NOT check PyPI specifically, just basic connectivity. 

490 Returns immediately on success (< 50ms typically). 

491 Returns False on any error without raising exceptions. 

492 

493 Args: 

494 timeout_seconds: Socket timeout in seconds (default 0.1s) 

495 

496 Returns: 

497 True if network appears available, False otherwise 

498 

499 Examples: 

500 >>> is_network_available() 

501 True # Network is available 

502 >>> is_network_available(timeout_seconds=0.001) 

503 False # Timeout too short, returns False 

504 

505 TDD History: 

506 - RED: 3 test scenarios (success, failure, timeout) 

507 - GREEN: Minimal socket.create_connection implementation 

508 - REFACTOR: Add error handling for all exception types 

509 """ 

510 try: 

511 # Try connecting to Google's public DNS server (8.8.8.8:53) 

512 # This is a reliable host that's typically reachable 

513 connection = socket.create_connection(("8.8.8.8", 53), timeout=timeout_seconds) 

514 connection.close() 

515 return True 

516 except (socket.timeout, OSError, Exception): 

517 # Any connection error means network is unavailable 

518 # This includes: timeout, connection refused, network unreachable, etc. 

519 return False 

520 

521 

522def is_major_version_change(current: str, latest: str) -> bool: 

523 """Detect if version change is a major version bump. 

524 

525 A major version change is when the first (major) component increases: 

526 - 0.8.1 → 1.0.0: True (0 → 1) 

527 - 1.2.3 → 2.0.0: True (1 → 2) 

528 - 0.8.1 → 0.9.0: False (0 → 0, minor changed) 

529 - 1.2.3 → 1.3.0: False (1 → 1) 

530 

531 Args: 

532 current: Current version string (e.g., "0.8.1") 

533 latest: Latest version string (e.g., "1.0.0") 

534 

535 Returns: 

536 True if major version increased, False otherwise 

537 

538 Examples: 

539 >>> is_major_version_change("0.8.1", "1.0.0") 

540 True 

541 >>> is_major_version_change("0.8.1", "0.9.0") 

542 False 

543 >>> is_major_version_change("dev", "1.0.0") 

544 False # Invalid versions return False 

545 

546 TDD History: 

547 - RED: 4 test scenarios (0→1, 1→2, minor, invalid) 

548 - GREEN: Minimal version parsing and comparison 

549 - REFACTOR: Improve error handling for invalid versions 

550 """ 

551 try: 

552 # Parse version strings into integer components 

553 current_parts = [int(x) for x in current.split(".")] 

554 latest_parts = [int(x) for x in latest.split(".")] 

555 

556 # Compare major version (first component) 

557 if len(current_parts) >= 1 and len(latest_parts) >= 1: 

558 return latest_parts[0] > current_parts[0] 

559 

560 # If parsing succeeds but empty, no major change 

561 return False 

562 

563 except (ValueError, AttributeError, IndexError): 

564 # Invalid version format - return False (no exception) 

565 return False 

566 

567 

568def get_package_version_info(cwd: str = ".") -> dict[str, Any]: 

569 """Check MoAI-ADK current and latest version with caching and offline support. 

570 

571 ⭐ CRITICAL GUARANTEE: This function ALWAYS returns the current installed version. 

572 Network failures, cache issues, and timeouts NEVER result in "unknown" version. 

573 

574 Execution flow: 

575 1. Get current installed version (ALWAYS succeeds) ← CRITICAL 

576 2. Build minimal result with current version 

577 3. Try to load from cache (< 50ms) - optional enhancement 

578 4. If cache valid, return cached latest info 

579 5. If cache invalid/miss, optionally query PyPI - optional enhancement 

580 6. Save result to cache for next time - optional 

581 

582 Args: 

583 cwd: Project root directory (for cache location) 

584 

585 Returns: 

586 dict with keys: 

587 - "current": Current installed version (ALWAYS valid, never empty) 

588 - "latest": Latest version available on PyPI (may be "unknown") 

589 - "update_available": Boolean indicating if update is available 

590 - "upgrade_command": Recommended upgrade command (if update available) 

591 - "release_notes_url": URL to release notes 

592 - "is_major_update": Boolean indicating major version change 

593 

594 Guarantees: 

595 - Cache hit (< 24 hours): Returns in ~20ms, no network access ✓ 

596 - Cache miss + online: Query PyPI (1s timeout), cache result ✓ 

597 - Cache miss + offline: Return current version only (~100ms) ✓ 

598 - Network timeout: Returns current + "unknown" latest (~50ms) ✓ 

599 - Any exception: Always returns current version ✓ 

600 

601 TDD History: 

602 - RED: 5 test scenarios (network detection, cache integration, offline mode) 

603 - GREEN: Integrate VersionCache with network detection 

604 - REFACTOR: Extract cache directory constant, improve error handling 

605 """ 

606 import importlib.util 

607 import urllib.error 

608 import urllib.request 

609 from importlib.metadata import PackageNotFoundError, version 

610 

611 # Import VersionCache from the same directory (using dynamic import for testing compatibility) 

612 try: 

613 version_cache_path = Path(__file__).parent / "version_cache.py" 

614 spec = importlib.util.spec_from_file_location("version_cache", version_cache_path) 

615 if spec and spec.loader: 

616 version_cache_module = importlib.util.module_from_spec(spec) 

617 spec.loader.exec_module(version_cache_module) 

618 version_cache_class = version_cache_module.VersionCache 

619 else: 

620 # Skip caching if module can't be loaded 

621 version_cache_class = None 

622 except (ImportError, OSError): 

623 # Graceful degradation: skip caching on import errors 

624 version_cache_class = None 

625 

626 # 1. Find project root (ensure cache is always in correct location) 

627 # This prevents creating .moai/cache in wrong locations when hooks run 

628 # from subdirectories like .claude/hooks/alfred/ 

629 project_root = find_project_root(cwd) 

630 

631 # 2. Initialize cache (skip if VersionCache couldn't be imported) 

632 cache_dir = project_root / CACHE_DIR_NAME 

633 version_cache = version_cache_class(cache_dir) if version_cache_class else None 

634 

635 # 2. Get current installed version first (needed for cache validation) 

636 current_version = "unknown" 

637 try: 

638 current_version = version("moai-adk") 

639 except PackageNotFoundError: 

640 current_version = "dev" 

641 # Dev mode - skip cache and return immediately 

642 return { 

643 "current": "dev", 

644 "latest": "unknown", 

645 "update_available": False, 

646 "upgrade_command": "", 

647 } 

648 

649 # 3. Try to load from cache (fast path with version validation) 

650 if version_cache and version_cache.is_valid(): 

651 cached_info = version_cache.load() 

652 if cached_info: 

653 # Only use cache if the cached version matches current installed version 

654 # This prevents stale cache when package is upgraded locally 

655 if cached_info.get("current") == current_version: 

656 # Ensure new fields exist for backward compatibility 

657 if "release_notes_url" not in cached_info: 

658 # Add missing fields to old cached data 

659 cached_info.setdefault("release_notes_url", None) 

660 cached_info.setdefault("is_major_update", False) 

661 return cached_info 

662 # else: cache is stale (version changed), fall through to re-check 

663 

664 # 4. Cache miss or stale - need to query PyPI 

665 result = { 

666 "current": current_version, 

667 "latest": "unknown", 

668 "update_available": False, 

669 "upgrade_command": "", 

670 } 

671 

672 # 5. Check if version check is enabled in config 

673 config = get_version_check_config(cwd) 

674 if not config["enabled"]: 

675 # Version check disabled - return only current version 

676 return result 

677 

678 # 6. Check network before PyPI query 

679 if not is_network_available(): 

680 # Offline mode - return current version only 

681 return result 

682 

683 # 7. Network available - query PyPI 

684 pypi_data = None 

685 try: 

686 with timeout_handler(1): 

687 url = "https://pypi.org/pypi/moai-adk/json" 

688 headers = {"Accept": "application/json"} 

689 req = urllib.request.Request(url, headers=headers) 

690 with urllib.request.urlopen(req, timeout=0.8) as response: 

691 pypi_data = json.load(response) 

692 result["latest"] = pypi_data.get("info", {}).get("version", "unknown") 

693 

694 # Extract release notes URL from project_urls 

695 try: 

696 project_urls = pypi_data.get("info", {}).get("project_urls", {}) 

697 release_url = project_urls.get("Changelog", "") 

698 if not release_url: 

699 # Fallback to GitHub releases URL pattern 

700 release_url = ( 

701 f"https://github.com/modu-ai/moai-adk/releases/tag/v{result['latest']}" 

702 ) 

703 result["release_notes_url"] = release_url 

704 except (KeyError, AttributeError, TypeError): 

705 result["release_notes_url"] = None 

706 

707 except (urllib.error.URLError, TimeoutError, Exception): 

708 # PyPI query failed - return current version 

709 result["release_notes_url"] = None 

710 pass 

711 

712 # 7. Compare versions (simple comparison) 

713 if result["current"] != "unknown" and result["latest"] != "unknown": 

714 try: 

715 # Parse versions for comparison 

716 current_parts = [int(x) for x in result["current"].split(".")] 

717 latest_parts = [int(x) for x in result["latest"].split(".")] 

718 

719 # Pad shorter version with zeros 

720 max_len = max(len(current_parts), len(latest_parts)) 

721 current_parts.extend([0] * (max_len - len(current_parts))) 

722 latest_parts.extend([0] * (max_len - len(latest_parts))) 

723 

724 if latest_parts > current_parts: 

725 result["update_available"] = True 

726 result["upgrade_command"] = "uv tool upgrade moai-adk" 

727 

728 # Detect major version change 

729 result["is_major_update"] = is_major_version_change( 

730 result["current"], result["latest"] 

731 ) 

732 else: 

733 result["is_major_update"] = False 

734 except (ValueError, AttributeError): 

735 # Version parsing failed - skip comparison 

736 result["is_major_update"] = False 

737 pass 

738 

739 # 8. Save result to cache (if caching is available) 

740 if version_cache: 

741 version_cache.save(result) 

742 

743 return result 

744 

745 

746__all__ = [ 

747 "find_project_root", 

748 "detect_language", 

749 "get_git_info", 

750 "count_specs", 

751 "get_project_language", 

752 "get_version_check_config", 

753 "is_network_available", 

754 "is_major_version_change", 

755 "get_package_version_info", 

756]