Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/src/content/docs/guides/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ dependencies:
- gitlab.com/acme/repo/prompts/code-review.prompt.md

# Local path (for development / monorepo workflows)
- ./packages/my-shared-skills # relative to project root
- ./packages/my-shared-skills # relative to this apm.yml's directory
- /home/user/repos/my-ai-package # absolute path

# Object format: git URL + sub-path / ref / alias
Expand Down Expand Up @@ -286,7 +286,7 @@ Or declare them in `apm.yml`:
```yaml
dependencies:
apm:
- ./packages/my-shared-skills # relative to project root
- ./packages/my-shared-skills # relative to this apm.yml's directory
- /home/user/repos/my-ai-package # absolute path
- microsoft/apm-sample-package # remote (can be mixed)
```
Expand All @@ -296,6 +296,7 @@ dependencies:
- Local packages are validated the same as remote packages (must have `apm.yml` or `SKILL.md`)
- `apm compile` works identically regardless of dependency source
- Transitive dependencies are resolved recursively (local packages can depend on remote packages)
- **Relative paths anchor on the declaring `apm.yml`** -- a local dep listed inside a nested package (e.g. `../sibling-package` declared in `packages/foo/apm.yml`) resolves against that package's directory, not the root consumer. This matches every other package manager (npm, pip, cargo) and lets sibling packages compose. Use absolute paths if you want anchor-independent behavior.

**Re-install behavior:** Local deps are always re-copied on `apm install` since there is no commit SHA to cache against. This ensures you always get the latest local changes.

Expand Down
172 changes: 152 additions & 20 deletions src/apm_cli/deps/apm_resolver.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""APM dependency resolution engine with recursive resolution and conflict detection."""

import inspect
from pathlib import Path
from typing import List, Set, Optional, Protocol, Tuple, runtime_checkable
from collections import deque
Expand All @@ -11,17 +12,21 @@
)

# Type alias for the download callback.
# Takes (dep_ref, apm_modules_dir, parent_chain) and returns the install path
# if successful. ``parent_chain`` is a human-readable breadcrumb string like
# "root-pkg > mid-pkg > this-pkg" showing the full dependency path including
# the current node, or just the node's display name for direct (depth-1) deps.
# Takes (dep_ref, apm_modules_dir, parent_chain, parent_pkg) and returns the
# install path if successful. ``parent_chain`` is a human-readable breadcrumb
# string like "root-pkg > mid-pkg > this-pkg" showing the full dependency path
# including the current node, or just the node's display name for direct
# (depth-1) deps. ``parent_pkg`` is the APMPackage that declared this
# dependency (None for direct deps from the root); callers use its
# ``source_path`` to anchor relative ``local_path`` resolution (#857).
@runtime_checkable
class DownloadCallback(Protocol):
def __call__(
self,
dep_ref: 'DependencyReference',
apm_modules_dir: Path,
parent_chain: str = "",
parent_pkg: Optional['APMPackage'] = None,
) -> Optional[Path]: ...


Expand All @@ -47,7 +52,33 @@ def __init__(
self._apm_modules_dir: Optional[Path] = apm_modules_dir
self._project_root: Optional[Path] = None
self._download_callback = download_callback
# Whether ``download_callback`` accepts ``parent_pkg`` (added in #857).
# Detected once via signature inspection so legacy callbacks that
# predate the field still work without raising a silent TypeError
# that would mask the dependency.
self._callback_accepts_parent_pkg: bool = (
self._signature_accepts_parent_pkg(download_callback)
if download_callback is not None
else False
)
self._downloaded_packages: Set[str] = set() # Track what we downloaded during this resolution

@staticmethod
def _signature_accepts_parent_pkg(callback) -> bool:
"""Return True if ``callback`` declares a ``parent_pkg`` parameter
(or accepts ``**kwargs``). Falls back to True if the signature can't
be introspected (e.g. C extensions) so the modern path is preferred.
"""
try:
sig = inspect.signature(callback)
except (TypeError, ValueError):
return True
for param in sig.parameters.values():
if param.kind is inspect.Parameter.VAR_KEYWORD:
return True
if param.name == "parent_pkg":
return True
return False

def resolve_dependencies(self, project_root: Path) -> DependencyGraph:
"""
Expand Down Expand Up @@ -79,6 +110,8 @@ def resolve_dependencies(self, project_root: Path) -> DependencyGraph:

try:
root_package = APMPackage.from_apm_yml(apm_yml_path)
# Anchor for relative local_path dependencies declared at the root.
root_package.source_path = project_root.resolve()
except (ValueError, FileNotFoundError) as e:
# Create error graph
empty_package = APMPackage(name="error", version="0.0.0", package_path=project_root)
Expand Down Expand Up @@ -213,7 +246,9 @@ def build_dependency_tree(self, root_apm_yml: Path) -> DependencyTree:
parent_chain = node.get_ancestor_chain()

loaded_package = self._try_load_dependency_package(
dep_ref, parent_chain=parent_chain
dep_ref,
parent_chain=parent_chain,
parent_pkg=parent_node.package if parent_node else None,
)
if loaded_package:
# Update the node with the actual loaded package
Expand Down Expand Up @@ -358,56 +393,90 @@ def _validate_dependency_reference(self, dep_ref: DependencyReference) -> bool:
return True

def _try_load_dependency_package(
self, dep_ref: DependencyReference, parent_chain: str = ""
self,
dep_ref: DependencyReference,
parent_chain: str = "",
parent_pkg: Optional[APMPackage] = None,
) -> Optional[APMPackage]:
"""
Try to load a dependency package from apm_modules/.

This method scans apm_modules/ to find installed packages and loads their
apm.yml to enable transitive dependency resolution. If a package is not
installed and a download_callback is available, it will attempt to fetch
the package first.

Args:
dep_ref: Reference to the dependency to load
parent_chain: Human-readable breadcrumb of the dependency path
that led here (e.g. "root-pkg > mid-pkg"). Forwarded to the
download callback for contextual error messages.

parent_pkg: APMPackage that declared *dep_ref* (None for direct
deps from the root project). Its ``source_path`` is forwarded
to the download callback so relative ``local_path`` deps
resolve against the directory containing the declaring
apm.yml rather than the root consumer (#857).

Returns:
APMPackage: Loaded package if found, None otherwise

Raises:
ValueError: If package exists but has invalid format
FileNotFoundError: If package cannot be found
"""
if self._apm_modules_dir is None:
return None

# Get the canonical install path for this dependency
install_path = dep_ref.get_install_path(self._apm_modules_dir)

# If package doesn't exist locally, try to download it
if not install_path.exists():
if self._download_callback is not None:
unique_key = dep_ref.get_unique_key()
# For local deps, dedupe by the *resolved* on-disk path so that
# the same literal (e.g. ``../common``) declared by two
# different parents does not collapse onto a single graph
# node when each parent's source dir actually points to a
# different sibling (#857).
unique_key = self._download_dedup_key(dep_ref, parent_pkg)
# Avoid re-downloading the same package in a single resolution
if unique_key not in self._downloaded_packages:
try:
downloaded_path = self._download_callback(
dep_ref, self._apm_modules_dir, parent_chain
)
if self._callback_accepts_parent_pkg:
downloaded_path = self._download_callback(
dep_ref,
self._apm_modules_dir,
parent_chain,
parent_pkg,
)
else:
# Legacy callback predating #857 -- no parent_pkg.
# Local deps from non-root parents will fall back
# to project_root resolution; this matches behavior
# before the fix, so no surprise regression.
downloaded_path = self._download_callback(
dep_ref,
self._apm_modules_dir,
parent_chain,
)
if downloaded_path and downloaded_path.exists():
self._downloaded_packages.add(unique_key)
install_path = downloaded_path
except Exception:
# Download failed - continue without this dependency's sub-deps
pass
Comment thread
JahanzaibTayyab marked this conversation as resolved.

# Still doesn't exist after download attempt
if not install_path.exists():
return None


# Anchor for resolving this package's own relative local_path deps:
# the *original* source dir for local deps (not the apm_modules copy),
# otherwise the install_path itself.
resolved_source_path = self._compute_dep_source_path(
dep_ref, install_path, parent_pkg
)

# Look for apm.yml in the install path
apm_yml_path = install_path / "apm.yml"
if not apm_yml_path.exists():
Expand All @@ -420,22 +489,85 @@ def _try_load_dependency_package(
name=dep_ref.get_display_name(),
version="1.0.0",
source=dep_ref.repo_url,
package_path=install_path
package_path=install_path,
source_path=resolved_source_path,
)
# No manifest found
return None

# Load and return the package
try:
package = APMPackage.from_apm_yml(apm_yml_path)
# Ensure source is set for tracking
if not package.source:
package.source = dep_ref.repo_url
# Set source_path so transitive resolution of THIS package's
# local-path deps anchors on the right directory.
package.source_path = resolved_source_path
return package
except (ValueError, FileNotFoundError) as e:
# Package has invalid apm.yml - log warning but continue
# In production, we might want to surface this to the user
return None

def _download_dedup_key(
self,
dep_ref: DependencyReference,
parent_pkg: Optional[APMPackage],
) -> str:
"""Return a context-aware key for the per-resolution download cache.

For remote deps this is just ``dep_ref.get_unique_key()``. For local
deps the literal ``local_path`` (e.g. ``../common``) is ambiguous
once #857 anchors it on the declaring package's directory: the same
string can refer to different on-disk packages depending on which
parent declared it. We therefore prefix the key with the resolved
absolute source path so the dedup matches the actual filesystem
identity, not the textual identity.
"""
if dep_ref.is_local and dep_ref.local_path:
local = Path(dep_ref.local_path).expanduser()
if local.is_absolute():
resolved = local.resolve()
else:
base_dir = (
parent_pkg.source_path
if parent_pkg is not None and parent_pkg.source_path is not None
else self._project_root
)
resolved = (base_dir / local).resolve() if base_dir else local.resolve()
return f"local::{resolved}"
return dep_ref.get_unique_key()

def _compute_dep_source_path(
self,
dep_ref: DependencyReference,
install_path: Path,
parent_pkg: Optional[APMPackage],
) -> Path:
"""Return the on-disk anchor for resolving *dep_ref*'s own local deps.

For local dependencies this is the *original* directory the user
referenced (so relative paths inside the package's apm.yml mean what
a developer reading the file would expect). For remote deps it is
``install_path`` (the clone location), which is also where the
package's apm.yml lives.
"""
if dep_ref.is_local and dep_ref.local_path:
local = Path(dep_ref.local_path).expanduser()
if local.is_absolute():
return local.resolve()
base_dir = (
parent_pkg.source_path
if parent_pkg is not None and parent_pkg.source_path is not None
else self._project_root
)
if base_dir is None:
# Fallback: best-effort relative-to-cwd. Rare; only hit if
# the resolver was driven without a project root.
return local.resolve()
return (base_dir / local).resolve()
return install_path.resolve()

def _create_resolution_summary(self, graph: DependencyGraph) -> str:
"""
Expand Down
9 changes: 6 additions & 3 deletions src/apm_cli/install/phases/local_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,16 @@ def _has_local_apm_content(project_root):
# ---------------------------------------------------------------------------


def _copy_local_package(dep_ref, install_path, project_root, logger=None):
def _copy_local_package(dep_ref, install_path, base_dir, logger=None):
"""Copy a local package to apm_modules/.

Args:
dep_ref: DependencyReference with is_local=True
install_path: Target path under apm_modules/
project_root: Project root for resolving relative paths
base_dir: Directory used to resolve a relative ``dep_ref.local_path``.
For direct deps from the root project this is the project root;
for transitive deps it is the source directory of the package
whose apm.yml declared *dep_ref* (#857).
logger: Optional CommandLogger for structured output

Returns:
Expand All @@ -91,7 +94,7 @@ def _copy_local_package(dep_ref, install_path, project_root, logger=None):

local = Path(dep_ref.local_path).expanduser()
if not local.is_absolute():
local = (project_root / local).resolve()
local = (base_dir / local).resolve()
else:
local = local.resolve()

Expand Down
20 changes: 18 additions & 2 deletions src/apm_cli/install/phases/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,21 @@ def run(ctx: "InstallContext") -> None:
logger = ctx.logger
verbose = ctx.verbose

def download_callback(dep_ref, modules_dir, parent_chain=""):
def download_callback(dep_ref, modules_dir, parent_chain="", parent_pkg=None):
"""Download a package during dependency resolution.

Args:
dep_ref: The dependency to download.
modules_dir: Target apm_modules directory.
parent_chain: Human-readable breadcrumb (e.g. "root > mid")
showing which dependency path led to this transitive dep.
parent_pkg: The APMPackage that declared *dep_ref* (None for
direct deps from the root project). For local deps, the
parent's ``source_path`` becomes the base directory for
resolving the relative path -- so a transitive dep like
``../sibling`` declared inside a package nested several
directories deep resolves against the package's own
location, not the root consumer (#857).
"""
install_path = dep_ref.get_install_path(modules_dir)
if install_path.exists():
Expand All @@ -140,8 +147,17 @@ def download_callback(dep_ref, modules_dir, parent_chain=""):
# so use .add() rather than dict-style assignment.
callback_failures.add(dep_ref.get_unique_key())
return None
# Anchor relative paths on the *declaring* package's source
# directory when available (#857). Falls back to
# ``project_root`` for direct deps and for parents that
# predate the source_path field.
base_dir = (
parent_pkg.source_path
if parent_pkg is not None and parent_pkg.source_path is not None
else project_root
)
Comment thread
JahanzaibTayyab marked this conversation as resolved.
result_path = _copy_local_package(
dep_ref, install_path, project_root, logger=logger
dep_ref, install_path, base_dir, logger=logger
)
if result_path:
callback_downloaded[dep_ref.get_unique_key()] = None
Expand Down
6 changes: 6 additions & 0 deletions src/apm_cli/models/apm_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ class APMPackage:
dev_dependencies: Optional[Dict[str, List[Union[DependencyReference, str, dict]]]] = None
scripts: Optional[Dict[str, str]] = None
package_path: Optional[Path] = None # Local path to package
# Absolute on-disk directory holding this package's apm.yml. Used to
# resolve relative ``local_path`` dependencies declared in this package's
# apm.yml (#857). For local deps this is the *original* source directory,
# not the apm_modules/_local/ copy. For remote deps and the root project
# this matches package_path.
source_path: Optional[Path] = None
target: Optional[Union[str, List[str]]] = None # Target agent(s): single string or list (applies to compile and install)
type: Optional[PackageContentType] = None # Package content type: instructions, skill, hybrid, or prompts
includes: Optional[Union[str, List[str]]] = None # Include-only manifest: 'auto' or list of repo paths
Expand Down
Loading
Loading