diff --git a/docs/src/content/docs/guides/dependencies.md b/docs/src/content/docs/guides/dependencies.md index 629778db..54e859c7 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//` +- 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 00d5b4db..4efa9561 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 fd17faea..4cc7fbfd 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 e5790ad0..835f25f9 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/` 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 c5c9739c..8f2235ad 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 12c9748f..966b5eaa 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 a078df52..24956bd7 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 e171ed1a..6b726572 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 2fa57e93..cda16fb1 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 87f0f7f6..48b4689a 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// + 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 00000000..fc583970 --- /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 4e9228ae..82749daf 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 d49c729e..5e32d798 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 3a4eaee5..75f7848a 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 00000000..64491adb --- /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 b5f5e0b4..821fdafa 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" },