diff --git a/docs/src/content/docs/guides/dependencies.md b/docs/src/content/docs/guides/dependencies.md
index 629778db9..54e859c7a 100644
--- a/docs/src/content/docs/guides/dependencies.md
+++ b/docs/src/content/docs/guides/dependencies.md
@@ -27,6 +27,7 @@ APM supports multiple dependency types:
 | **Marketplace Plugin** | Has `plugin.json` (no `apm.yml`) | `github/awesome-copilot/plugins/context-engineering` |
 | **Claude Skill** | Has `SKILL.md` (no `apm.yml`) | `ComposioHQ/awesome-claude-skills/brand-guidelines` || **Hook Package** | Has `hooks/*.json` (no `apm.yml` or `SKILL.md`) | `anthropics/claude-plugins-official/plugins/hookify` || **Virtual Subdirectory Package** | Folder path in monorepo | `ComposioHQ/awesome-claude-skills/mcp-builder` |
 | **Virtual Subdirectory Package** | Folder path in repo | `github/awesome-copilot/skills/review-and-refactor` |
+| **Local Path Package** | Path starts with `./`, `../`, or `/` | `./packages/my-shared-skills` |
 | **ADO Package** | Azure DevOps repo | `dev.azure.com/org/project/_git/repo` |
 
 **Virtual Subdirectory Packages** are skill folders from monorepos - they download an entire folder and may contain a SKILL.md plus resources.
@@ -92,6 +93,10 @@ dependencies:
     # FQDN shorthand with virtual path (any host)
     - gitlab.com/acme/repo/prompts/code-review.prompt.md
 
+    # Local path (for development / monorepo workflows)
+    - ./packages/my-shared-skills          # relative to project root
+    - /home/user/repos/my-ai-package       # absolute path
+
     # Object format: git URL + sub-path / ref / alias
     - git: https://gitlab.com/acme/coding-standards.git
       path: instructions/security
@@ -119,6 +124,7 @@ APM accepts dependencies in two forms:
   - GitLab nested groups: `gitlab.com/group/subgroup/repo`
   - Virtual paths on simple repos: `gitlab.com/owner/repo/file.prompt.md`
   - For nested groups + virtual paths, use the object format below
+- **Local path** (`./path`, `../path`, `/absolute/path`) — local filesystem package
 
 **Object format** (when you need `path`, `ref`, or `alias` on a git URL):
 
@@ -164,6 +170,8 @@ APM normalizes every dependency entry on write — no matter how you specify a p
 | `gitlab.com/group/subgroup/repo` | `gitlab.com/group/subgroup/repo` |
 | `git@gitlab.com:group/subgroup/repo.git` | `gitlab.com/group/subgroup/repo` |
 | `git@bitbucket.org:team/standards.git` | `bitbucket.org/team/standards` |
+| `./packages/my-skills` | `./packages/my-skills` |
+| `/home/user/repos/my-pkg` | `/home/user/repos/my-pkg` |
 
 Virtual paths, refs, and aliases are preserved:
 
@@ -218,6 +226,40 @@ apm compile
 # See docs/wip/distributed-agents-compilation-strategy.md for detailed compilation logic
 ```
 
+## Local Path Dependencies
+
+Install packages from the local filesystem for fast iteration during development.
+
+```bash
+# Relative path
+apm install ./packages/my-shared-skills
+
+# Absolute path
+apm install /home/user/repos/my-ai-package
+```
+
+Or declare them in `apm.yml`:
+
+```yaml
+dependencies:
+  apm:
+    - ./packages/my-shared-skills          # relative to project root
+    - /home/user/repos/my-ai-package       # absolute path
+    - microsoft/apm-sample-package         # remote (can be mixed)
+```
+
+**How it works:**
+- Files are **copied** (not symlinked) to `apm_modules/_local/<package-name>/`
+- 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)
+
+**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.
+
+**Lockfile representation:** Local dependencies are tracked with `source: local` and `local_path` fields. No `resolved_commit` is stored.
+
+**Pack guard:** `apm pack` rejects packages with local path dependencies — replace them with remote references before distributing.
+
 ## MCP Dependency Formats
 
 MCP dependencies support three forms: string references, overlay objects, and self-defined servers.
diff --git a/docs/src/content/docs/guides/pack-distribute.md b/docs/src/content/docs/guides/pack-distribute.md
index 00d5b4dbb..4efa9561b 100644
--- a/docs/src/content/docs/guides/pack-distribute.md
+++ b/docs/src/content/docs/guides/pack-distribute.md
@@ -291,6 +291,7 @@ No APM binary, no Python runtime, no network calls. The action handles extractio
 
 1. **`apm.lock.yaml`** — the resolved lockfile produced by `apm install`. Pack reads the `deployed_files` manifest from this file to know what to include.
 2. **Installed files on disk** — the actual files referenced in `deployed_files` must exist at their expected paths. Pack verifies this and fails with a clear error if files are missing.
+3. **No local path dependencies** — `apm pack` rejects packages that depend on local filesystem paths (`./path` or `/absolute/path`). Replace local dependencies with remote references before packing.
 
 The typical sequence is:
 
diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md
index fd17faead..4cc7fbfd6 100644
--- a/docs/src/content/docs/reference/cli-commands.md
+++ b/docs/src/content/docs/reference/cli-commands.md
@@ -75,7 +75,7 @@ apm install [PACKAGES...] [OPTIONS]
 ```
 
 **Arguments:**
-- `PACKAGES` - Optional APM packages to add and install. Accepts shorthand (`owner/repo`), HTTPS URLs, SSH URLs, or FQDN shorthand (`host/owner/repo`). All forms are normalized to canonical format in `apm.yml`.
+- `PACKAGES` - Optional APM packages to add and install. Accepts shorthand (`owner/repo`), HTTPS URLs, SSH URLs, FQDN shorthand (`host/owner/repo`), or local filesystem paths (`./path`, `../path`, `/absolute/path`, `~/path`). All forms are normalized to canonical format in `apm.yml`.
 
 **Options:**
 - `--runtime TEXT` - Target specific runtime only (copilot, codex, vscode)
@@ -136,6 +136,10 @@ apm install --exclude codex
 
 # Trust self-defined MCP servers from transitive packages
 apm install --trust-transitive-mcp
+
+# Install from a local path (copies to apm_modules/_local/)
+apm install ./packages/my-shared-skills
+apm install /home/user/repos/my-ai-package
 ```
 
 **Auto-Bootstrap Behavior:**
diff --git a/docs/src/content/docs/reference/lockfile-spec.md b/docs/src/content/docs/reference/lockfile-spec.md
index e5790ad04..835f25f9d 100644
--- a/docs/src/content/docs/reference/lockfile-spec.md
+++ b/docs/src/content/docs/reference/lockfile-spec.md
@@ -111,10 +111,10 @@ fields:
 
 | Field | Type | Required | Description |
 |-------|------|----------|-------------|
-| `repo_url` | string | MUST | Source repository URL. |
+| `repo_url` | string | MUST | Source repository URL, or `_local/<name>` for local path dependencies. |
 | `host` | string | MAY | Git host identifier (e.g., `github.com`). Omitted when inferrable from `repo_url`. |
-| `resolved_commit` | string | MUST | Full 40-character commit SHA that was checked out. |
-| `resolved_ref` | string | MUST | Git ref (tag, branch, SHA) that resolved to `resolved_commit`. |
+| `resolved_commit` | string | MUST (remote) | Full 40-character commit SHA that was checked out. Required for remote (git) dependencies; MUST be omitted for local (`source: "local"`) dependencies. |
+| `resolved_ref` | string | MUST (remote) | Git ref (tag, branch, SHA) that resolved to `resolved_commit`. Required for remote (git) dependencies; MUST be omitted for local (`source: "local"`) dependencies. |
 | `version` | string | MAY | Semantic version of the package, if declared in its manifest. |
 | `virtual_path` | string | MAY | Sub-path within the repository for virtual (monorepo) packages. |
 | `is_virtual` | boolean | MAY | `true` if the package is a virtual sub-package. Omitted when `false`. |
@@ -122,6 +122,8 @@ fields:
 | `resolved_by` | string | MAY | `repo_url` of the parent that introduced this transitive dependency. Present only when `depth >= 2`. |
 | `package_type` | string | MUST | Package type: `apm_package`, `plugin`, `virtual`, or other registered types. |
 | `deployed_files` | array of strings | MUST | Every file path APM deployed for this dependency, relative to project root. |
+| `source` | string | MAY | Dependency source. `"local"` for local path dependencies. Omitted for remote (git) dependencies. |
+| `local_path` | string | MAY | Filesystem path (relative or absolute) to the local package. Present only when `source` is `"local"`. |
 
 Fields with empty or default values (empty strings, `false` booleans, empty
 lists) SHOULD be omitted from the serialized output to keep the file concise.
@@ -129,8 +131,10 @@ lists) SHOULD be omitted from the serialized output to keep the file concise.
 ### 4.3 Unique Key
 
 Each dependency is uniquely identified by its `repo_url`, or by the
-combination of `repo_url` and `virtual_path` for virtual packages. A
-conforming lock file MUST NOT contain duplicate entries for the same key.
+combination of `repo_url` and `virtual_path` for virtual packages.
+For local path dependencies (`source: "local"`), the unique key is the
+`local_path` value. A conforming lock file MUST NOT contain duplicate
+entries for the same key.
 
 ## 5. Path Conventions
 
diff --git a/docs/src/content/docs/reference/manifest-schema.md b/docs/src/content/docs/reference/manifest-schema.md
index c5c9739cb..8f2235ad7 100644
--- a/docs/src/content/docs/reference/manifest-schema.md
+++ b/docs/src/content/docs/reference/manifest-schema.md
@@ -169,9 +169,10 @@ Each element MUST be one of two forms: **string** or **object**.
 Grammar (ABNF-style):
 
 ```
-dependency     = url_form / shorthand_form
+dependency     = url_form / shorthand_form / local_path_form
 url_form       = ("https://" / "http://" / "ssh://git@" / "git@") clone-url
 shorthand_form = [host "/"] owner "/" repo ["/" virtual_path] ["#" ref] ["@" alias]
+local_path_form = ("./" / "../" / "/" / "~/" / ".\\" / "..\\" / "~\\") path
 ```
 
 | Segment | Required | Pattern | Description |
@@ -208,6 +209,10 @@ dependencies:
 
     # Azure DevOps
     - dev.azure.com/org/project/_git/repo
+
+    # Local path (development only)
+    - ./packages/my-shared-skills          # relative to project root
+    - ../sibling-repo/my-package           # parent directory
 ```
 
 #### 4.1.2. Object Form
@@ -216,11 +221,13 @@ REQUIRED when the shorthand is ambiguous (e.g. nested-group repos with virtual p
 
 | Field | Type | Required | Pattern / Constraint | Description |
 |---|---|---|---|---|
-| `git` | `string` | REQUIRED | HTTPS URL, SSH URL, or FQDN shorthand | Clone URL of the repository. |
-| `path` | `string` | OPTIONAL | Relative path within the repo | Subdirectory, file, or collection (virtual package). |
+| `git` | `string` | REQUIRED (remote) | HTTPS URL, SSH URL, or FQDN shorthand | Clone URL of the repository. Required for remote dependencies. |
+| `path` | `string` | OPTIONAL / REQUIRED (local) | Relative path within the repo, or local filesystem path | When `git` is present: subdirectory, file, or collection (virtual package). When `git` is absent: local filesystem path (must start with `./`, `../`, `/`, or `~/`). |
 | `ref` | `string` | OPTIONAL | Branch, tag, or commit SHA | Git reference to checkout. |
 | `alias` | `string` | OPTIONAL | `^[a-zA-Z0-9._-]+$` | Local alias. |
 
+Remote dependency (git URL + sub-path):
+
 ```yaml
 - git: https://gitlab.com/acme/repo.git
   path: instructions/security
@@ -228,6 +235,12 @@ REQUIRED when the shorthand is ambiguous (e.g. nested-group repos with virtual p
   alias: acme-sec
 ```
 
+Local path dependency (development only):
+
+```yaml
+- path: ./packages/my-shared-skills
+```
+
 #### 4.1.3. Virtual Packages
 
 A dependency MAY target a subdirectory, file, or collection within a repository rather than the whole repo. Conforming resolvers MUST classify virtual packages using the following rules, evaluated in order:
diff --git a/pyproject.toml b/pyproject.toml
index 12c9748f4..966b5eaab 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,7 +41,7 @@ dependencies = [
 dev = [
     "pytest>=7.0.0",
     "pytest-cov>=4.0.0",
-    "black>=23.0.0",
+    "black>=26.3.1; python_version>='3.10'",
     "isort>=5.0.0",
     "mypy>=1.0.0",
 ]
diff --git a/src/apm_cli/bundle/packer.py b/src/apm_cli/bundle/packer.py
index a078df523..24956bd72 100644
--- a/src/apm_cli/bundle/packer.py
+++ b/src/apm_cli/bundle/packer.py
@@ -78,7 +78,19 @@ def pack_bundle(
         pkg_name = package.name
         pkg_version = package.version or "0.0.0"
         config_target = package.target
-    except (FileNotFoundError, ValueError):
+
+        # Guard: reject local-path dependencies (non-portable)
+        for dep_ref in package.get_apm_dependencies():
+            if dep_ref.is_local:
+                raise ValueError(
+                    f"Cannot pack — apm.yml contains local path dependency: "
+                    f"{dep_ref.local_path}\n"
+                    f"Local dependencies are for development only. Replace them with "
+                    f"remote references (e.g., 'owner/repo') before packing."
+                )
+    except ValueError:
+        raise
+    except FileNotFoundError:
         pkg_name = project_root.resolve().name
         pkg_version = "0.0.0"
         config_target = None
diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py
index e171ed1ab..6b726572a 100644
--- a/src/apm_cli/commands/install.py
+++ b/src/apm_cli/commands/install.py
@@ -95,8 +95,8 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False):
     _rich_info(f"Validating {len(packages)} package(s)...")
 
     for package in packages:
-        # Validate package format (should be owner/repo or a git URL)
-        if "/" not in package:
+        # Validate package format (should be owner/repo, a git URL, or a local path)
+        if "/" not in package and not DependencyReference.is_local_path(package):
             _rich_error(f"Invalid package format: {package}. Use 'owner/repo' format.")
             continue
 
@@ -160,7 +160,7 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False):
 
 
 def _validate_package_exists(package):
-    """Validate that a package exists and is accessible on GitHub or Azure DevOps."""
+    """Validate that a package exists and is accessible on GitHub, Azure DevOps, or locally."""
     import os
     import subprocess
     import tempfile
@@ -172,6 +172,17 @@ def _validate_package_exists(package):
 
         dep_ref = DependencyReference.parse(package)
 
+        # For local packages, validate directory exists and has valid package content
+        if dep_ref.is_local and dep_ref.local_path:
+            local = Path(dep_ref.local_path).expanduser()
+            if not local.is_absolute():
+                local = Path.cwd() / local
+            local = local.resolve()
+            if not local.is_dir():
+                return False
+            # Must contain apm.yml or SKILL.md
+            return (local / "apm.yml").exists() or (local / "SKILL.md").exists()
+
         # For virtual packages, use the downloader's validation method
         if dep_ref.is_virtual:
             downloader = GitHubPackageDownloader()
@@ -497,6 +508,192 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
 # ---------------------------------------------------------------------------
 
 
+def _integrate_package_primitives(
+    package_info,
+    project_root,
+    *,
+    integrate_vscode,
+    integrate_claude,
+    prompt_integrator,
+    agent_integrator,
+    skill_integrator,
+    instruction_integrator,
+    command_integrator,
+    hook_integrator,
+    force,
+    managed_files,
+    diagnostics,
+):
+    """Run the full integration pipeline for a single package.
+
+    Returns a dict with integration counters and the list of deployed file paths.
+    """
+    result = {
+        "prompts": 0,
+        "agents": 0,
+        "skills": 0,
+        "sub_skills": 0,
+        "instructions": 0,
+        "commands": 0,
+        "hooks": 0,
+        "links_resolved": 0,
+        "deployed_files": [],
+    }
+
+    deployed = result["deployed_files"]
+
+    if not (integrate_vscode or integrate_claude):
+        return result
+
+    # --- prompts ---
+    prompt_result = prompt_integrator.integrate_package_prompts(
+        package_info, project_root,
+        force=force, managed_files=managed_files,
+        diagnostics=diagnostics,
+    )
+    if prompt_result.files_integrated > 0:
+        result["prompts"] += prompt_result.files_integrated
+        _rich_info(f"  └─ {prompt_result.files_integrated} prompts integrated → .github/prompts/")
+    if prompt_result.files_updated > 0:
+        _rich_info(f"  └─ {prompt_result.files_updated} prompts updated")
+    result["links_resolved"] += prompt_result.links_resolved
+    for tp in prompt_result.target_paths:
+        deployed.append(tp.relative_to(project_root).as_posix())
+
+    # --- agents (.github) ---
+    agent_result = agent_integrator.integrate_package_agents(
+        package_info, project_root,
+        force=force, managed_files=managed_files,
+        diagnostics=diagnostics,
+    )
+    if agent_result.files_integrated > 0:
+        result["agents"] += agent_result.files_integrated
+        _rich_info(f"  └─ {agent_result.files_integrated} agents integrated → .github/agents/")
+    if agent_result.files_updated > 0:
+        _rich_info(f"  └─ {agent_result.files_updated} agents updated")
+    result["links_resolved"] += agent_result.links_resolved
+    for tp in agent_result.target_paths:
+        deployed.append(tp.relative_to(project_root).as_posix())
+
+    # --- skills ---
+    if integrate_vscode or integrate_claude:
+        skill_result = skill_integrator.integrate_package_skill(package_info, project_root)
+        if skill_result.skill_created:
+            result["skills"] += 1
+            _rich_info(f"  └─ Skill integrated → .github/skills/")
+        if skill_result.sub_skills_promoted > 0:
+            result["sub_skills"] += skill_result.sub_skills_promoted
+            _rich_info(f"  └─ {skill_result.sub_skills_promoted} skill(s) integrated → .github/skills/")
+        for tp in skill_result.target_paths:
+            deployed.append(tp.relative_to(project_root).as_posix())
+
+    # --- instructions (.github) ---
+    if integrate_vscode:
+        instruction_result = instruction_integrator.integrate_package_instructions(
+            package_info, project_root,
+            force=force, managed_files=managed_files,
+            diagnostics=diagnostics,
+        )
+        if instruction_result.files_integrated > 0:
+            result["instructions"] += instruction_result.files_integrated
+            _rich_info(f"  └─ {instruction_result.files_integrated} instruction(s) integrated → .github/instructions/")
+        result["links_resolved"] += instruction_result.links_resolved
+        for tp in instruction_result.target_paths:
+            deployed.append(tp.relative_to(project_root).as_posix())
+
+    # --- Claude agents (.claude) ---
+    if integrate_claude:
+        claude_agent_result = agent_integrator.integrate_package_agents_claude(
+            package_info, project_root,
+            force=force, managed_files=managed_files,
+            diagnostics=diagnostics,
+        )
+        if claude_agent_result.files_integrated > 0:
+            result["agents"] += claude_agent_result.files_integrated
+            _rich_info(f"  └─ {claude_agent_result.files_integrated} agents integrated → .claude/agents/")
+        result["links_resolved"] += claude_agent_result.links_resolved
+        for tp in claude_agent_result.target_paths:
+            deployed.append(tp.relative_to(project_root).as_posix())
+
+        # --- commands (.claude) ---
+        command_result = command_integrator.integrate_package_commands(
+            package_info, project_root,
+            force=force, managed_files=managed_files,
+            diagnostics=diagnostics,
+        )
+        if command_result.files_integrated > 0:
+            result["commands"] += command_result.files_integrated
+            _rich_info(f"  └─ {command_result.files_integrated} commands integrated → .claude/commands/")
+        if command_result.files_updated > 0:
+            _rich_info(f"  └─ {command_result.files_updated} commands updated")
+        result["links_resolved"] += command_result.links_resolved
+        for tp in command_result.target_paths:
+            deployed.append(tp.relative_to(project_root).as_posix())
+
+    # --- hooks ---
+    if integrate_vscode:
+        hook_result = hook_integrator.integrate_package_hooks(
+            package_info, project_root,
+            force=force, managed_files=managed_files,
+            diagnostics=diagnostics,
+        )
+        if hook_result.hooks_integrated > 0:
+            result["hooks"] += hook_result.hooks_integrated
+            _rich_info(f"  └─ {hook_result.hooks_integrated} hook(s) integrated → .github/hooks/")
+        for tp in hook_result.target_paths:
+            deployed.append(tp.relative_to(project_root).as_posix())
+    if integrate_claude:
+        hook_result_claude = hook_integrator.integrate_package_hooks_claude(
+            package_info, project_root,
+            force=force, managed_files=managed_files,
+            diagnostics=diagnostics,
+        )
+        if hook_result_claude.hooks_integrated > 0:
+            result["hooks"] += hook_result_claude.hooks_integrated
+            _rich_info(f"  └─ {hook_result_claude.hooks_integrated} hook(s) integrated → .claude/settings.json")
+        for tp in hook_result_claude.target_paths:
+            deployed.append(tp.relative_to(project_root).as_posix())
+
+    return result
+
+
+def _copy_local_package(dep_ref, install_path, project_root):
+    """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
+
+    Returns:
+        install_path on success, None on failure
+    """
+    import shutil
+
+    local = Path(dep_ref.local_path).expanduser()
+    if not local.is_absolute():
+        local = (project_root / local).resolve()
+    else:
+        local = local.resolve()
+
+    if not local.is_dir():
+        _rich_error(f"Local package path does not exist: {dep_ref.local_path}")
+        return None
+    if not (local / "apm.yml").exists() and not (local / "SKILL.md").exists():
+        _rich_error(
+            f"Local package is not a valid APM package (no apm.yml or SKILL.md): {dep_ref.local_path}"
+        )
+        return None
+
+    # Ensure parent exists and clean target (always re-copy for local deps)
+    install_path.parent.mkdir(parents=True, exist_ok=True)
+    if install_path.exists():
+        shutil.rmtree(install_path)
+
+    shutil.copytree(local, install_path, dirs_exist_ok=False, symlinks=True)
+    return install_path
+
+
 def _install_apm_dependencies(
     apm_package: "APMPackage",
     update_refs: bool = False,
@@ -556,6 +753,14 @@ def download_callback(dep_ref, modules_dir):
         if install_path.exists():
             return install_path
         try:
+            # Handle local packages: copy instead of git clone
+            if dep_ref.is_local and dep_ref.local_path:
+                result_path = _copy_local_package(dep_ref, install_path, project_root)
+                if result_path:
+                    callback_downloaded[dep_ref.get_unique_key()] = None
+                    return result_path
+                return None
+
             # Build repo_ref string - include host for GHE/ADO, plus reference if specified
             repo_ref = dep_ref.repo_url
             if dep_ref.host and dep_ref.host not in ("github.com", None):
@@ -770,6 +975,9 @@ def _collect_descendants(node, visited=None):
         for _pd_ref in deps_to_install:
             _pd_key = _pd_ref.get_unique_key()
             _pd_path = (apm_modules_dir / _pd_ref.alias) if _pd_ref.alias else _pd_ref.get_install_path(apm_modules_dir)
+            # Skip local packages — they are copied, not downloaded
+            if _pd_ref.is_local:
+                continue
             # Skip if already downloaded during BFS resolution
             if _pd_key in callback_downloaded:
                 continue
@@ -856,6 +1064,118 @@ def _collect_descendants(node, visited=None):
                     # Use the canonical install path from DependencyReference
                     install_path = dep_ref.get_install_path(apm_modules_dir)
 
+                # --- Local package: copy from filesystem (no git download) ---
+                if dep_ref.is_local and dep_ref.local_path:
+                    result_path = _copy_local_package(dep_ref, install_path, project_root)
+                    if not result_path:
+                        diagnostics.error(
+                            f"Failed to copy local package: {dep_ref.local_path}",
+                            package=dep_ref.local_path,
+                        )
+                        continue
+
+                    installed_count += 1
+                    _rich_success(f"✓ {dep_ref.local_path} (local)")
+
+                    # Build minimal PackageInfo for integration
+                    from apm_cli.models.apm_package import (
+                        APMPackage,
+                        PackageInfo,
+                        PackageType,
+                        ResolvedReference,
+                        GitReferenceType,
+                    )
+                    from datetime import datetime
+
+                    local_apm_yml = install_path / "apm.yml"
+                    if local_apm_yml.exists():
+                        local_pkg = APMPackage.from_apm_yml(local_apm_yml)
+                        if not local_pkg.source:
+                            local_pkg.source = dep_ref.local_path
+                    else:
+                        local_pkg = APMPackage(
+                            name=Path(dep_ref.local_path).name,
+                            version="0.0.0",
+                            package_path=install_path,
+                            source=dep_ref.local_path,
+                        )
+
+                    local_ref = ResolvedReference(
+                        original_ref="local",
+                        ref_type=GitReferenceType.BRANCH,
+                        resolved_commit="local",
+                        ref_name="local",
+                    )
+                    local_info = PackageInfo(
+                        package=local_pkg,
+                        install_path=install_path,
+                        resolved_reference=local_ref,
+                        installed_at=datetime.now().isoformat(),
+                        dependency_ref=dep_ref,
+                    )
+
+                    # Detect package type
+                    has_skill = (install_path / "SKILL.md").exists()
+                    has_apm = (install_path / "apm.yml").exists()
+                    from apm_cli.utils.helpers import find_plugin_json
+                    has_plugin = find_plugin_json(install_path) is not None
+                    if has_plugin and not has_apm:
+                        local_info.package_type = PackageType.MARKETPLACE_PLUGIN
+                    elif has_skill and has_apm:
+                        local_info.package_type = PackageType.HYBRID
+                    elif has_skill:
+                        local_info.package_type = PackageType.CLAUDE_SKILL
+                    elif has_apm:
+                        local_info.package_type = PackageType.APM_PACKAGE
+
+                    # Record for lockfile
+                    node = dependency_graph.dependency_tree.get_node(dep_ref.get_unique_key())
+                    depth = node.depth if node else 1
+                    resolved_by = node.parent.dependency_ref.repo_url if node and node.parent else None
+                    installed_packages.append((dep_ref, None, depth, resolved_by))
+                    dep_key = dep_ref.get_unique_key()
+                    dep_deployed_files: builtins.list = []
+
+                    if hasattr(local_info, 'package_type') and local_info.package_type:
+                        package_types[dep_key] = local_info.package_type.value
+
+                    # Use the same variable name as the rest of the loop
+                    package_info = local_info
+
+                    # Run shared integration pipeline
+                    try:
+                        int_result = _integrate_package_primitives(
+                            package_info, project_root,
+                            integrate_vscode=integrate_vscode,
+                            integrate_claude=integrate_claude,
+                            prompt_integrator=prompt_integrator,
+                            agent_integrator=agent_integrator,
+                            skill_integrator=skill_integrator,
+                            instruction_integrator=instruction_integrator,
+                            command_integrator=command_integrator,
+                            hook_integrator=hook_integrator,
+                            force=force,
+                            managed_files=managed_files,
+                            diagnostics=diagnostics,
+                        )
+                        total_prompts_integrated += int_result["prompts"]
+                        total_agents_integrated += int_result["agents"]
+                        total_skills_integrated += int_result["skills"]
+                        total_sub_skills_promoted += int_result["sub_skills"]
+                        total_instructions_integrated += int_result["instructions"]
+                        total_commands_integrated += int_result["commands"]
+                        total_hooks_integrated += int_result["hooks"]
+                        total_links_resolved += int_result["links_resolved"]
+                        dep_deployed_files.extend(int_result["deployed_files"])
+                    except Exception as e:
+                        diagnostics.error(
+                            f"Failed to integrate primitives from local package: {e}",
+                            package=dep_ref.local_path,
+                        )
+
+                    package_deployed_files[dep_key] = dep_deployed_files
+                    continue
+
                 # npm-like behavior: Branches always fetch latest, only tags/commits use cache
                 # Resolve git reference to determine type
                 from apm_cli.models.apm_package import GitReferenceType
diff --git a/src/apm_cli/deps/lockfile.py b/src/apm_cli/deps/lockfile.py
index 2fa57e936..cda16fb1e 100644
--- a/src/apm_cli/deps/lockfile.py
+++ b/src/apm_cli/deps/lockfile.py
@@ -31,9 +31,13 @@ class LockedDependency:
     resolved_by: Optional[str] = None
     package_type: Optional[str] = None
     deployed_files: List[str] = field(default_factory=list)
+    source: Optional[str] = None  # "local" for local deps, None/absent for remote
+    local_path: Optional[str] = None  # Original local path (relative to project root)
 
     def get_unique_key(self) -> str:
         """Returns unique key for this dependency."""
+        if self.source == "local" and self.local_path:
+            return self.local_path
         if self.is_virtual and self.virtual_path:
             return f"{self.repo_url}/{self.virtual_path}"
         return self.repo_url
@@ -61,6 +65,10 @@ def to_dict(self) -> Dict[str, Any]:
             result["package_type"] = self.package_type
         if self.deployed_files:
             result["deployed_files"] = sorted(self.deployed_files)
+        if self.source:
+            result["source"] = self.source
+        if self.local_path:
+            result["local_path"] = self.local_path
         return result
 
     @classmethod
@@ -92,6 +100,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "LockedDependency":
             resolved_by=data.get("resolved_by"),
             package_type=data.get("package_type"),
             deployed_files=deployed_files,
+            source=data.get("source"),
+            local_path=data.get("local_path"),
         )
 
     @classmethod
@@ -112,6 +122,8 @@ def from_dependency_ref(
             is_virtual=dep_ref.is_virtual,
             depth=depth,
             resolved_by=resolved_by,
+            source="local" if dep_ref.is_local else None,
+            local_path=dep_ref.local_path if dep_ref.is_local else None,
         )
 
 
@@ -256,6 +268,8 @@ def get_installed_paths(self, apm_modules_dir: Path) -> List[str]:
                 host=dep.host,
                 virtual_path=dep.virtual_path,
                 is_virtual=dep.is_virtual,
+                is_local=(dep.source == "local"),
+                local_path=dep.local_path,
             )
             install_path = dep_ref.get_install_path(apm_modules_dir)
             try:
diff --git a/src/apm_cli/models/dependency.py b/src/apm_cli/models/dependency.py
index 87f0f7f6d..48b4689a3 100644
--- a/src/apm_cli/models/dependency.py
+++ b/src/apm_cli/models/dependency.py
@@ -54,6 +54,10 @@ class DependencyReference:
     ado_project: Optional[str] = None       # e.g., "market-js-app"
     ado_repo: Optional[str] = None          # e.g., "compliance-rules"
     
+    # Local path dependency fields
+    is_local: bool = False  # True if this is a local filesystem dependency
+    local_path: Optional[str] = None  # Original local path string (e.g., "./packages/my-pkg")
+    
     # Supported file extensions for virtual packages
     VIRTUAL_FILE_EXTENSIONS = ('.prompt.md', '.instructions.md', '.chatmode.md', '.agent.md')
     
@@ -131,15 +135,31 @@ def get_virtual_package_name(self) -> str:
                     break
             return f"{repo_name}-{filename}"
     
+    @staticmethod
+    def is_local_path(dep_str: str) -> bool:
+        """Check if a dependency string looks like a local filesystem path.
+        
+        Local paths start with './', '../', '/', or '~'.
+        Protocol-relative URLs ('//...') are explicitly excluded.
+        """
+        s = dep_str.strip()
+        # Reject protocol-relative URLs ('//...')
+        if s.startswith('//'):
+            return False
+        return s.startswith(('./','../', '/', '~/', '~\\', '.\\', '..\\'))
+
     def get_unique_key(self) -> str:
         """Get a unique key for this dependency for deduplication.
         
         For regular packages: repo_url
         For virtual packages: repo_url + virtual_path to ensure uniqueness
+        For local packages: the local_path
         
         Returns:
             str: Unique key for this dependency
         """
+        if self.is_local and self.local_path:
+            return self.local_path
         if self.is_virtual and self.virtual_path:
             return f"{self.repo_url}/{self.virtual_path}"
         return self.repo_url
@@ -153,12 +173,16 @@ def to_canonical(self) -> str:
         - Virtual paths are appended              ->  owner/repo/path/to/thing
         - Refs are appended with #                ->  owner/repo#v1.0
         - Aliases are appended with @             ->  owner/repo@my-alias
+        - Local paths are returned as-is          ->  ./packages/my-pkg
         
         No .git suffix, no https://, no git@  -- just the canonical identifier.
         
         Returns:
             str: Canonical dependency string
         """
+        if self.is_local and self.local_path:
+            return self.local_path
+
         host = self.host or default_host()
         is_default = host.lower() == default_host().lower()
         
@@ -191,6 +215,9 @@ def get_identity(self) -> str:
         Returns:
             str: Identity string (e.g., "owner/repo" or "gitlab.com/owner/repo/path")
         """
+        if self.is_local and self.local_path:
+            return self.local_path
+
         host = self.host or default_host()
         is_default = host.lower() == default_host().lower()
         
@@ -249,12 +276,19 @@ def get_install_path(self, apm_modules_dir: Path) -> Path:
             - GitHub: apm_modules/owner/repo/subdir/path/
             - ADO: apm_modules/org/project/repo/subdir/path/
         
+        For local packages:
+            - apm_modules/_local/<directory-name>/
+        
         Args:
             apm_modules_dir: Path to the apm_modules directory
             
         Returns:
             Path: Absolute path to the package installation directory
         """
+        if self.is_local and self.local_path:
+            pkg_dir_name = Path(self.local_path).name
+            return apm_modules_dir / "_local" / pkg_dir_name
+
         repo_parts = self.repo_url.split("/")
         
         if self.is_virtual:
@@ -343,8 +377,12 @@ def parse_from_dict(cls, entry: dict) -> "DependencyReference":
             - git: git@bitbucket.org:team/rules.git
               path: prompts/review.prompt.md
         
+        Also supports local path entries:
+        
+            - path: ./packages/my-shared-skills
+        
         Args:
-            entry: Dictionary with 'git' (required), 'path' (optional), 'ref' (optional)
+            entry: Dictionary with 'git' or 'path' (required), plus optional fields
             
         Returns:
             DependencyReference: Parsed dependency reference
@@ -352,8 +390,22 @@ def parse_from_dict(cls, entry: dict) -> "DependencyReference":
         Raises:
             ValueError: If the entry is missing required fields or has invalid format
         """
+        # Support dict-form local path: { path: ./local/dir }
+        if 'path' in entry and 'git' not in entry:
+            local = entry['path']
+            if not isinstance(local, str) or not local.strip():
+                raise ValueError("'path' field must be a non-empty string")
+            local = local.strip()
+            if not cls.is_local_path(local):
+                raise ValueError(
+                    f"Object-style dependency must have a 'git' field, "
+                    f"or 'path' must be a local filesystem path "
+                    f"(starting with './', '../', '/', or '~')"
+                )
+            return cls.parse(local)
+
         if 'git' not in entry:
-            raise ValueError("Object-style dependency must have a 'git' field")
+            raise ValueError("Object-style dependency must have a 'git' or 'path' field")
         
         git_url = entry['git']
         if not isinstance(git_url, str) or not git_url.strip():
@@ -407,6 +459,9 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
         - https://gitlab.com/owner/repo.git (generic HTTPS git URL)
         - git@gitlab.com:owner/repo.git (SSH git URL)
         - ssh://git@gitlab.com/owner/repo.git (SSH protocol URL)
+        - ./local/path (local filesystem path)
+        - /absolute/path (local filesystem path)
+        - ../relative/path (local filesystem path)
         
         Any valid FQDN is accepted as a git host (GitHub, GitLab, Bitbucket,
         self-hosted instances, etc.).
@@ -423,6 +478,25 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
         if not dependency_str.strip():
             raise ValueError("Empty dependency string")
 
+        # --- Local path detection (must run before URL/host parsing) ---
+        if cls.is_local_path(dependency_str):
+            local = dependency_str.strip()
+            # Derive a safe directory name from the path basename.
+            # Path("../pkg").name → "pkg", but Path("../").name → "..",
+            # Path("./").name → "", Path("/").name → "" — all unsafe.
+            pkg_name = Path(local).name
+            if not pkg_name or pkg_name in ('.', '..'):
+                raise ValueError(
+                    f"Local path '{local}' does not resolve to a named directory. "
+                    f"Use a path that ends with a directory name "
+                    f"(e.g., './my-package' instead of './')."
+                )
+            return cls(
+                repo_url=f"_local/{pkg_name}",
+                is_local=True,
+                local_path=local,
+            )
+
         # Decode percent-encoded characters (e.g., %20 for spaces in ADO project names)
         dependency_str = urllib.parse.unquote(dependency_str)
 
@@ -828,7 +902,11 @@ def to_github_url(self) -> str:
         
         For Azure DevOps, generates: https://dev.azure.com/org/project/_git/repo
         For GitHub, generates: https://github.com/owner/repo
+        For local packages, returns the local path.
         """
+        if self.is_local and self.local_path:
+            return self.local_path
+
         host = self.host or default_host()
         
         if self.is_azure_devops():
@@ -847,12 +925,16 @@ def get_display_name(self) -> str:
         """Get display name for this dependency (alias or repo name)."""
         if self.alias:
             return self.alias
+        if self.is_local and self.local_path:
+            return self.local_path
         if self.is_virtual:
             return self.get_virtual_package_name()
         return self.repo_url  # Full repo URL for disambiguation
 
     def __str__(self) -> str:
         """String representation of the dependency reference."""
+        if self.is_local and self.local_path:
+            return self.local_path
         if self.host:
             result = f"{self.host}/{self.repo_url}"
         else:
diff --git a/tests/integration/test_local_install.py b/tests/integration/test_local_install.py
new file mode 100644
index 000000000..fc5839704
--- /dev/null
+++ b/tests/integration/test_local_install.py
@@ -0,0 +1,407 @@
+"""Integration tests for local filesystem path dependency support.
+
+Tests the full install/uninstall/deps workflow using local path dependencies.
+These tests create real file structures and invoke CLI commands via subprocess.
+"""
+
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+import pytest
+import yaml
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def apm_command():
+    """Get the path to the APM CLI executable."""
+    apm_on_path = shutil.which("apm")
+    if apm_on_path:
+        return apm_on_path
+    venv_apm = Path(__file__).parent.parent.parent / ".venv" / "bin" / "apm"
+    if venv_apm.exists():
+        return str(venv_apm)
+    return "apm"
+
+
+@pytest.fixture
+def temp_workspace(tmp_path):
+    """Create a workspace with a consumer project and local packages.
+
+    Layout:
+        workspace/
+        ├── consumer/               ← project that installs local deps
+        │   └── apm.yml
+        └── packages/
+            ├── local-skills/       ← valid APM package
+            │   ├── apm.yml
+            │   └── instructions/
+            │       └── test-skill.instructions.md
+            ├── local-prompts/      ← valid APM package with prompts
+            │   ├── apm.yml
+            │   └── prompts/
+            │       └── review.prompt.md
+            └── no-manifest/        ← invalid package (no apm.yml/SKILL.md)
+                └── README.md
+    """
+    workspace = tmp_path / "workspace"
+    workspace.mkdir()
+
+    # Consumer project
+    consumer = workspace / "consumer"
+    consumer.mkdir()
+    (consumer / "apm.yml").write_text(yaml.dump({
+        "name": "consumer-project",
+        "version": "1.0.0",
+        "dependencies": {"apm": []},
+    }))
+    # Create .github directory for instructions deployment
+    (consumer / ".github").mkdir()
+
+    # Local skills package
+    skills_pkg = workspace / "packages" / "local-skills"
+    skills_pkg.mkdir(parents=True)
+    (skills_pkg / "apm.yml").write_text(yaml.dump({
+        "name": "local-skills",
+        "version": "1.0.0",
+        "description": "Local test skills package",
+    }))
+    instructions_dir = skills_pkg / ".apm" / "instructions"
+    instructions_dir.mkdir(parents=True)
+    (instructions_dir / "test-skill.instructions.md").write_text(
+        "---\napplyTo: '**'\n---\n# Test Skill\nThis is a test skill."
+    )
+
+    # Local prompts package
+    prompts_pkg = workspace / "packages" / "local-prompts"
+    prompts_pkg.mkdir(parents=True)
+    (prompts_pkg / "apm.yml").write_text(yaml.dump({
+        "name": "local-prompts",
+        "version": "1.0.0",
+        "description": "Local test prompts package",
+    }))
+    prompts_dir = prompts_pkg / ".apm" / "prompts"
+    prompts_dir.mkdir(parents=True)
+    (prompts_dir / "review.prompt.md").write_text(
+        "---\nmode: agent\n---\nReview this code carefully."
+    )
+
+    # Invalid package (no manifest)
+    no_manifest = workspace / "packages" / "no-manifest"
+    no_manifest.mkdir(parents=True)
+    (no_manifest / "README.md").write_text("# No manifest here")
+
+    return workspace
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+class TestLocalInstall:
+    """Test `apm install ./local/path` workflow."""
+
+    def test_install_local_package_relative_path(self, temp_workspace, apm_command):
+        """Install a local package using a relative path."""
+        consumer = temp_workspace / "consumer"
+        result = subprocess.run(
+            [apm_command, "install", "../packages/local-skills"],
+            cwd=consumer,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+        assert result.returncode == 0, f"Install failed: {result.stderr}"
+
+        # Verify apm.yml updated
+        with open(consumer / "apm.yml") as f:
+            data = yaml.safe_load(f)
+        apm_deps = data.get("dependencies", {}).get("apm", [])
+        assert "../packages/local-skills" in apm_deps
+
+        # Verify apm_modules populated
+        install_dir = consumer / "apm_modules" / "_local" / "local-skills"
+        assert install_dir.exists(), "Package not copied to apm_modules/_local/"
+        assert (install_dir / "apm.yml").exists()
+        assert (install_dir / ".apm" / "instructions" / "test-skill.instructions.md").exists()
+
+        # Verify lockfile
+        lock_path = consumer / "apm.lock"
+        assert lock_path.exists(), "Lockfile not created"
+        with open(lock_path) as f:
+            lock_data = yaml.safe_load(f)
+        deps = lock_data.get("dependencies", [])
+        # Local deps have source: local
+        assert any(
+            d.get("source") == "local"
+            for d in deps
+        ), f"No local source in lockfile: {deps}"
+
+    def test_install_local_package_absolute_path(self, temp_workspace, apm_command):
+        """Install a local package using an absolute path."""
+        consumer = temp_workspace / "consumer"
+        abs_path = str(temp_workspace / "packages" / "local-skills")
+        result = subprocess.run(
+            [apm_command, "install", abs_path],
+            cwd=consumer,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+        assert result.returncode == 0, f"Install failed: {result.stderr}"
+
+        # Verify apm.yml has the absolute path
+        with open(consumer / "apm.yml") as f:
+            data = yaml.safe_load(f)
+        apm_deps = data.get("dependencies", {}).get("apm", [])
+        assert abs_path in apm_deps
+
+    def test_install_local_deploys_instructions(self, temp_workspace, apm_command):
+        """Verify that instructions from a local package are deployed to .github/instructions/."""
+        consumer = temp_workspace / "consumer"
+        result = subprocess.run(
+            [apm_command, "install", "../packages/local-skills"],
+            cwd=consumer,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+        assert result.returncode == 0, f"Install failed: {result.stderr}"
+
+        # Check instructions deployed
+        deployed = consumer / ".github" / "instructions" / "test-skill.instructions.md"
+        all_files = list((consumer / '.github').rglob('*'))
+        assert deployed.exists(), (
+            f"Instructions not deployed. Files in .github/: {all_files}\n"
+            f"stdout: {result.stdout}"
+        )
+
+    def test_install_local_package_no_manifest_fails(self, temp_workspace, apm_command):
+        """Installing a path with no apm.yml or SKILL.md should fail gracefully."""
+        consumer = temp_workspace / "consumer"
+        result = subprocess.run(
+            [apm_command, "install", "../packages/no-manifest"],
+            cwd=consumer,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+        # Should report the package as not accessible (validation fails)
+        combined = result.stdout + result.stderr
+        assert "not accessible" in combined.lower() or "doesn't exist" in combined.lower(), (
+            f"Expected failure message. stdout: {result.stdout}, stderr: {result.stderr}"
+        )
+
+    def test_install_nonexistent_local_path_fails(self, temp_workspace, apm_command):
+        """Installing a non-existent path should fail."""
+        consumer = temp_workspace / "consumer"
+        result = subprocess.run(
+            [apm_command, "install", "./does-not-exist"],
+            cwd=consumer,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+        combined = result.stdout + result.stderr
+        assert "not accessible" in combined.lower() or "doesn't exist" in combined.lower()
+
+    def test_install_local_from_apm_yml(self, temp_workspace, apm_command):
+        """Install local deps declared in apm.yml (bare `apm install`)."""
+        consumer = temp_workspace / "consumer"
+
+        # Write apm.yml with local dep
+        (consumer / "apm.yml").write_text(yaml.dump({
+            "name": "consumer-project",
+            "version": "1.0.0",
+            "dependencies": {
+                "apm": ["../packages/local-skills"],
+            },
+        }))
+
+        result = subprocess.run(
+            [apm_command, "install"],
+            cwd=consumer,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+        assert result.returncode == 0, f"Install failed: {result.stderr}"
+
+        # Verify package installed
+        install_dir = consumer / "apm_modules" / "_local" / "local-skills"
+        assert install_dir.exists()
+
+    def test_reinstall_copies_fresh(self, temp_workspace, apm_command):
+        """Re-running `apm install` on local deps should re-copy (no SHA to cache)."""
+        consumer = temp_workspace / "consumer"
+
+        # First install
+        subprocess.run(
+            [apm_command, "install", "../packages/local-skills"],
+            cwd=consumer,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+
+        # Modify source file
+        skill_file = temp_workspace / "packages" / "local-skills" / ".apm" / "instructions" / "test-skill.instructions.md"
+        skill_file.write_text("---\napplyTo: '**'\n---\n# Updated Test Skill\nThis skill was updated.")
+
+        # Re-install
+        result = subprocess.run(
+            [apm_command, "install"],
+            cwd=consumer,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+        assert result.returncode == 0
+
+        # Verify updated content in apm_modules
+        copied_file = consumer / "apm_modules" / "_local" / "local-skills" / ".apm" / "instructions" / "test-skill.instructions.md"
+        assert "Updated Test Skill" in copied_file.read_text()
+
+
+class TestLocalUninstall:
+    """Test `apm uninstall ./local/path` workflow."""
+
+    def test_uninstall_local_package(self, temp_workspace, apm_command):
+        """Uninstall a previously installed local package."""
+        consumer = temp_workspace / "consumer"
+
+        # Install first
+        subprocess.run(
+            [apm_command, "install", "../packages/local-skills"],
+            cwd=consumer,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+
+        # Verify installed
+        assert (consumer / "apm_modules" / "_local" / "local-skills").exists()
+
+        # Uninstall
+        result = subprocess.run(
+            [apm_command, "uninstall", "../packages/local-skills"],
+            cwd=consumer,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+        assert result.returncode == 0, f"Uninstall failed: {result.stderr}"
+
+        # Verify removed from apm.yml
+        with open(consumer / "apm.yml") as f:
+            data = yaml.safe_load(f)
+        apm_deps = data.get("dependencies", {}).get("apm", []) or []
+        assert "../packages/local-skills" not in apm_deps
+
+        # Verify apm_modules cleaned up
+        assert not (consumer / "apm_modules" / "_local" / "local-skills").exists()
+
+
+class TestLocalDeps:
+    """Test `apm deps` with local dependencies."""
+
+    def test_deps_shows_local_packages(self, temp_workspace, apm_command):
+        """The `apm deps list` command should list local dependencies."""
+        consumer = temp_workspace / "consumer"
+
+        # Install a local package
+        subprocess.run(
+            [apm_command, "install", "../packages/local-skills"],
+            cwd=consumer,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+
+        result = subprocess.run(
+            [apm_command, "deps", "list"],
+            cwd=consumer,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+        assert result.returncode == 0, f"deps list failed: {result.stderr}"
+        combined = result.stdout + result.stderr
+        # Should mention the local dep somehow
+        assert "local-skills" in combined.lower() or "local" in combined.lower(), (
+            f"Expected 'local-skills' in deps output: {combined}"
+        )
+
+
+class TestLocalPackMixed:
+    """Test that `apm pack` rejects local deps."""
+
+    def test_pack_rejects_with_local_deps(self, temp_workspace, apm_command):
+        """apm pack should refuse when apm.yml has local deps."""
+        consumer = temp_workspace / "consumer"
+
+        # Write apm.yml with local dep
+        (consumer / "apm.yml").write_text(yaml.dump({
+            "name": "consumer-project",
+            "version": "1.0.0",
+            "dependencies": {
+                "apm": ["../packages/local-skills"],
+            },
+        }))
+
+        # Create a valid lockfile via the LockFile API
+        from apm_cli.deps.lockfile import LockFile as _LF, LockedDependency as _LD
+        _lock = _LF()
+        _lock.add_dependency(_LD(
+            repo_url="_local/local-skills",
+            source="local",
+            local_path="../packages/local-skills",
+        ))
+        _lock.write(consumer / "apm.lock")
+
+        result = subprocess.run(
+            [apm_command, "pack"],
+            cwd=consumer,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+        combined = result.stdout + result.stderr
+        assert result.returncode != 0 or "local" in combined.lower(), (
+            f"Expected pack to reject local deps. stdout: {result.stdout}, stderr: {result.stderr}"
+        )
+
+
+class TestLocalMixedWithRemote:
+    """Test mixing local and remote dependencies."""
+
+    def test_install_local_alongside_remote_in_apm_yml(self, temp_workspace, apm_command):
+        """Both local and remote deps in apm.yml should install correctly."""
+        consumer = temp_workspace / "consumer"
+
+        # First install local
+        result = subprocess.run(
+            [apm_command, "install", "../packages/local-skills"],
+            cwd=consumer,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+        assert result.returncode == 0, f"Install local failed: {result.stderr}"
+
+        # Verify local installed
+        assert (consumer / "apm_modules" / "_local" / "local-skills").exists()
+
+        # Verify apm.yml has both
+        with open(consumer / "apm.yml") as f:
+            data = yaml.safe_load(f)
+        apm_deps = data.get("dependencies", {}).get("apm", [])
+        assert "../packages/local-skills" in apm_deps
diff --git a/tests/test_apm_package_models.py b/tests/test_apm_package_models.py
index 4e9228aed..82749dafa 100644
--- a/tests/test_apm_package_models.py
+++ b/tests/test_apm_package_models.py
@@ -391,7 +391,6 @@ def test_regular_package_not_virtual(self):
     def test_parse_control_characters_rejected(self):
         """Test that control characters are rejected."""
         invalid_formats = [
-            "/repo",
             "user//repo",
             "user repo",
         ]
@@ -399,6 +398,12 @@ def test_parse_control_characters_rejected(self):
         for invalid_format in invalid_formats:
             with pytest.raises(ValueError, match="Invalid Git host|Empty dependency string|Invalid repository|Use 'user/repo'|path component"):
                 DependencyReference.parse(invalid_format)
+
+    def test_parse_absolute_path_as_local(self):
+        """Test that an absolute path like /repo is parsed as a local dependency."""
+        dep = DependencyReference.parse("/repo")
+        assert dep.is_local is True
+        assert dep.local_path == "/repo"
     
     def test_to_github_url(self):
         """Test converting to GitHub URL."""
diff --git a/tests/test_lockfile.py b/tests/test_lockfile.py
index d49c729e8..5e32d798e 100644
--- a/tests/test_lockfile.py
+++ b/tests/test_lockfile.py
@@ -169,6 +169,8 @@ def test_from_installed_packages(self):
         dep_ref.reference = "main"
         dep_ref.virtual_path = None
         dep_ref.is_virtual = False
+        dep_ref.is_local = False
+        dep_ref.local_path = None
         installed = [(dep_ref, "commit123", 1, None)]
         lock = LockFile.from_installed_packages(installed, Mock())
         assert lock.has_dependency("owner/repo")
diff --git a/tests/unit/test_auth_scoping.py b/tests/unit/test_auth_scoping.py
index 3a4eaee5f..75f7848a6 100644
--- a/tests/unit/test_auth_scoping.py
+++ b/tests/unit/test_auth_scoping.py
@@ -306,7 +306,9 @@ def test_ref_in_url_overridden_by_field(self):
     # --- Error cases ---
 
     def test_missing_git_field(self):
-        with pytest.raises(ValueError, match="'git' field"):
+        # With local path support, {"path": "foo"} is treated as a local path attempt.
+        # Since "foo" is not a valid local or remote dependency, it raises ValueError.
+        with pytest.raises(ValueError):
             DependencyReference.parse_from_dict({"path": "foo"})
 
     def test_empty_git_field(self):
@@ -402,7 +404,7 @@ def test_invalid_dict_dep_raises(self, tmp_path):
   apm:
     - path: foo/bar
 """)
-        with pytest.raises(ValueError, match="'git' field"):
+        with pytest.raises(ValueError, match="'git' field|local filesystem path"):
             APMPackage.from_apm_yml(yml)
 
 
diff --git a/tests/unit/test_local_deps.py b/tests/unit/test_local_deps.py
new file mode 100644
index 000000000..64491adba
--- /dev/null
+++ b/tests/unit/test_local_deps.py
@@ -0,0 +1,523 @@
+"""Unit tests for local filesystem path dependency support."""
+
+import pytest
+import yaml
+from pathlib import Path
+from unittest.mock import Mock
+
+from apm_cli.models.apm_package import DependencyReference, APMPackage
+from apm_cli.deps.lockfile import LockedDependency, LockFile
+
+
+# ===========================================================================
+# DependencyReference.is_local_path()
+# ===========================================================================
+
+class TestIsLocalPath:
+    """Test local path detection logic."""
+
+    def test_relative_dot_slash(self):
+        assert DependencyReference.is_local_path("./my-package") is True
+
+    def test_relative_dot_dot_slash(self):
+        assert DependencyReference.is_local_path("../sibling-pkg") is True
+
+    def test_absolute_unix(self):
+        assert DependencyReference.is_local_path("/home/user/my-pkg") is True
+
+    def test_home_tilde(self):
+        assert DependencyReference.is_local_path("~/repos/my-pkg") is True
+
+    def test_windows_relative(self):
+        assert DependencyReference.is_local_path(".\\packages\\my-pkg") is True
+
+    def test_windows_parent(self):
+        assert DependencyReference.is_local_path("..\\sibling-pkg") is True
+
+    def test_windows_home(self):
+        assert DependencyReference.is_local_path("~\\repos\\my-pkg") is True
+
+    def test_remote_shorthand_not_local(self):
+        assert DependencyReference.is_local_path("owner/repo") is False
+
+    def test_https_url_not_local(self):
+        assert DependencyReference.is_local_path("https://github.com/owner/repo") is False
+
+    def test_ssh_url_not_local(self):
+        assert DependencyReference.is_local_path("git@github.com:owner/repo.git") is False
+
+    def test_protocol_relative_not_local(self):
+        """Protocol-relative URLs (//...) must NOT be treated as local paths."""
+        assert DependencyReference.is_local_path("//evil.com/owner/repo") is False
+
+    def test_bare_name_not_local(self):
+        assert DependencyReference.is_local_path("my-package") is False
+
+    def test_whitespace_trimmed(self):
+        assert DependencyReference.is_local_path("  ./my-pkg  ") is True
+
+    def test_empty_string_not_local(self):
+        assert DependencyReference.is_local_path("") is False
+
+
+# ===========================================================================
+# DependencyReference.parse() with local paths
+# ===========================================================================
+
+class TestParseLocalPath:
+    """Test parsing local filesystem paths into DependencyReference."""
+
+    def test_relative_path(self):
+        dep = DependencyReference.parse("./packages/my-skills")
+        assert dep.is_local is True
+        assert dep.local_path == "./packages/my-skills"
+        assert dep.repo_url == "_local/my-skills"
+
+    def test_relative_parent_path(self):
+        dep = DependencyReference.parse("../sibling-package")
+        assert dep.is_local is True
+        assert dep.local_path == "../sibling-package"
+        assert dep.repo_url == "_local/sibling-package"
+
+    def test_absolute_path(self):
+        dep = DependencyReference.parse("/home/user/repos/my-package")
+        assert dep.is_local is True
+        assert dep.local_path == "/home/user/repos/my-package"
+        assert dep.repo_url == "_local/my-package"
+
+    def test_home_path(self):
+        dep = DependencyReference.parse("~/repos/my-ai-pkg")
+        assert dep.is_local is True
+        assert dep.local_path == "~/repos/my-ai-pkg"
+        assert dep.repo_url == "_local/my-ai-pkg"
+
+    def test_deeply_nested_relative(self):
+        dep = DependencyReference.parse("./a/b/c/d/my-deep-pkg")
+        assert dep.is_local is True
+        assert dep.local_path == "./a/b/c/d/my-deep-pkg"
+        assert dep.repo_url == "_local/my-deep-pkg"
+
+    def test_no_reference_for_local(self):
+        """Local paths should not have reference, alias, or virtual_path."""
+        dep = DependencyReference.parse("./my-pkg")
+        assert dep.reference is None
+        assert dep.alias is None
+        assert dep.virtual_path is None
+        assert dep.is_virtual is False
+
+    def test_remote_dep_not_local(self):
+        """Regular remote deps should remain unaffected."""
+        dep = DependencyReference.parse("microsoft/apm-sample-package")
+        assert dep.is_local is False
+        assert dep.local_path is None
+
+    def test_bare_dot_dot_slash_rejected(self):
+        """Path '../' has name '..' which could escape _local/ — must be rejected."""
+        with pytest.raises(ValueError, match="does not resolve to a named directory"):
+            DependencyReference.parse("../")
+
+    def test_bare_dot_slash_rejected(self):
+        """Path './' has empty name — must be rejected."""
+        with pytest.raises(ValueError, match="does not resolve to a named directory"):
+            DependencyReference.parse("./")
+
+    def test_bare_root_rejected(self):
+        """Path '/' has empty name — must be rejected."""
+        with pytest.raises(ValueError, match="does not resolve to a named directory"):
+            DependencyReference.parse("/")
+
+    def test_dot_dot_without_slash_rejected(self):
+        """Path '..' is not detected as a local path (no trailing '/')."""
+        # '..' doesn't start with '../' so is_local_path returns False.
+        # It falls through to regular parsing which also rejects it.
+        with pytest.raises(ValueError):
+            DependencyReference.parse("..")
+
+
+# ===========================================================================
+# DependencyReference methods for local deps
+# ===========================================================================
+
+class TestLocalDepMethods:
+    """Test DependencyReference methods with local dependencies."""
+
+    def test_to_canonical_returns_local_path(self):
+        dep = DependencyReference.parse("./packages/my-skills")
+        assert dep.to_canonical() == "./packages/my-skills"
+
+    def test_get_identity_returns_local_path(self):
+        dep = DependencyReference.parse("./packages/my-skills")
+        assert dep.get_identity() == "./packages/my-skills"
+
+    def test_get_unique_key_returns_local_path(self):
+        dep = DependencyReference.parse("./packages/my-skills")
+        assert dep.get_unique_key() == "./packages/my-skills"
+
+    def test_get_install_path(self, tmp_path):
+        dep = DependencyReference.parse("./packages/my-skills")
+        install_path = dep.get_install_path(tmp_path / "apm_modules")
+        assert install_path == tmp_path / "apm_modules" / "_local" / "my-skills"
+
+    def test_get_display_name_returns_path(self):
+        dep = DependencyReference.parse("./packages/my-skills")
+        assert dep.get_display_name() == "./packages/my-skills"
+
+    def test_str_returns_path(self):
+        dep = DependencyReference.parse("./my-pkg")
+        assert str(dep) == "./my-pkg"
+
+    def test_install_path_no_conflict_with_remote(self, tmp_path):
+        """Local and remote packages with same name should not conflict."""
+        local_dep = DependencyReference.parse("./skills")
+        remote_dep = DependencyReference.parse("owner/skills")
+        apm_modules = tmp_path / "apm_modules"
+        assert local_dep.get_install_path(apm_modules) != remote_dep.get_install_path(apm_modules)
+
+
+# ===========================================================================
+# LockedDependency with local source
+# ===========================================================================
+
+class TestLockedDependencyLocal:
+    """Test LockedDependency serialization for local path dependencies."""
+
+    def test_from_dependency_ref_local(self):
+        dep_ref = DependencyReference.parse("./packages/my-skills")
+        locked = LockedDependency.from_dependency_ref(dep_ref, None, 1, None)
+        assert locked.source == "local"
+        assert locked.local_path == "./packages/my-skills"
+        assert locked.resolved_commit is None
+
+    def test_from_dependency_ref_remote(self):
+        dep_ref = DependencyReference.parse("owner/repo")
+        locked = LockedDependency.from_dependency_ref(dep_ref, "abc123", 1, None)
+        assert locked.source is None
+        assert locked.local_path is None
+        assert locked.resolved_commit == "abc123"
+
+    def test_to_dict_includes_source(self):
+        locked = LockedDependency(
+            repo_url="_local/my-skills",
+            source="local",
+            local_path="./packages/my-skills",
+        )
+        d = locked.to_dict()
+        assert d["source"] == "local"
+        assert d["local_path"] == "./packages/my-skills"
+
+    def test_to_dict_excludes_source_for_remote(self):
+        locked = LockedDependency(repo_url="owner/repo", resolved_commit="abc123")
+        d = locked.to_dict()
+        assert "source" not in d
+        assert "local_path" not in d
+
+    def test_from_dict_with_source(self):
+        data = {
+            "repo_url": "_local/my-skills",
+            "source": "local",
+            "local_path": "./packages/my-skills",
+        }
+        locked = LockedDependency.from_dict(data)
+        assert locked.source == "local"
+        assert locked.local_path == "./packages/my-skills"
+
+    def test_round_trip(self, tmp_path):
+        """Write and read back a lockfile with local dependencies."""
+        lock = LockFile()
+        lock.add_dependency(LockedDependency(
+            repo_url="_local/my-skills",
+            source="local",
+            local_path="./packages/my-skills",
+            deployed_files=[".github/instructions/my-skill.instructions.md"],
+        ))
+        lock.add_dependency(LockedDependency(
+            repo_url="owner/remote-pkg",
+            resolved_commit="abc123",
+            deployed_files=[".github/instructions/remote.instructions.md"],
+        ))
+
+        lock_path = tmp_path / "apm.lock"
+        lock.write(lock_path)
+        loaded = LockFile.read(lock_path)
+
+        assert loaded.has_dependency("./packages/my-skills")
+        assert loaded.has_dependency("owner/remote-pkg")
+
+        local_dep = loaded.get_dependency("./packages/my-skills")
+        assert local_dep.source == "local"
+        assert local_dep.local_path == "./packages/my-skills"
+
+        remote_dep = loaded.get_dependency("owner/remote-pkg")
+        assert remote_dep.source is None
+        assert remote_dep.resolved_commit == "abc123"
+
+    def test_get_unique_key_local(self):
+        locked = LockedDependency(
+            repo_url="_local/my-skills",
+            source="local",
+            local_path="./packages/my-skills",
+        )
+        assert locked.get_unique_key() == "./packages/my-skills"
+
+    def test_get_unique_key_remote(self):
+        locked = LockedDependency(repo_url="owner/repo")
+        assert locked.get_unique_key() == "owner/repo"
+
+
+# ===========================================================================
+# APMPackage.from_apm_yml with local deps
+# ===========================================================================
+
+class TestAPMPackageLocalDeps:
+    """Test APMPackage loading with local path dependencies in apm.yml."""
+
+    def test_apm_yml_with_local_string_dep(self, tmp_path):
+        apm_yml = tmp_path / "apm.yml"
+        apm_yml.write_text(yaml.dump({
+            "name": "test-project",
+            "version": "1.0.0",
+            "dependencies": {
+                "apm": ["./packages/my-skills"],
+            },
+        }))
+        pkg = APMPackage.from_apm_yml(apm_yml)
+        deps = pkg.get_apm_dependencies()
+        assert len(deps) == 1
+        assert deps[0].is_local is True
+        assert deps[0].local_path == "./packages/my-skills"
+
+    def test_apm_yml_with_local_dict_dep(self, tmp_path):
+        apm_yml = tmp_path / "apm.yml"
+        apm_yml.write_text(yaml.dump({
+            "name": "test-project",
+            "version": "1.0.0",
+            "dependencies": {
+                "apm": [{"path": "./packages/my-skills"}],
+            },
+        }))
+        pkg = APMPackage.from_apm_yml(apm_yml)
+        deps = pkg.get_apm_dependencies()
+        assert len(deps) == 1
+        assert deps[0].is_local is True
+        assert deps[0].local_path == "./packages/my-skills"
+
+    def test_mixed_local_and_remote_deps(self, tmp_path):
+        apm_yml = tmp_path / "apm.yml"
+        apm_yml.write_text(yaml.dump({
+            "name": "test-project",
+            "version": "1.0.0",
+            "dependencies": {
+                "apm": [
+                    "microsoft/apm-sample-package",
+                    "./packages/my-local-skills",
+                    "/absolute/path/to/pkg",
+                ],
+            },
+        }))
+        pkg = APMPackage.from_apm_yml(apm_yml)
+        deps = pkg.get_apm_dependencies()
+        assert len(deps) == 3
+        assert deps[0].is_local is False
+        assert deps[1].is_local is True
+        assert deps[1].local_path == "./packages/my-local-skills"
+        assert deps[2].is_local is True
+        assert deps[2].local_path == "/absolute/path/to/pkg"
+
+    def test_invalid_dict_path_rejected(self, tmp_path):
+        """Dict-form paths that don't look like filesystem paths should be rejected."""
+        apm_yml = tmp_path / "apm.yml"
+        apm_yml.write_text(yaml.dump({
+            "name": "test-project",
+            "version": "1.0.0",
+            "dependencies": {
+                "apm": [{"path": "not-a-local-path"}],
+            },
+        }))
+        with pytest.raises(ValueError, match="local filesystem path"):
+            APMPackage.from_apm_yml(apm_yml)
+
+
+# ===========================================================================
+# Pack guard: reject local deps
+# ===========================================================================
+
+class TestPackGuardLocalDeps:
+    """Test that packing rejects packages with local dependencies."""
+
+    def test_pack_rejects_local_deps(self, tmp_path):
+        from apm_cli.bundle.packer import pack_bundle
+
+        # Set up project with local dep
+        apm_yml = tmp_path / "apm.yml"
+        apm_yml.write_text(yaml.dump({
+            "name": "test-project",
+            "version": "1.0.0",
+            "dependencies": {
+                "apm": ["./packages/my-local-pkg"],
+            },
+        }))
+
+        # Create a lockfile so pack_bundle doesn't fail on missing lockfile
+        lock = LockFile()
+        lock.add_dependency(LockedDependency(
+            repo_url="_local/my-local-pkg",
+            source="local",
+            local_path="./packages/my-local-pkg",
+        ))
+        lock.write(tmp_path / "apm.lock")
+
+        with pytest.raises(ValueError, match="local path dependency"):
+            pack_bundle(tmp_path, tmp_path / "dist")
+
+    def test_pack_allows_remote_deps(self, tmp_path):
+        from apm_cli.bundle.packer import pack_bundle
+
+        # Set up project with only remote dep
+        apm_yml = tmp_path / "apm.yml"
+        apm_yml.write_text(yaml.dump({
+            "name": "test-project",
+            "version": "1.0.0",
+            "dependencies": {
+                "apm": ["owner/repo"],
+            },
+        }))
+
+        # Create an empty lockfile
+        lock = LockFile()
+        lock.write(tmp_path / "apm.lock")
+
+        # Should not raise (may fail for other reasons like missing files, that's OK)
+        # We just check it gets past the guard
+        try:
+            pack_bundle(tmp_path, tmp_path / "dist")
+        except ValueError as e:
+            assert "local path dependency" not in str(e)
+
+
+# ===========================================================================
+# Copy local package helper
+# ===========================================================================
+
+class TestCopyLocalPackage:
+    """Test the _copy_local_package helper from the install module."""
+
+    def test_copy_local_package_with_apm_yml(self, tmp_path):
+        from apm_cli.commands.install import _copy_local_package
+
+        # Create a local package
+        local_pkg = tmp_path / "my-local-pkg"
+        local_pkg.mkdir()
+        (local_pkg / "apm.yml").write_text(yaml.dump({
+            "name": "my-local-pkg",
+            "version": "1.0.0",
+        }))
+        instr_dir = local_pkg / ".apm" / "instructions"
+        instr_dir.mkdir(parents=True)
+        (instr_dir / "test.instructions.md").write_text("# Test")
+
+        # Create dep ref and install path
+        dep_ref = DependencyReference.parse(f"./{local_pkg.name}")
+        dep_ref.local_path = str(local_pkg)  # Use absolute path for test
+        install_path = tmp_path / "apm_modules" / "_local" / "my-local-pkg"
+
+        result = _copy_local_package(dep_ref, install_path, tmp_path)
+        assert result is not None
+        assert result.exists()
+        assert (result / "apm.yml").exists()
+        assert (result / ".apm" / "instructions" / "test.instructions.md").exists()
+
+    def test_copy_local_package_with_skill_md(self, tmp_path):
+        from apm_cli.commands.install import _copy_local_package
+
+        # Create a Claude Skill package (SKILL.md but no apm.yml)
+        local_pkg = tmp_path / "my-skill"
+        local_pkg.mkdir()
+        (local_pkg / "SKILL.md").write_text("# My Skill")
+
+        dep_ref = DependencyReference.parse(f"./{local_pkg.name}")
+        dep_ref.local_path = str(local_pkg)
+        install_path = tmp_path / "apm_modules" / "_local" / "my-skill"
+
+        result = _copy_local_package(dep_ref, install_path, tmp_path)
+        assert result is not None
+        assert (result / "SKILL.md").exists()
+
+    def test_copy_local_package_missing_path(self, tmp_path):
+        from apm_cli.commands.install import _copy_local_package
+
+        dep_ref = DependencyReference.parse("./nonexistent-pkg")
+        install_path = tmp_path / "apm_modules" / "_local" / "nonexistent-pkg"
+
+        result = _copy_local_package(dep_ref, install_path, tmp_path)
+        assert result is None
+
+    def test_copy_local_package_no_manifest(self, tmp_path):
+        from apm_cli.commands.install import _copy_local_package
+
+        # Create a directory without apm.yml or SKILL.md
+        local_pkg = tmp_path / "no-manifest"
+        local_pkg.mkdir()
+        (local_pkg / "README.md").write_text("# No manifest")
+
+        dep_ref = DependencyReference.parse(f"./{local_pkg.name}")
+        dep_ref.local_path = str(local_pkg)
+        install_path = tmp_path / "apm_modules" / "_local" / "no-manifest"
+
+        result = _copy_local_package(dep_ref, install_path, tmp_path)
+        assert result is None
+
+    def test_copy_replaces_existing(self, tmp_path):
+        from apm_cli.commands.install import _copy_local_package
+
+        # Create a local package
+        local_pkg = tmp_path / "my-pkg"
+        local_pkg.mkdir()
+        (local_pkg / "apm.yml").write_text(yaml.dump({
+            "name": "my-pkg",
+            "version": "1.0.0",
+        }))
+        (local_pkg / "data.txt").write_text("original")
+
+        dep_ref = DependencyReference.parse(f"./{local_pkg.name}")
+        dep_ref.local_path = str(local_pkg)
+        install_path = tmp_path / "apm_modules" / "_local" / "my-pkg"
+
+        # First copy
+        _copy_local_package(dep_ref, install_path, tmp_path)
+        assert (install_path / "data.txt").read_text() == "original"
+
+        # Modify source
+        (local_pkg / "data.txt").write_text("updated")
+
+        # Second copy should overwrite
+        _copy_local_package(dep_ref, install_path, tmp_path)
+        assert (install_path / "data.txt").read_text() == "updated"
+
+    def test_copy_preserves_symlinks_without_following(self, tmp_path):
+        """Symlinks in local packages should be preserved, not followed."""
+        from apm_cli.commands.install import _copy_local_package
+
+        # Create a secret file outside the package
+        secret_dir = tmp_path / "secret"
+        secret_dir.mkdir()
+        (secret_dir / "credentials.txt").write_text("TOP_SECRET")
+
+        # Create a local package with a symlink pointing outside
+        local_pkg = tmp_path / "evil-pkg"
+        local_pkg.mkdir()
+        (local_pkg / "apm.yml").write_text(yaml.dump({
+            "name": "evil-pkg",
+            "version": "1.0.0",
+        }))
+        (local_pkg / "escape").symlink_to(secret_dir)
+
+        dep_ref = DependencyReference.parse(f"./{local_pkg.name}")
+        dep_ref.local_path = str(local_pkg)
+        install_path = tmp_path / "apm_modules" / "_local" / "evil-pkg"
+
+        result = _copy_local_package(dep_ref, install_path, tmp_path)
+        assert result is not None
+
+        # The symlink should be preserved as a symlink, NOT followed
+        link = install_path / "escape"
+        assert link.is_symlink(), "Symlink was followed instead of preserved"
diff --git a/uv.lock b/uv.lock
index b5f5e0b49..821fdafab 100644
--- a/uv.lock
+++ b/uv.lock
@@ -211,7 +211,7 @@ dev = [
 
 [package.metadata]
 requires-dist = [
-    { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
+    { name = "black", marker = "python_full_version >= '3.10' and extra == 'dev'", specifier = ">=26.3.1" },
     { name = "click", specifier = ">=8.0.0" },
     { name = "colorama", specifier = ">=0.4.6" },
     { name = "gitpython", specifier = ">=3.1.0" },
