Skip to content

Commit 3ea8b6c

Browse files
Kasper Jungeclaude
authored andcommitted
feat: add agr upgrade to re-fetch deps past the pinned commit
`agr sync` only installs what's missing; `agr upgrade` re-fetches deps at the latest upstream commit (or re-copies for local paths) and refreshes `agr.lock`. Supports full handles, short names, local paths, multiple targets, and `--global`. Targeted upgrades leave untargeted siblings alone — including same-repo siblings — so partial-install repair stays `agr sync`'s job. Extracts `_run_pre_install_setup` and `_run_install_pipeline` from `run_sync` so both commands share classify/install/lockfile/report. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2c577db commit 3ea8b6c

6 files changed

Lines changed: 828 additions & 34 deletions

File tree

agr/commands/sync.py

Lines changed: 128 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
179204
def _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

Comments
 (0)