11"""agr add command implementation."""
22
3+ from dataclasses import dataclass
34from pathlib import Path
45
56from agr .commands import CommandResult
2122 format_install_error ,
2223)
2324from agr ._install_common import InstallResult
24- from agr .package import expand_packages , has_package_section
25+ from agr .package import detect_conflicts , expand_packages , has_package_section
2526from agr .ralph_installer import fetch_and_install_ralph
2627from agr .skill_installer import fetch_and_install_to_tools , list_remote_repo_skills
2728from agr .handle import ParsedHandle , parse_handle
3839from agr .tool import ToolConfig
3940
4041
42+ @dataclass
43+ class AddInstallResult :
44+ """Result of installing one requested dependency."""
45+
46+ installed_paths : list [str ]
47+ install_result : InstallResult
48+ dep_type : str
49+ lock_entries : list [tuple [str , LockedEntry ]] | None = None
50+
51+
52+ def _parent_fields (parent_ids : set [str ] | None ) -> tuple [str | None , list [str ] | None ]:
53+ if not parent_ids :
54+ return None , None
55+ sorted_ids = sorted (parent_ids )
56+ if len (sorted_ids ) == 1 :
57+ return sorted_ids [0 ], None
58+ return None , sorted_ids
59+
60+
4161def _detect_local_type (source_path : Path ) -> str :
4262 """Detect whether a local path is a skill, ralph, or package.
4363
@@ -100,7 +120,7 @@ def _install_dependency(
100120 default_repo : str | None ,
101121 * ,
102122 config : AgrConfig | None = None ,
103- ) -> tuple [ list [ str ], InstallResult , str ] :
123+ ) -> AddInstallResult :
104124 """Install a dependency and return paths, metadata, and resolved type.
105125
106126 For local deps, installs directly as the detected type. For remote deps,
@@ -137,7 +157,7 @@ def _install_dependency(
137157 installed_paths = [
138158 f"{ name } : { path } " for name , path in installed_paths_dict .items ()
139159 ]
140- return installed_paths , install_result , dep_type
160+ return AddInstallResult ( installed_paths , install_result , dep_type )
141161
142162 if handle .is_local :
143163 installed_path , install_result = fetch_and_install_ralph (
@@ -148,7 +168,7 @@ def _install_dependency(
148168 source = source ,
149169 default_repo = default_repo ,
150170 )
151- return [str (installed_path )], install_result , dep_type
171+ return AddInstallResult ( [str (installed_path )], install_result , dep_type )
152172
153173 # Remote — try as skill first, fall back to ralph.
154174 try :
@@ -165,7 +185,7 @@ def _install_dependency(
165185 installed_paths = [
166186 f"{ name } : { path } " for name , path in installed_paths_dict .items ()
167187 ]
168- return installed_paths , install_result , DEPENDENCY_TYPE_SKILL
188+ return AddInstallResult ( installed_paths , install_result , DEPENDENCY_TYPE_SKILL )
169189 except SkillNotFoundError :
170190 pass
171191
@@ -178,11 +198,23 @@ def _install_dependency(
178198 source = source ,
179199 default_repo = default_repo ,
180200 )
181- return [str (installed_path )], install_result , DEPENDENCY_TYPE_RALPH
201+ return AddInstallResult (
202+ [str (installed_path )], install_result , DEPENDENCY_TYPE_RALPH
203+ )
182204 except RalphNotFoundError :
183- raise SkillNotFoundError (
184- f"'{ handle .name } ' not found as a skill or ralph in any configured source."
185- ) from None
205+ pass
206+
207+ return _install_package (
208+ handle ,
209+ repo_root ,
210+ tools ,
211+ overwrite ,
212+ resolver ,
213+ source ,
214+ skills_dirs ,
215+ default_repo ,
216+ config = config ,
217+ )
186218
187219
188220def _install_package (
@@ -196,7 +228,7 @@ def _install_package(
196228 default_repo : str | None ,
197229 * ,
198230 config : AgrConfig | None = None ,
199- ) -> tuple [ list [ str ], InstallResult , str ] :
231+ ) -> AddInstallResult :
200232 """Expand a package and install its transitive leaf deps."""
201233 if config is None :
202234 config = AgrConfig ()
@@ -214,9 +246,26 @@ def _install_package(
214246 config .default_owner ,
215247 config .default_repo ,
216248 )
249+ direct_ids = {dep .identifier for dep in config .dependencies }
250+ direct_ids .add (pkg_dep .identifier )
251+ direct_leaf_deps = [dep for dep in config .dependencies if not dep .is_package ]
252+ resolved_deps = detect_conflicts (
253+ [* direct_leaf_deps , * expanded .dependencies ], expanded .parents , direct_ids
254+ )
255+ resolved_keys = {(dep .type , dep .identifier ) for dep in resolved_deps }
256+ direct_leaf_keys = {(dep .type , dep .identifier ) for dep in direct_leaf_deps }
257+ expanded .dependencies = [
258+ dep
259+ for dep in expanded .dependencies
260+ if (dep .type , dep .identifier ) in resolved_keys
261+ and (dep .type , dep .identifier ) not in direct_leaf_keys
262+ ]
217263
218264 installed_paths : list [str ] = []
219265 first_result : InstallResult | None = None
266+ lock_entries : list [tuple [str , LockedEntry ]] = [
267+ ("package" , entry ) for entry in expanded .package_entries
268+ ]
220269 for dep in expanded .dependencies :
221270 sub_handle = dep .to_parsed_handle (config .default_owner )
222271 sub_source = dep .resolve_source_name (config .default_source )
@@ -246,23 +295,68 @@ def _install_package(
246295 )
247296 if first_result is None :
248297 first_result = result
298+ parent_id = expanded .parents .get (dep .identifier )
299+ parent , parents = _parent_fields (
300+ expanded .parent_sets .get (dep .identifier )
301+ or ({parent_id } if parent_id else None )
302+ )
303+ if dep .is_local :
304+ lock_entries .append (
305+ (
306+ dep .type ,
307+ LockedEntry (
308+ path = dep .path ,
309+ installed_name = dep .installed_name ,
310+ parent = parent ,
311+ parents = parents ,
312+ ),
313+ )
314+ )
315+ else :
316+ lock_entries .append (
317+ (
318+ dep .type ,
319+ LockedEntry (
320+ handle = dep .handle ,
321+ source = result .source_name ,
322+ commit = result .commit ,
323+ content_hash = result .content_hash ,
324+ installed_name = dep .installed_name ,
325+ parent = parent ,
326+ parents = parents ,
327+ ),
328+ )
329+ )
249330
250331 if first_result is None :
251332 first_result = InstallResult ()
252333
253- return installed_paths , first_result , DEPENDENCY_TYPE_PACKAGE
334+ return AddInstallResult (
335+ installed_paths ,
336+ first_result ,
337+ DEPENDENCY_TYPE_PACKAGE ,
338+ lock_entries = lock_entries ,
339+ )
254340
255341
256342def _update_lockfile_for_adds (
257- lockfile_updates : list [tuple [ParsedHandle , str , InstallResult , str ]],
343+ lockfile_updates : list [
344+ tuple [
345+ ParsedHandle , str , InstallResult , str , list [tuple [str , LockedEntry ]] | None
346+ ]
347+ ],
258348 config_path : Path ,
259349) -> None :
260350 """Write install results to the lockfile."""
261351 if not lockfile_updates :
262352 return
263353 lockfile_path = build_lockfile_path (config_path )
264354 lockfile = load_lockfile (lockfile_path ) or Lockfile ()
265- for handle , ref , install_result , dep_type in lockfile_updates :
355+ for handle , ref , install_result , dep_type , entries in lockfile_updates :
356+ if entries is not None :
357+ for kind , entry in entries :
358+ lockfile .update_entry (entry , kind = kind )
359+ continue
266360 if handle .is_local :
267361 lockfile .update_entry (
268362 LockedEntry (path = ref , installed_name = handle .name ),
@@ -305,7 +399,11 @@ def run_add(
305399 # Track results for summary
306400 results : list [CommandResult ] = []
307401 # Track install results for lockfile: (handle, ref, install_result, dep_type)
308- lockfile_updates : list [tuple [ParsedHandle , str , InstallResult , str ]] = []
402+ lockfile_updates : list [
403+ tuple [
404+ ParsedHandle , str , InstallResult , str , list [tuple [str , LockedEntry ]] | None
405+ ]
406+ ] = []
309407
310408 for ref in refs :
311409 try :
@@ -325,7 +423,7 @@ def run_add(
325423 dep_type = DEPENDENCY_TYPE_SKILL # default, may change below
326424
327425 # Install
328- installed_paths , install_result , dep_type = _install_dependency (
426+ install = _install_dependency (
329427 handle ,
330428 dep_type ,
331429 repo_root ,
@@ -337,6 +435,9 @@ def run_add(
337435 config .default_repo ,
338436 config = config ,
339437 )
438+ installed_paths = install .installed_paths
439+ install_result = install .install_result
440+ dep_type = install .dep_type
340441
341442 # Add to config
342443 if handle .is_local :
@@ -356,7 +457,9 @@ def run_add(
356457
357458 # Track for lockfile update
358459 lockfile_ref = path_value if handle .is_local else ref
359- lockfile_updates .append ((handle , lockfile_ref , install_result , dep_type ))
460+ lockfile_updates .append (
461+ (handle , lockfile_ref , install_result , dep_type , install .lock_entries )
462+ )
360463 results .append (CommandResult (ref , True , ", " .join (installed_paths )))
361464
362465 except SkillNotFoundError as e :
0 commit comments