@@ -123,6 +123,7 @@ class SyncEntry:
123123 handle : ParsedHandle
124124 source_name : str | None
125125 tools_needing_install : list [ToolConfig ] | None = None
126+ overwrite : bool = False
126127
127128
128129@dataclass
@@ -176,6 +177,30 @@ def _print_results_and_summary(
176177 raise SystemExit (1 )
177178
178179
180+ def _run_pre_install_setup (
181+ repo_root : Path ,
182+ config : AgrConfig ,
183+ tools : list [ToolConfig ],
184+ ) -> None :
185+ """Instruction sync + directory migrations. Shared by `run_sync` and `run_upgrade`."""
186+ _sync_instructions_if_configured (repo_root , config , tools )
187+ run_tool_migrations (tools , repo_root )
188+ for tool in tools :
189+ skills_dir = tool .get_skills_dir (repo_root )
190+ migrate_legacy_directories (skills_dir , tool )
191+ migrate_flat_installed_names (skills_dir , tool , config , repo_root )
192+
193+
194+ def _is_forced (
195+ dep : Dependency ,
196+ force_all : bool ,
197+ force_identifiers : set [str ] | None ,
198+ ) -> bool :
199+ return force_all or (
200+ force_identifiers is not None and dep .identifier in force_identifiers
201+ )
202+
203+
179204def _sync_instructions_if_configured (
180205 repo_root : Path , config : AgrConfig , tools : list [ToolConfig ]
181206) -> None :
@@ -248,6 +273,7 @@ def _sync_individual_entries(
248273 resolver ,
249274 tools_needing_install = entry .tools_needing_install ,
250275 default_repo = default_repo ,
276+ overwrite = entry .overwrite ,
251277 )
252278 except INSTALL_ERROR_TYPES as e :
253279 results [entry .index ] = SyncResult .from_error (e )
@@ -300,6 +326,7 @@ def _sync_batched_repo_entries(
300326 tools ,
301327 source_name ,
302328 commit = commit ,
329+ overwrite = entry .overwrite ,
303330 )
304331 except INSTALL_ERROR_TYPES as e :
305332 # If the repo-level operation fails (clone, checkout), mark
@@ -317,15 +344,18 @@ def _install_one_from_repo(
317344 tools : list [ToolConfig ],
318345 source_name : str ,
319346 commit : str | None = None ,
347+ overwrite : bool = False ,
320348) -> None :
321349 """Install a single skill from an already-downloaded repo."""
322350 handle = entry .handle
323- tools_needing_install = entry .tools_needing_install or filter_tools_needing_install (
324- handle , repo_root , tools , entry .source_name
351+ # _classify_dependencies only queues entries with a non-empty
352+ # tools_needing_install list, so the invariant is asserted rather than
353+ # re-checked here.
354+ assert entry .tools_needing_install , (
355+ "_classify_dependencies must populate tools_needing_install before "
356+ "queuing to _install_one_from_repo"
325357 )
326- if not tools_needing_install :
327- results [entry .index ] = SyncResult .up_to_date ()
328- return
358+ tools_needing_install = entry .tools_needing_install
329359 skill_source = skill_sources .get (handle .name )
330360 if skill_source is None :
331361 results [entry .index ] = SyncResult (
@@ -339,7 +369,7 @@ def _install_one_from_repo(
339369 handle ,
340370 tools_needing_install ,
341371 repo_root ,
342- overwrite = False ,
372+ overwrite = overwrite ,
343373 install_source = source_name ,
344374 skill_source = skill_source ,
345375 )
@@ -363,6 +393,7 @@ def _sync_one_dependency(
363393 skills_dirs : dict [str , Path ] | None = None ,
364394 tools_needing_install : list [ToolConfig ] | None = None ,
365395 default_repo : str | None = None ,
396+ overwrite : bool = False ,
366397) -> SyncResult :
367398 """Sync a single dependency: check install status and install if needed.
368399
@@ -371,17 +402,20 @@ def _sync_one_dependency(
371402 caller can handle errors per-entry.
372403 """
373404 if tools_needing_install is None :
374- tools_needing_install = filter_tools_needing_install (
375- handle , repo_root , tools , source_name , skills_dirs
376- )
405+ if overwrite :
406+ tools_needing_install = list (tools )
407+ else :
408+ tools_needing_install = filter_tools_needing_install (
409+ handle , repo_root , tools , source_name , skills_dirs
410+ )
377411 if not tools_needing_install :
378412 return SyncResult .up_to_date ()
379413
380414 _paths , install_result = fetch_and_install_to_tools (
381415 handle ,
382416 repo_root ,
383417 tools_needing_install ,
384- overwrite = False ,
418+ overwrite = overwrite ,
385419 resolver = resolver ,
386420 source = source_name ,
387421 skills_dirs = skills_dirs ,
@@ -403,7 +437,7 @@ def _sync_ralph_entries(
403437 path , install_result = fetch_and_install_ralph (
404438 entry .handle ,
405439 repo_root ,
406- overwrite = False ,
440+ overwrite = entry . overwrite ,
407441 resolver = resolver ,
408442 source = entry .source_name ,
409443 default_repo = default_repo ,
@@ -413,8 +447,16 @@ def _sync_ralph_entries(
413447 results [entry .index ] = SyncResult .from_error (e )
414448
415449
416- def _run_global_sync () -> None :
417- """Sync global dependencies from ~/.agr/agr.toml."""
450+ def _run_global_sync (
451+ * ,
452+ force_all : bool = False ,
453+ force_identifiers : set [str ] | None = None ,
454+ ) -> None :
455+ """Sync global dependencies from ~/.agr/agr.toml.
456+
457+ When ``force_all`` or ``force_identifiers`` is supplied, matching deps
458+ are re-fetched with ``overwrite=True`` (used by ``run_upgrade``).
459+ """
418460 console = get_console ()
419461 loaded = load_existing_config (global_install = True , missing_ok = True )
420462 if loaded is None :
@@ -436,6 +478,12 @@ def _run_global_sync() -> None:
436478 results : list [tuple [str , SyncResult ]] = []
437479
438480 for dep in config .dependencies :
481+ forced = _is_forced (dep , force_all , force_identifiers )
482+ # Targeted upgrade: skip untargeted deps before resolving or printing
483+ # anything, so `agr upgrade -g alpha` doesn't leak ralph-skip noise or
484+ # sibling resolve errors for deps the user didn't ask about.
485+ if force_identifiers is not None and not forced :
486+ continue
439487 try :
440488 handle , source_name = dep .resolve (
441489 config .default_source , config .default_owner
@@ -455,6 +503,7 @@ def _run_global_sync() -> None:
455503 resolver ,
456504 skills_dirs ,
457505 default_repo = config .default_repo ,
506+ overwrite = forced ,
458507 )
459508 except INSTALL_ERROR_TYPES as e :
460509 result = SyncResult .from_error (e )
@@ -467,12 +516,19 @@ def _classify_dependencies(
467516 config : AgrConfig ,
468517 repo_root : Path ,
469518 tools : list [ToolConfig ],
519+ * ,
520+ force_all : bool = False ,
521+ force_identifiers : set [str ] | None = None ,
470522) -> _ClassifiedDeps :
471523 """Classify dependencies into local, remote, and ralph install queues.
472524
473525 Resolves each dependency and checks whether it is already installed.
474526 Up-to-date or errored entries are recorded directly in the results list;
475527 entries that need installation are placed into the appropriate pending queue.
528+
529+ When ``force_all`` is True, every dep is pushed to the pending queue with
530+ ``overwrite=True`` regardless of its current install state. When
531+ ``force_identifiers`` is provided, only matching deps are forced.
476532 """
477533 results : list [SyncResult ] = [SyncResult .pending () for _ in config .dependencies ]
478534 pending_local : list [SyncEntry ] = []
@@ -485,19 +541,36 @@ def _classify_dependencies(
485541 config .default_source , config .default_owner
486542 )
487543
544+ forced = _is_forced (dep , force_all , force_identifiers )
545+
546+ # Targeted upgrade: skip non-forced deps entirely so `agr upgrade
547+ # alpha` never touches siblings (same repo or otherwise), even if
548+ # they're partially installed. Partial repair is `agr sync`'s job.
549+ if force_identifiers is not None and not forced :
550+ results [index ] = SyncResult .up_to_date ()
551+ continue
552+
488553 if dep .is_ralph :
489554 # Ralphs are tool-agnostic: check project-level ralphs dir.
490- if is_ralph_installed (handle , repo_root , source_name ):
555+ if not forced and is_ralph_installed (handle , repo_root , source_name ):
491556 results [index ] = SyncResult .up_to_date ()
492557 continue
493558 pending_ralph .append (
494- SyncEntry (index = index , handle = handle , source_name = source_name )
559+ SyncEntry (
560+ index = index ,
561+ handle = handle ,
562+ source_name = source_name ,
563+ overwrite = forced ,
564+ )
495565 )
496566 else :
497567 # Skills: check per-tool install status.
498- tools_needing_install = filter_tools_needing_install (
499- handle , repo_root , tools , source_name
500- )
568+ if forced :
569+ tools_needing_install = list (tools )
570+ else :
571+ tools_needing_install = filter_tools_needing_install (
572+ handle , repo_root , tools , source_name
573+ )
501574
502575 if not tools_needing_install :
503576 results [index ] = SyncResult .up_to_date ()
@@ -508,6 +581,7 @@ def _classify_dependencies(
508581 handle = handle ,
509582 source_name = source_name ,
510583 tools_needing_install = tools_needing_install ,
584+ overwrite = forced ,
511585 )
512586 if dep .is_local :
513587 pending_local .append (entry )
@@ -567,16 +641,9 @@ def run_sync(
567641 config = AgrConfig .load (config_path )
568642 tools = config .get_tools ()
569643
570- # Stage 1: Sync instruction files across tools (e.g. CLAUDE.md → AGENTS.md).
571- _sync_instructions_if_configured (repo_root , config , tools )
572-
573- # Stage 2: Run directory migrations before installing new skills so that
574- # existing installs are in the expected layout for duplicate detection.
575- run_tool_migrations (tools , repo_root )
576- for tool in tools :
577- skills_dir = tool .get_skills_dir (repo_root )
578- migrate_legacy_directories (skills_dir , tool )
579- migrate_flat_installed_names (skills_dir , tool , config , repo_root )
644+ # Sync instruction files and run directory migrations before installing,
645+ # so existing installs are in the expected layout for duplicate detection.
646+ _run_pre_install_setup (repo_root , config , tools )
580647
581648 if not config .dependencies :
582649 console .print ("[yellow]No dependencies in agr.toml.[/yellow] Nothing to sync." )
@@ -603,8 +670,36 @@ def run_sync(
603670 _sync_from_lockfile (existing_lockfile , config , repo_root , tools , resolver )
604671 return
605672
673+ _run_install_pipeline (
674+ config ,
675+ lockfile_path ,
676+ repo_root ,
677+ tools ,
678+ resolver ,
679+ existing_lockfile ,
680+ )
681+
682+
683+ def _run_install_pipeline (
684+ config : AgrConfig ,
685+ lockfile_path : Path ,
686+ repo_root : Path ,
687+ tools : list [ToolConfig ],
688+ resolver : SourceResolver ,
689+ existing_lockfile : Lockfile | None ,
690+ * ,
691+ force_all : bool = False ,
692+ force_identifiers : set [str ] | None = None ,
693+ ) -> None :
694+ """Shared by `run_sync` and `run_upgrade`: classify → install → lockfile → report."""
606695 # --- Phase 1: Classify dependencies ---
607- classified = _classify_dependencies (config , repo_root , tools )
696+ classified = _classify_dependencies (
697+ config ,
698+ repo_root ,
699+ tools ,
700+ force_all = force_all ,
701+ force_identifiers = force_identifiers ,
702+ )
608703 results = classified .results
609704
610705 # --- Phase 2: Install pending dependencies ---
@@ -817,8 +912,10 @@ def _build_lockfile_from_results(
817912 )
818913 continue
819914
820- # Remote: freshly installed with commit — record new metadata.
821- if result .status == SyncStatus .INSTALLED and result .commit :
915+ # Remote: freshly installed — record new metadata. When commit
916+ # capture failed (result.commit is None), we still drop the old
917+ # lockfile entry rather than carrying a stale commit forward.
918+ if result .status == SyncStatus .INSTALLED :
822919 lockfile .update_entry (
823920 LockedEntry (
824921 handle = dep .handle ,
0 commit comments