diff --git a/xircuits/library/__init__.py b/xircuits/library/__init__.py index 31f7ad7e..06ee68f6 100644 --- a/xircuits/library/__init__.py +++ b/xircuits/library/__init__.py @@ -1,4 +1,5 @@ from .list_library import list_component_library from .install_fetch_library import install_library, fetch_library, uninstall_library from .create_library import create_or_update_library -from .update_library import update_library \ No newline at end of file +from .update_library import update_library +from .core_libs import CORE_LIBS, is_core_library diff --git a/xircuits/library/core_libs.py b/xircuits/library/core_libs.py new file mode 100644 index 00000000..00bfa5a8 --- /dev/null +++ b/xircuits/library/core_libs.py @@ -0,0 +1,16 @@ +from typing import FrozenSet + +# Core component libraries that are bundled with the xircuits wheel +CORE_LIBS: FrozenSet[str] = frozenset({ + "xai_controlflow", + "xai_events", + "xai_template", + "xai_utils", +}) + +def is_core_library(library_name: str) -> bool: + """Check if a library name refers to a core component.""" + normalized = library_name.strip().lower() + if not normalized.startswith("xai_"): + normalized = f"xai_{normalized}" + return normalized in CORE_LIBS \ No newline at end of file diff --git a/xircuits/library/install_fetch_library.py b/xircuits/library/install_fetch_library.py index 5bd498ab..3ba03050 100644 --- a/xircuits/library/install_fetch_library.py +++ b/xircuits/library/install_fetch_library.py @@ -1,6 +1,8 @@ import shutil from pathlib import Path +from .core_libs import is_core_library + from xircuits.utils.file_utils import is_valid_url, is_empty from xircuits.utils.requirements_utils import read_requirements_for_library from xircuits.utils.git_toml_manager import ( @@ -19,10 +21,6 @@ from ..handlers.request_remote import request_remote_library from ..handlers.request_folder import clone_from_github_url - -CORE_LIBS = {"xai_events", "xai_template", "xai_controlflow", "xai_utils"} - - def get_component_library_path(library_name: str) -> str: """ If a URL is provided, clone to xai_components/xai_. @@ -149,7 +147,7 @@ def uninstall_library(library_name: str) -> str: raw = "xai_" + raw short = raw.split("/")[-1] - if short in CORE_LIBS: + if is_core_library(short): raise RuntimeError(f"'{short}' is a core library and cannot be uninstalled.") lib_path = resolve_library_dir(short) diff --git a/xircuits/library/update_library.py b/xircuits/library/update_library.py index 9d1f3915..41951286 100644 --- a/xircuits/library/update_library.py +++ b/xircuits/library/update_library.py @@ -6,11 +6,13 @@ from dataclasses import dataclass from pathlib import Path from typing import List, Optional, Set +from importlib_resources import files, as_file from xircuits.utils.pathing import ( resolve_working_dir, resolve_library_dir, normalize_library_slug, + components_base_dir, ) from xircuits.utils.git_toml_manager import ( read_component_metadata_entry, @@ -26,6 +28,8 @@ from xircuits.utils.venv_ops import install_specs from xircuits.handlers.request_remote import get_remote_config +from .core_libs import is_core_library + @dataclass class SourceSpec: @@ -48,48 +52,186 @@ def update_library( dry_run: bool = False, prune: bool = False, install_deps: bool = True, + use_latest: bool = False, + no_overwrite: bool = False, ) -> str: """ - Safely update an installed component library (e.g., "gradio"). + Update an installed component library (core or regular). - Defaults: - - Keeps any local-only files/dirs as-is (no deletions) unless prune=True. - - Overwrites changed files, backing up the previous version in-place as *.YYYYmmdd-HHMMSS.bak. - - Updates per-library extra and installs requirements (unless disabled). + Core libraries (xai_events, xai_template, xai_controlflow, xai_utils, base.py) + are updated from the installed xircuits wheel. + + Regular libraries are updated from their git repository. Args: - library_name: "gradio", "xai_gradio", etc. (normalized internally) - repo: Optional repository URL override (persists into pyproject if not dry-run). - ref: Optional tag/branch/commit to update to. + library_name: "gradio", "xai_events", "base.py", etc. (normalized internally) + repo: Optional repository URL override (ignored for core libs) + ref: Optional tag/branch/commit to update to (ignored for core libs) dry_run: If True, compute actions and print a unified diff (no files changed). - Also writes a combined diff file alongside the library directory. prune: If True, also archive local-only files/dirs (rename to *.bak). install_deps: If True (default), update per-library extra and install its requirements. + use_latest: If True, ignore metadata ref and pull latest from default branch. + no_overwrite: If True, skip updating files with local modifications. - Output policy: - - Print action markers: - +++ path (added or written) - --- path (...) (backed up / deleted) - - Unchanged files are not printed. - - On dry-run, a unified diff of changes is printed and saved to disk. + Returns: + Summary message of update results. """ working_dir = resolve_working_dir() if working_dir is None: raise RuntimeError("Xircuits working directory not found. Run 'xircuits init' first.") - lib_name = normalize_library_slug(library_name) # e.g., 'xai_gradio' - dest_dir = resolve_library_dir(lib_name) # absolute path + # Check if updating base.py + normalized = library_name.strip().lower() + if normalized in ("base", "base.py"): + return _update_from_wheel( + component_name="base.py", + working_dir=working_dir, + dry_run=dry_run, + prune=prune, + no_overwrite=no_overwrite, + ) + + # Normalize library name and check if it's a core library + lib_name = normalize_library_slug(library_name) + if is_core_library(lib_name): + return _update_from_wheel( + component_name=lib_name, + working_dir=working_dir, + dry_run=dry_run, + prune=prune, + no_overwrite=no_overwrite, + ) + + # Regular (non-core) library update from git + return _update_from_git( + lib_name=lib_name, + repo=repo, + ref=ref, + dry_run=dry_run, + prune=prune, + install_deps=install_deps, + use_latest=use_latest, + no_overwrite=no_overwrite, + ) + +def _update_from_wheel( + component_name: str, + working_dir: Path, + dry_run: bool, + prune: bool, + no_overwrite: bool = False, +) -> str: + """ + Update a core component from the installed xircuits wheel. + """ + dest_base = working_dir / "xai_components" + if not dest_base.exists(): + raise RuntimeError(f"xai_components directory not found at {working_dir}") + + timestamp = time.strftime("%Y%m%d-%H%M%S") + is_base_file = component_name == "base.py" + display_name = component_name + + print(f"Updating core component: {display_name} (from installed wheel)") + + temp_dir = Path(tempfile.mkdtemp(prefix=f"update_core_{component_name.replace('.', '_')}_")) + + try: + # Extract from wheel to temp directory + if is_base_file: + _extract_base_py_from_wheel(temp_dir) + src_path = temp_dir / "base.py" + dst_path = dest_base / "base.py" + else: + _extract_core_lib_from_wheel(component_name, temp_dir) + src_path = temp_dir / component_name + dst_path = dest_base / component_name + + if not src_path.exists(): + raise FileNotFoundError(f"{display_name} not found in installed wheel") + + # Ensure destination exists + if not dst_path.exists(): + if is_base_file: + raise FileNotFoundError(f"{display_name} does not exist locally. Run 'xircuits init' first.") + else: + raise FileNotFoundError(f"{display_name} does not exist locally. This is unexpected for a core library.") + + # Sync files + if is_base_file: + report = _sync_single_file(src_path, dst_path, dry_run, timestamp, no_overwrite) + else: + report = _sync_with_backups( + source_root=src_path, + destination_root=dst_path, + dry_run=dry_run, + prune=prune, + timestamp=timestamp, + no_overwrite=no_overwrite, + ) + + # Generate diff for dry-run + if dry_run: + if is_base_file and report.updated: + diff_text = _unified_diff_for_pair(src_path, dst_path, Path("base.py")) + if diff_text.strip(): + print("\n--- DRY-RUN DIFF ---") + print(diff_text.rstrip()) + elif not is_base_file: + diff_text = _build_combined_diff( + src_path, dst_path, + added=report.added, + updated=report.updated, + deleted=report.deleted + ) + if diff_text.strip(): + diff_path = dst_path / f"{component_name}.update.{timestamp}.dry-run.diff.txt" + diff_path.write_text(diff_text, encoding="utf-8") + print("\n--- DRY-RUN DIFF ---") + print(diff_text.rstrip()) + print(f"\n(Wrote diff file to: {diff_path})") + + summary = ( + f"{display_name} update " + f"(added: {len(report.added)}, updated: {len(report.updated)}, " + f"deleted: {len(report.deleted)}, unchanged: {len(report.unchanged)})" + ) + return summary + + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +def _update_from_git( + lib_name: str, + repo: Optional[str], + ref: Optional[str], + dry_run: bool, + prune: bool, + install_deps: bool, + use_latest: bool = False, + no_overwrite: bool = False, +) -> str: + """ + Update a regular component library from its git repository. + (Original update_library logic) + """ + working_dir = resolve_working_dir() + if working_dir is None: + raise RuntimeError("Xircuits working directory not found. Run 'xircuits init' first.") + + dest_dir = resolve_library_dir(lib_name) if not dest_dir.exists() or not dest_dir.is_dir(): raise FileNotFoundError( - f"Library '{lib_name}' not found at {dest_dir}. Try 'xircuits install {library_name}'." + f"Library '{lib_name}' not found at {dest_dir}. Try 'xircuits install {lib_name}'." ) timestamp = time.strftime("%Y%m%d-%H%M%S") - source_spec = _resolve_source_spec(lib_name, repo, ref) + source_spec = _resolve_source_spec(lib_name, repo, ref, use_latest) if not source_spec or not source_spec.repo_url: raise RuntimeError( - f"Could not resolve a repository URL for '{library_name}'. " + f"Could not resolve a repository URL for '{lib_name}'. " "Ensure it was installed (so metadata exists) or present in your index.json." ) @@ -113,11 +255,10 @@ def update_library( dry_run=dry_run, prune=prune, timestamp=timestamp, + no_overwrite=no_overwrite, ) # On DRY-RUN: show and save a unified diff of planned changes. - # This is an *in-memory* comparison; no filesystem writes occur, and we don't - # ever touch '/dev/null'. For adds/deletes we diff against an empty side. if dry_run: diff_text = _build_combined_diff( src_dir, dest_dir, @@ -136,7 +277,7 @@ def update_library( if not dry_run: try: record_component_metadata( - library_name=lib_name, # normalizes to xai-* + library_name=lib_name, member_path=str(dest_dir), repo_url=repo_url_final or source_spec.repo_url, ref=resolved_ref or source_spec.desired_ref or "latest", @@ -152,7 +293,7 @@ def update_library( reqs = [] print(f"Warning: could not read requirements for {lib_name}: {e}") - # Always refresh the per-library extra + meta extra on update (safe even if no install) + # Always refresh the per-library extra + meta extra on update try: set_library_extra(lib_name, reqs) rebuild_meta_extra("xai-components") @@ -184,17 +325,96 @@ def update_library( finally: shutil.rmtree(temp_repo_dir, ignore_errors=True) +# ========== Wheel Extraction Helpers ========== + +def _extract_core_lib_from_wheel(lib_name: str, dest_dir: Path) -> None: + """ + Extract a core component library directory from the installed wheel. + """ + try: + lib_ref = files('xai_components') / lib_name + with as_file(lib_ref) as source_path: + if not source_path.exists(): + raise FileNotFoundError(f"{lib_name} not found in wheel") + shutil.copytree(source_path, dest_dir / lib_name, dirs_exist_ok=True) + except Exception as e: + raise RuntimeError(f"Failed to extract {lib_name} from wheel: {e}") + + +def _extract_base_py_from_wheel(dest_dir: Path) -> None: + """ + Extract base.py from the installed wheel. + """ + try: + base_ref = files('xai_components') / 'base.py' + with as_file(base_ref) as source_path: + if not source_path.exists(): + raise FileNotFoundError("base.py not found in wheel") + shutil.copy2(source_path, dest_dir / 'base.py') + except Exception as e: + raise RuntimeError(f"Failed to extract base.py from wheel: {e}") + + +def _sync_single_file( + src_file: Path, + dst_file: Path, + dry_run: bool, + timestamp: str, + no_overwrite: bool = False +) -> SyncReport: + """ + Sync a single file with backup support. + """ + added: List[str] = [] + updated: List[str] = [] + deleted: List[str] = [] + unchanged: List[str] = [] + + rel_path = dst_file.name + + if not dst_file.exists(): + print(f"+++ {rel_path}") + if not dry_run: + dst_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src_file, dst_file) + added.append(rel_path) + elif _files_equal(src_file, dst_file): + unchanged.append(rel_path) + else: + # File has local modifications + if no_overwrite: + print(f"⊙ {rel_path} (local changes preserved)") + unchanged.append(rel_path) + elif dry_run: + backup_name = _backup_in_place(dst_file, timestamp, dry_run) + print(f"--- {rel_path} (would backup as: {backup_name})") + print(f"+++ {rel_path}") + updated.append(rel_path) + else: + backup_name = _backup_in_place(dst_file, timestamp, dry_run) + print(f"--- {rel_path} (backup: {backup_name})") + print(f"+++ {rel_path}") + shutil.copy2(src_file, dst_file) + updated.append(rel_path) + + return SyncReport(added=added, updated=updated, deleted=deleted, unchanged=unchanged) + + +# ========== Git Update Helpers ========== def _resolve_source_spec( lib_name: str, repo_override: Optional[str], user_ref: Optional[str], + use_latest: bool = False, ) -> Optional[SourceSpec]: """ Priority: 0) explicit repo override (CLI/API 'repo=') 1) pyproject.toml [tool.xircuits.components] entry (source + tag/rev) 2) manifest index via get_remote_config() + + If use_latest=True, ignore metadata ref and use user_ref (or None for default branch). """ # use repo url if specified if repo_override: @@ -202,7 +422,13 @@ def _resolve_source_spec( # pyproject metadata source_url, meta_ref = read_component_metadata_entry(lib_name) - desired_ref = user_ref or meta_ref + + # Determine ref: use_latest bypasses metadata ref + if use_latest: + desired_ref = user_ref # None means default branch + else: + desired_ref = user_ref or meta_ref + if source_url: return SourceSpec(repo_url=source_url, desired_ref=desired_ref) @@ -298,6 +524,7 @@ def _sync_with_backups( dry_run: bool, prune: bool, timestamp: str, + no_overwrite: bool = False, ) -> SyncReport: """ Perform the filesystem sync (or simulate it on dry_run). @@ -306,6 +533,7 @@ def _sync_with_backups( +++ path --- path (backup: ) for updated/deleted --- path (would backup) for updated/deleted (dry-run mode) + ⊙ path (local changes preserved) when no_overwrite=True """ added: List[str] = [] updated: List[str] = [] @@ -331,6 +559,12 @@ def _sync_with_backups( unchanged.append(path_str) continue + # File differs - check no_overwrite flag + if no_overwrite: + print(f"⊙ {path_str} (local changes preserved)") + unchanged.append(path_str) + continue + if dry_run: backup_name = _backup_in_place(dst, timestamp, dry_run) print(f"--- {path_str} (would backup as: {backup_name})") @@ -400,6 +634,7 @@ def _read_text(path: Optional[Path]) -> List[str]: def _unified_diff_for_pair(src: Optional[Path], dst: Optional[Path], rel: Path) -> str: """ Build a unified diff between dst (current destination, old) and src (incoming source, new). + For .xircuits files, only show a summary line instead of full diff. """ def _normalize_for_diff(lines: List[str], keep_max_blank_run: int = 1) -> List[str]: # Collapse runs of blank lines to at most `keep_max_blank_run`. @@ -418,6 +653,9 @@ def _normalize_for_diff(lines: List[str], keep_max_blank_run: int = 1) -> List[s label_old = f"a/{rel.as_posix()}" label_new = f"b/{rel.as_posix()}" + # Check if this is a .xircuits file + is_xircuits_file = rel.suffix.lower() == '.xircuits' + # Binary file summary if _is_binary_file(dst) or _is_binary_file(src): # Added @@ -433,6 +671,21 @@ def _normalize_for_diff(lines: List[str], keep_max_blank_run: int = 1) -> List[s old_lines = _normalize_for_diff(_read_text(dst)) new_lines = _normalize_for_diff(_read_text(src)) + # For .xircuits files, just show summary + if is_xircuits_file: + # Added + if dst is None or (dst and not dst.exists()): + return f".xircuits file added: {rel.as_posix()} ({len(new_lines)} lines)\n" + # Deleted + if src is None or (src and not src.exists()): + return f".xircuits file deleted: {rel.as_posix()} ({len(old_lines)} lines)\n" + # Updated - show line count change + if old_lines == new_lines: + return "" # No actual changes + line_diff = len(new_lines) - len(old_lines) + sign = "+" if line_diff > 0 else "" + return f".xircuits file updated: {rel.as_posix()} ({len(old_lines)} -> {len(new_lines)} lines, {sign}{line_diff})\n" + # Use lineterm="\n" so each diff line includes its newline -> headers won't glue diff_lines = list(difflib.unified_diff( old_lines, new_lines, @@ -481,3 +734,193 @@ def _build_combined_diff( out.append(diff) return "\n".join(out) + +# ---------- Update All functionality ---------- + +def update_all_libraries( + dry_run: bool = False, + prune: bool = False, + install_deps: bool = True, + core_only: bool = False, + remote_only: bool = False, + exclude: List[str] = None, + respect_refs: bool = False, + no_overwrite: bool = False, +) -> dict: + """ + Update all installed component libraries found in xai_components/. + + Args: + dry_run: Preview changes without modifying files + prune: Remove local-only files during update + install_deps: Install/update Python dependencies + core_only: Only update core libraries + remote_only: Only update non-core libraries + exclude: List of library names to skip + respect_refs: Honor pinned refs in metadata (default: pull latest) + no_overwrite: Skip updating files with local modifications + + Returns: + Dict with 'success', 'failed', 'skipped' lists and summary stats + """ + + # Validate conflicting flags + if core_only and remote_only: + raise ValueError("Cannot specify both --core-only and --remote-only") + + working_dir = resolve_working_dir() + if working_dir is None: + raise RuntimeError("Xircuits working directory not found. Run 'xircuits init' first.") + + exclude_set = set((exclude or [])) + exclude_set = {normalize_library_slug(x) for x in exclude_set} + + results = { + "success": [], + "failed": [], + "skipped": [] + } + + # Discover libraries to update + libraries_to_update = _discover_updateable_libraries( + working_dir=working_dir, + core_only=core_only, + remote_only=remote_only, + exclude=exclude_set + ) + + if not libraries_to_update: + print("No libraries found to update.") + return results + + print(f"Found {len(libraries_to_update)} {'library' if len(libraries_to_update) == 1 else 'libraries'} to update") + if dry_run: + print("DRY-RUN MODE: No files will be modified\n") + print() + + # Update each library + for lib_name in sorted(libraries_to_update): + try: + print(f"{'='*60}") + print(f"Updating: {lib_name}") + print(f"{'='*60}") + + # For --all without --respect-refs, pull latest by setting use_latest=True + message = update_library( + library_name=lib_name, + repo=None, + ref=None, + dry_run=dry_run, + prune=prune, + install_deps=install_deps, + use_latest=not respect_refs, + no_overwrite=no_overwrite, + ) + + results["success"].append((lib_name, message)) + print(f"✓ {lib_name}: {message}\n") + + except Exception as e: + error_msg = str(e) + results["failed"].append((lib_name, error_msg)) + print(f"✗ {lib_name}: Failed - {error_msg}\n") + # Continue to next library + + # Print summary + _print_update_all_summary(results, dry_run) + + return results + +def _discover_updateable_libraries( + working_dir: Path, + core_only: bool, + remote_only: bool, + exclude: set +) -> List[str]: + """ + Scan xai_components directory and return list of updateable library names. + """ + + base_dir = components_base_dir(working_dir) + if not base_dir.exists(): + return [] + + libraries = [] + + # Check base.py + base_py = base_dir / "base.py" + if base_py.exists() and base_py.is_file(): + if not remote_only and "base.py" not in exclude: + if core_only or not remote_only: + libraries.append("base.py") + + # Scan xai_* directories + for item in base_dir.glob("xai_*"): + if not item.is_dir(): + continue + + # Must have __init__.py to be valid + if not (item / "__init__.py").exists(): + continue + + lib_name = item.name + + # Check exclusions + if lib_name in exclude: + continue + + # Check core/remote filters + is_core = is_core_library(lib_name) + if core_only and not is_core: + continue + if remote_only and is_core: + continue + + libraries.append(lib_name) + + return libraries + + +def _print_update_all_summary(results: dict, dry_run: bool): + """ + Print a formatted summary of update results. + """ + print() + print("="*60) + print("Update All Summary") + print("="*60) + print() + + if results["success"]: + print("✓ SUCCEEDED:") + for lib_name, message in results["success"]: + print(f" {lib_name:20} {message}") + print() + + if results["failed"]: + print("✗ FAILED:") + for lib_name, error in results["failed"]: + # Truncate long errors + error_display = error if len(error) <= 60 else error[:57] + "..." + print(f" {lib_name:20} {error_display}") + print() + + if results["skipped"]: + print("⊘ SKIPPED:") + for lib_name, reason in results["skipped"]: + print(f" {lib_name:20} {reason}") + print() + + # Summary counts + total = len(results["success"]) + len(results["failed"]) + len(results["skipped"]) + summary_parts = [] + if results["success"]: + summary_parts.append(f"{len(results['success'])} succeeded") + if results["failed"]: + summary_parts.append(f"{len(results['failed'])} failed") + if results["skipped"]: + summary_parts.append(f"{len(results['skipped'])} skipped") + + mode_suffix = " (dry-run)" if dry_run else "" + print(f"{', '.join(summary_parts)}{mode_suffix}") + print("="*60) diff --git a/xircuits/start_xircuits.py b/xircuits/start_xircuits.py index 79f3c7d6..974087da 100644 --- a/xircuits/start_xircuits.py +++ b/xircuits/start_xircuits.py @@ -9,7 +9,7 @@ from .library import list_component_library, install_library, fetch_library, uninstall_library from .library.index_config import refresh_index -from .library.update_library import update_library +from .library.update_library import update_library, update_all_libraries from .compiler import compile, recursive_compile from xircuits.handlers.config import get_config @@ -146,16 +146,49 @@ def cmd_sync(args, extra_args=[]): sync_xai_components() def cmd_update_library(args, extra_args=[]): - - message = update_library( - library_name=args.library_name, - repo=args.repo, - ref=args.ref, - dry_run=args.dry_run, - prune=args.prune, - install_deps=args.install_deps, - ) - print(message) + if args.all: + + # Parse exclude list + exclude_list = [] + if args.exclude: + exclude_list = [x.strip() for x in args.exclude.split(',') if x.strip()] + + # Validate conflicting flags + if args.core_only and args.remote_only: + print("Error: Cannot specify both --core-only and --remote-only") + return + + try: + results = update_all_libraries( + dry_run=args.dry_run, + prune=args.prune, + install_deps=args.install_deps, + core_only=args.core_only, + remote_only=args.remote_only, + exclude=exclude_list, + respect_refs=args.respect_refs, + no_overwrite=args.no_overwrite, + ) + + except Exception as e: + print(f"Error: {e}") + return + else: + # single-library update + if not args.library_name: + print("Error: library_name is required when not using --all") + return + + message = update_library( + library_name=args.library_name, + repo=args.repo, + ref=args.ref, + dry_run=args.dry_run, + prune=args.prune, + install_deps=args.install_deps, + no_overwrite=args.no_overwrite, + ) + print(message) def cmd_run(args, extra_args=[]): original_cwd = args.original_cwd @@ -276,15 +309,30 @@ def main(): update_parser = subparsers.add_parser( 'update', help='Update a component library with in-place .bak backups.' ) - update_parser.add_argument('library_name', type=str, help='Library to update (e.g., flask)') + update_parser.add_argument('library_name', nargs='?', type=str, + help='Library to update (e.g., flask). Omit with --all.') update_parser.add_argument('--repo', type=str, default=None, help='Override source repository URL') update_parser.add_argument('--ref', type=str, default=None, help='Tag/branch/commit to update to') update_parser.add_argument('--dry-run', action='store_true', help='Preview only; no changes') update_parser.add_argument('--prune', action='store_true', help='Prune local-only files/dirs (rename to .bak)') update_parser.add_argument('--install-deps', nargs='?', const=True, default=True, - type=lambda s: str(s).lower() not in ('0','false','no','off'), - help='Install/update Python deps (default true). Pass false to disable.') + type=lambda s: str(s).lower() not in ('0','false','no','off'), + help='Install/update Python deps (default true). Pass false to disable.') + + update_parser.add_argument('--all', action='store_true', + help='Update all installed component libraries') + update_parser.add_argument('--core-only', action='store_true', + help='Update only core libraries (xai_events, xai_template, etc.)') + update_parser.add_argument('--remote-only', action='store_true', + help='Update only remote (non-core) libraries') + update_parser.add_argument('--exclude', type=str, default='', + help='Comma-separated list of libraries to exclude (e.g., gradio,opencv)') + update_parser.add_argument('--respect-refs', action='store_true', + help='Honor pinned refs in metadata (default: pull latest for --all)') + update_parser.add_argument('--no-overwrite', action='store_true', + help='Skip updating files with local modifications (preserve local changes)') + update_parser.set_defaults(func=cmd_update_library) # 'run' command.