diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7f4d2c..914ef943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `apm install --target` flag to force deployment to a specific target (copilot, claude, cursor, opencode, all) (#456) - +- Global `apm install --global` / `-g` and `apm uninstall --global` flags for user-scope package installation, backed by `InstallScope`-based scope resolution in `core/scope.py`; deploys primitives to `~/.github/`, `~/.claude/`, etc. and tracks metadata under `~/.apm/` (#440) ## [0.8.5] - 2026-03-24 ### Added diff --git a/docs/src/content/docs/guides/scoped-installation.md b/docs/src/content/docs/guides/scoped-installation.md new file mode 100644 index 00000000..dd8cbcfe --- /dev/null +++ b/docs/src/content/docs/guides/scoped-installation.md @@ -0,0 +1,63 @@ +--- +title: Scoped Installation +sidebar: + order: 11 +--- + +APM supports two installation scopes: **project** (default) and **user** (global). + +## Project scope (default) + +Packages install into the current directory: + +```bash +apm install microsoft/apm-sample-package +``` + +- Manifest: `./apm.yml` +- Modules: `./apm_modules/` +- Lockfile: `./apm.lock.yaml` +- Deployed primitives: `./.github/`, `./.claude/`, `./.cursor/`, `./.opencode/` + +This is the standard behavior. Every collaborator who clones the repo gets the same setup. + +## User scope (`--global`) + +Packages install to your home directory, making them available across all projects: + +```bash +apm install -g microsoft/apm-sample-package +``` + +- Manifest: `~/.apm/apm.yml` +- Modules: `~/.apm/apm_modules/` +- Lockfile: `~/.apm/apm.lock.yaml` + +### Per-target support + +Currently, only **Claude Code** fully supports user-scope primitives. **Copilot CLI** and **VS Code** are partially supported -- the tools read from user-level directories, but APM's current integrators have limitations. APM deploys primitives relative to the home directory, but some AI tools either read from a different user-level path than what APM produces, or only support workspace-level configuration. + +APM warns during `--global` installs about targets that lack native user-level support. + +| Target | User-level directory | Status | Why | Reference | +|--------|---------------------|--------|-----|-----------| +| Claude Code | `~/.claude/` | Supported | APM deploys to `~/.claude/` which Claude reads for user-level commands, agents, skills, hooks | [Claude Code settings](https://docs.anthropic.com/en/docs/claude-code/settings) | +| Copilot (CLI) | `~/.copilot/` | Partially supported | Copilot CLI reads agents, skills, instructions from `~/.copilot/`; Copilot CLI does not support prompts | [Agents](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-custom-agents-for-cli), [Skills](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-skills), [Instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-custom-instructions) | +| VS Code | User `mcp.json` | Partially supported | VS Code reads user-level MCP servers from user `mcp.json`; APM currently only writes to workspace `.vscode/mcp.json` | [VS Code MCP servers](https://code.visualstudio.com/docs/copilot/customization/mcp-servers) | +| Cursor | `~/.cursor/` | Not supported | User rules are managed via Cursor Settings UI, not the filesystem | [Cursor rules docs](https://cursor.com/docs/rules) | +| OpenCode | `~/.opencode/` | Not supported | No official documentation for user-level config | No official docs available | + +### Uninstalling user-scope packages + +```bash +apm uninstall -g microsoft/apm-sample-package +``` + +## When to use each scope + +| Use case | Scope | +|----------|-------| +| Team-shared instructions and prompts | Project (`apm install`) | +| Personal Claude Code commands and agents | User (`apm install -g`) | +| CI/CD reproducible setup | Project | +| Cross-project coding standards (Claude Code) | User | diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index 7a695e20..928dde1e 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -95,6 +95,7 @@ apm install [PACKAGES...] [OPTIONS] - `--verbose` - Show individual file paths and full error details in the diagnostic summary - `--trust-transitive-mcp` - Trust self-defined MCP servers from transitive packages (skip re-declaration requirement) - `--dev` - Add packages to [`devDependencies`](../manifest-schema/#5-devdependencies) instead of `dependencies`. Dev deps are installed locally but excluded from `apm pack --format plugin` bundles +- `-g, --global` - Install to user scope (`~/.apm/`) instead of the current project. Primitives deploy to `~/.github/`, `~/.claude/`, etc. **Behavior:** - `apm install` (no args): Installs **all** packages from `apm.yml` @@ -151,6 +152,9 @@ apm install --dev owner/test-helpers # Install from a local path (copies to apm_modules/_local/) apm install ./packages/my-shared-skills apm install /home/user/repos/my-ai-package + +# Install to user scope (available across all projects) +apm install -g microsoft/apm-sample-package ``` **Auto-Bootstrap Behavior:** @@ -271,6 +275,7 @@ apm uninstall [OPTIONS] PACKAGES... **Options:** - `--dry-run` - Show what would be removed without removing +- `-g, --global` - Remove from user scope (`~/.apm/`) instead of the current project **Examples:** ```bash @@ -282,6 +287,9 @@ apm uninstall https://github.com/microsoft/apm-sample-package.git # Preview what would be removed apm uninstall microsoft/apm-sample-package --dry-run + +# Uninstall from user scope +apm uninstall -g microsoft/apm-sample-package ``` **What Gets Removed:** diff --git a/src/apm_cli/commands/_helpers.py b/src/apm_cli/commands/_helpers.py index 284256e7..015f6981 100644 --- a/src/apm_cli/commands/_helpers.py +++ b/src/apm_cli/commands/_helpers.py @@ -432,12 +432,13 @@ def _create_plugin_json(config): f.write(json.dumps(plugin_data, indent=2) + "\n") -def _create_minimal_apm_yml(config, plugin=False): +def _create_minimal_apm_yml(config, plugin=False, target_path=None): """Create minimal apm.yml file with auto-detected metadata. Args: config: dict with name, version, description, author keys. plugin: if True, include a devDependencies section. + target_path: explicit file path to write (defaults to cwd/apm.yml). """ # Create minimal apm.yml structure apm_yml_data = { @@ -455,4 +456,5 @@ def _create_minimal_apm_yml(config, plugin=False): # Write apm.yml from ..utils.yaml_io import dump_yaml - dump_yaml(apm_yml_data, APM_YML_FILENAME) + out_path = target_path or APM_YML_FILENAME + dump_yaml(apm_yml_data, out_path) diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index 628ad59f..6efe5bc8 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -56,7 +56,7 @@ # --------------------------------------------------------------------------- -def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, logger=None): +def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, logger=None, manifest_path=None): """Validate packages exist and can be accessed, then add to apm.yml dependencies section. Implements normalize-on-write: any input form (HTTPS URL, SSH URL, FQDN, shorthand) @@ -68,6 +68,7 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo dry_run: If True, only show what would be added. dev: If True, write to devDependencies instead of dependencies. logger: InstallLogger for structured output. + manifest_path: Explicit path to apm.yml (defaults to cwd/apm.yml). Returns: Tuple of (validated_packages list, _ValidationOutcome). @@ -76,7 +77,7 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo import tempfile from pathlib import Path - apm_yml_path = Path(APM_YML_FILENAME) + apm_yml_path = manifest_path or Path(APM_YML_FILENAME) # Read current apm.yml try: @@ -524,8 +525,14 @@ def _check_repo_fallback(token, git_env): default=None, help="Force deployment to a specific target (overrides auto-detection)", ) +@click.option( + "--global", "-g", "global_", + is_flag=True, + default=False, + help="Install to user scope (~/.apm/) instead of the current project", +) @click.pass_context -def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbose, trust_transitive_mcp, parallel_downloads, dev, target): +def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbose, trust_transitive_mcp, parallel_downloads, dev, target, global_): """Install APM and MCP dependencies from apm.yml (like npm install). This command automatically detects AI runtimes from your apm.yml scripts and installs @@ -544,34 +551,59 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo apm install --only=mcp # Install only MCP dependencies apm install --update # Update dependencies to latest Git refs apm install --dry-run # Show what would be installed + apm install -g org/pkg1 # Install to user scope (~/.apm/) """ try: - # Create structured logger for install output + # Create structured logger for install output early so exception + # handlers can always reference it (avoids UnboundLocalError if + # scope initialisation below throws). is_partial = bool(packages) logger = InstallLogger(verbose=verbose, dry_run=dry_run, partial=is_partial) + # Resolve scope + from ..core.scope import InstallScope, get_deploy_root, get_apm_dir, get_manifest_path, get_modules_dir, ensure_user_dirs, warn_unsupported_user_scope + scope = InstallScope.USER if global_ else InstallScope.PROJECT + + if scope is InstallScope.USER: + ensure_user_dirs() + _rich_info("[i] Installing to user scope (~/.apm/)") + _scope_warn = warn_unsupported_user_scope() + if _scope_warn: + from ..utils.console import _rich_warning + _rich_warning(_scope_warn) + + # Scope-aware paths + manifest_path = get_manifest_path(scope) + apm_dir = get_apm_dir(scope) + # Display name for messages (short for project scope, full for user scope) + manifest_display = str(manifest_path) if scope is InstallScope.USER else APM_YML_FILENAME + # Check if apm.yml exists - apm_yml_exists = Path(APM_YML_FILENAME).exists() + apm_yml_exists = manifest_path.exists() # Auto-bootstrap: create minimal apm.yml when packages specified but no apm.yml if not apm_yml_exists and packages: # Get current directory name as project name - project_name = Path.cwd().name + project_name = Path.cwd().name if scope is InstallScope.PROJECT else Path.home().name config = _get_default_config(project_name) - _create_minimal_apm_yml(config) - logger.success(f"Created {APM_YML_FILENAME}") + _create_minimal_apm_yml(config, target_path=manifest_path) + logger.success(f"Created {manifest_display}") # Error when NO apm.yml AND NO packages if not apm_yml_exists and not packages: - logger.error(f"No {APM_YML_FILENAME} found") - logger.progress("Run 'apm init' to create one, or:") - logger.progress(" apm install to auto-create + install") + logger.error(f"No {manifest_display} found") + if scope is InstallScope.USER: + logger.progress("Run 'apm install -g ' to auto-create + install") + else: + logger.progress("Run 'apm init' to create one, or:") + logger.progress(" apm install to auto-create + install") sys.exit(1) # If packages are specified, validate and add them to apm.yml first if packages: validated_packages, outcome = _validate_and_add_packages_to_apm_yml( packages, dry_run, dev=dev, logger=logger, + manifest_path=manifest_path, ) # Short-circuit: all packages failed validation — nothing to install if outcome.all_failed: @@ -586,9 +618,9 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo # Parse apm.yml to get both APM and MCP dependencies try: - apm_package = APMPackage.from_apm_yml(Path(APM_YML_FILENAME)) + apm_package = APMPackage.from_apm_yml(manifest_path) except Exception as e: - logger.error(f"Failed to parse {APM_YML_FILENAME}: {e}") + logger.error(f"Failed to parse {manifest_display}: {e}") sys.exit(1) logger.verbose_detail( @@ -643,7 +675,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo agent_count = 0 # Migrate legacy apm.lock → apm.lock.yaml if needed (one-time, transparent) - migrate_lockfile_if_needed(Path.cwd()) + migrate_lockfile_if_needed(apm_dir) # Capture old MCP servers and configs from lockfile BEFORE # _install_apm_dependencies regenerates it (which drops the fields). @@ -651,7 +683,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo # field after the lockfile is regenerated by the APM install step. old_mcp_servers: builtins.set = builtins.set() old_mcp_configs: builtins.dict = {} - _lock_path = get_lockfile_path(Path.cwd()) + _lock_path = get_lockfile_path(apm_dir) _existing_lock = LockFile.read(_lock_path) if _existing_lock: old_mcp_servers = builtins.set(_existing_lock.mcp_servers) @@ -672,6 +704,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo apm_package, update, verbose, only_pkgs, force=force, parallel_downloads=parallel_downloads, logger=logger, + scope=scope, target=target, ) apm_count = install_result.installed_count @@ -693,9 +726,9 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo clear_apm_yml_cache() # Collect transitive MCP dependencies from resolved APM packages - apm_modules_path = Path.cwd() / APM_MODULES_DIR + apm_modules_path = get_modules_dir(scope) if should_install_mcp and apm_modules_path.exists(): - lock_path = get_lockfile_path(Path.cwd()) + lock_path = get_lockfile_path(apm_dir) transitive_mcp = MCPIntegrator.collect_transitive( apm_modules_path, lock_path, trust_transitive_mcp, diagnostics=apm_diagnostics, @@ -1092,6 +1125,7 @@ def _install_apm_dependencies( force: bool = False, parallel_downloads: int = 4, logger: "InstallLogger" = None, + scope=None, target: str = None, ): """Install APM package dependencies. @@ -1104,22 +1138,28 @@ def _install_apm_dependencies( force: Whether to overwrite locally-authored files on collision parallel_downloads: Max concurrent downloads (0 disables parallelism) logger: InstallLogger for structured output + scope: InstallScope controlling project vs user deployment target: Explicit target override from --target CLI flag """ if not APM_DEPS_AVAILABLE: raise RuntimeError("APM dependency system not available") + from apm_cli.core.scope import InstallScope, get_deploy_root, get_apm_dir, get_modules_dir + if scope is None: + scope = InstallScope.PROJECT + apm_deps = apm_package.get_apm_dependencies() dev_apm_deps = apm_package.get_dev_apm_dependencies() all_apm_deps = apm_deps + dev_apm_deps if not all_apm_deps: return InstallResult() - project_root = Path.cwd() + project_root = get_deploy_root(scope) + apm_dir = get_apm_dir(scope) # T5: Check for existing lockfile - use locked versions for reproducible installs from apm_cli.deps.lockfile import LockFile, get_lockfile_path - lockfile_path = get_lockfile_path(project_root) + lockfile_path = get_lockfile_path(apm_dir) existing_lockfile = None lockfile_count = 0 if lockfile_path.exists() and not update_refs: @@ -1134,8 +1174,8 @@ def _install_apm_dependencies( _ref = locked_dep.resolved_ref if hasattr(locked_dep, 'resolved_ref') and locked_dep.resolved_ref else "" logger.lockfile_entry(locked_dep.get_unique_key(), ref=_ref, sha=_sha) - apm_modules_dir = project_root / APM_MODULES_DIR - apm_modules_dir.mkdir(exist_ok=True) + apm_modules_dir = get_modules_dir(scope) + apm_modules_dir.mkdir(parents=True, exist_ok=True) # Create downloader early so it can be used for transitive dependency resolution downloader = GitHubPackageDownloader() @@ -1391,7 +1431,7 @@ def _collect_descendants(node, visited=None): # Build managed_files from existing lockfile for collision detection managed_files = builtins.set() - existing_lockfile = LockFile.read(get_lockfile_path(project_root)) if project_root else None + existing_lockfile = LockFile.read(get_lockfile_path(apm_dir)) if apm_dir else None if existing_lockfile: for dep in existing_lockfile.dependencies.values(): managed_files.update(dep.deployed_files) @@ -2124,7 +2164,7 @@ def _collect_descendants(node, visited=None): # else: orphan — package was in lockfile but is no longer in # the manifest (full install only). Don't preserve so the # lockfile stays in sync with what apm.yml declares. - lockfile_path = get_lockfile_path(project_root) + lockfile_path = get_lockfile_path(apm_dir) # When installing a subset of packages (apm install ), # merge new entries into the existing lockfile instead of diff --git a/src/apm_cli/commands/uninstall/cli.py b/src/apm_cli/commands/uninstall/cli.py index b85b5d01..bf28848d 100644 --- a/src/apm_cli/commands/uninstall/cli.py +++ b/src/apm_cli/commands/uninstall/cli.py @@ -28,8 +28,14 @@ "--dry-run", is_flag=True, help="Show what would be removed without removing" ) @click.option("--verbose", "-v", is_flag=True, help="Show detailed removal information") +@click.option( + "--global", "-g", "global_", + is_flag=True, + default=False, + help="Remove from user scope (~/.apm/) instead of the current project", +) @click.pass_context -def uninstall(ctx, packages, dry_run, verbose): +def uninstall(ctx, packages, dry_run, verbose, global_): """Remove APM packages from apm.yml and apm_modules (like npm uninstall). This command removes packages from both the apm.yml dependencies list @@ -39,28 +45,46 @@ def uninstall(ctx, packages, dry_run, verbose): apm uninstall acme/my-package # Remove one package apm uninstall org/pkg1 org/pkg2 # Remove multiple packages apm uninstall acme/my-package --dry-run # Show what would be removed + apm uninstall -g acme/my-package # Remove from user scope """ + from ...core.scope import InstallScope, get_deploy_root, get_apm_dir, get_modules_dir, get_manifest_path + scope = InstallScope.USER if global_ else InstallScope.PROJECT + + manifest_path = get_manifest_path(scope) + apm_dir = get_apm_dir(scope) + deploy_root = get_deploy_root(scope) + logger = CommandLogger("uninstall", verbose=verbose, dry_run=dry_run) try: # Check if apm.yml exists - if not Path(APM_YML_FILENAME).exists(): - logger.error(f"No {APM_YML_FILENAME} found. Run 'apm init' first.") + if not manifest_path.exists(): + if scope is InstallScope.USER: + logger.error( + f"No user manifest found at {manifest_path}. Install a package globally " + "first with 'apm install -g ' or create the file manually." + ) + else: + logger.error(f"No {manifest_path} found. Run 'apm init' in this project first.") sys.exit(1) if not packages: logger.error("No packages specified. Specify packages to uninstall.") sys.exit(1) + if scope is InstallScope.USER: + from ...utils.console import _rich_info + _rich_info("[i] Uninstalling from user scope (~/.apm/)") + logger.start(f"Uninstalling {len(packages)} package(s)...") # Read current apm.yml from ...utils.yaml_io import load_yaml, dump_yaml - apm_yml_path = Path(APM_YML_FILENAME) + apm_yml_path = manifest_path try: data = load_yaml(apm_yml_path) or {} except Exception as e: - logger.error(f"Failed to read {APM_YML_FILENAME}: {e}") + logger.error(f"Failed to read {apm_yml_path}: {e}") sys.exit(1) if "dependencies" not in data: @@ -77,8 +101,9 @@ def uninstall(ctx, packages, dry_run, verbose): return # Step 2: Dry run + modules_dir = get_modules_dir(scope) if dry_run: - _dry_run_uninstall(packages_to_remove, Path(APM_MODULES_DIR), logger) + _dry_run_uninstall(packages_to_remove, modules_dir, logger) return # Step 3: Remove from apm.yml @@ -88,24 +113,23 @@ def uninstall(ctx, packages, dry_run, verbose): data["dependencies"]["apm"] = current_deps try: dump_yaml(data, apm_yml_path) - logger.success(f"Updated {APM_YML_FILENAME} (removed {len(packages_to_remove)} package(s))") + logger.success(f"Updated {apm_yml_path} (removed {len(packages_to_remove)} package(s))") except Exception as e: - logger.error(f"Failed to write {APM_YML_FILENAME}: {e}") + logger.error(f"Failed to write {apm_yml_path}: {e}") sys.exit(1) # Step 4: Load lockfile and capture pre-uninstall MCP state - apm_modules_dir = Path(APM_MODULES_DIR) from ...deps.lockfile import LockFile, get_lockfile_path - lockfile_path = get_lockfile_path(Path(".")) + lockfile_path = get_lockfile_path(apm_dir) lockfile = LockFile.read(lockfile_path) _pre_uninstall_mcp_servers = builtins.set(lockfile.mcp_servers) if lockfile else builtins.set() # Step 5: Remove packages from disk - removed_from_modules = _remove_packages_from_disk(packages_to_remove, apm_modules_dir, logger) + removed_from_modules = _remove_packages_from_disk(packages_to_remove, modules_dir, logger) # Step 6: Cleanup transitive orphans orphan_removed, actual_orphans = _cleanup_transitive_orphans( - lockfile, packages_to_remove, apm_modules_dir, apm_yml_path, logger + lockfile, packages_to_remove, modules_dir, apm_yml_path, logger ) removed_from_modules += orphan_removed @@ -154,9 +178,8 @@ def uninstall(ctx, packages, dry_run, verbose): # Step 9: Sync integrations cleaned = {"prompts": 0, "agents": 0, "skills": 0, "commands": 0, "hooks": 0, "instructions": 0} try: - apm_package = APMPackage.from_apm_yml(Path(APM_YML_FILENAME)) - project_root = Path(".") - cleaned = _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_files, logger) + apm_package = APMPackage.from_apm_yml(manifest_path) + cleaned = _sync_integrations_after_uninstall(apm_package, deploy_root, all_deployed_files, logger) except Exception: pass # Best effort cleanup @@ -167,8 +190,9 @@ def uninstall(ctx, packages, dry_run, verbose): # Step 10: MCP cleanup try: - apm_package = APMPackage.from_apm_yml(Path(APM_YML_FILENAME)) - _cleanup_stale_mcp(apm_package, lockfile, lockfile_path, _pre_uninstall_mcp_servers) + apm_package = APMPackage.from_apm_yml(manifest_path) + _cleanup_stale_mcp(apm_package, lockfile, lockfile_path, _pre_uninstall_mcp_servers, + modules_dir=get_modules_dir(scope)) except Exception: logger.warning("MCP cleanup during uninstall failed") diff --git a/src/apm_cli/commands/uninstall/engine.py b/src/apm_cli/commands/uninstall/engine.py index ed20129b..c41216b3 100644 --- a/src/apm_cli/commands/uninstall/engine.py +++ b/src/apm_cli/commands/uninstall/engine.py @@ -244,49 +244,55 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f counts = {"prompts": 0, "agents": 0, "skills": 0, "commands": 0, "hooks": 0, "instructions": 0} # Phase 1: Remove all APM-deployed files - if Path(".github/prompts").exists(): + # Use project_root-relative paths so user-scope (deploy_root=~) + # deployments are correctly detected and cleaned. + if (project_root / ".github/prompts").exists(): integrator = PromptIntegrator() result = integrator.sync_integration(apm_package, project_root, managed_files=_buckets["prompts"] if _buckets else None) counts["prompts"] = result.get("files_removed", 0) - if Path(".github/agents").exists(): + if (project_root / ".github/agents").exists(): integrator = AgentIntegrator() result = integrator.sync_integration(apm_package, project_root, managed_files=_buckets["agents_github"] if _buckets else None) counts["agents"] = result.get("files_removed", 0) - if Path(".claude/agents").exists(): + if (project_root / ".claude/agents").exists(): integrator = AgentIntegrator() result = integrator.sync_integration_claude(apm_package, project_root, managed_files=_buckets["agents_claude"] if _buckets else None) counts["agents"] += result.get("files_removed", 0) - if Path(".cursor/agents").exists(): + if (project_root / ".cursor/agents").exists(): integrator = AgentIntegrator() result = integrator.sync_integration_cursor(apm_package, project_root, managed_files=_buckets["agents_cursor"] if _buckets else None) counts["agents"] += result.get("files_removed", 0) - if Path(".opencode/agents").exists(): + if (project_root / ".opencode/agents").exists(): integrator = AgentIntegrator() result = integrator.sync_integration_opencode(apm_package, project_root, managed_files=_buckets["agents_opencode"] if _buckets else None) counts["agents"] += result.get("files_removed", 0) - if Path(".github/skills").exists() or Path(".claude/skills").exists() or Path(".cursor/skills").exists() or Path(".opencode/skills").exists(): + has_skills = any( + (project_root / d / "skills").exists() + for d in (".github", ".claude", ".cursor", ".opencode") + ) + if has_skills: integrator = SkillIntegrator() result = integrator.sync_integration(apm_package, project_root, managed_files=_buckets["skills"] if _buckets else None) counts["skills"] = result.get("files_removed", 0) - if Path(".claude/commands").exists(): + if (project_root / ".claude/commands").exists(): integrator = CommandIntegrator() result = integrator.sync_integration(apm_package, project_root, managed_files=_buckets["commands"] if _buckets else None) counts["commands"] = result.get("files_removed", 0) - if Path(".opencode/commands").exists(): + if (project_root / ".opencode/commands").exists(): integrator = CommandIntegrator() result = integrator.sync_integration_opencode(apm_package, project_root, managed_files=_buckets["commands_opencode"] if _buckets else None) @@ -297,14 +303,14 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f managed_files=_buckets["hooks"] if _buckets else None) counts["hooks"] = result.get("files_removed", 0) - if Path(".github/instructions").exists(): + if (project_root / ".github/instructions").exists(): integrator = InstructionIntegrator() result = integrator.sync_integration(apm_package, project_root, managed_files=_buckets["instructions"] if _buckets else None) counts["instructions"] = result.get("files_removed", 0) # Clean Cursor rules (.cursor/rules/) - if Path(".cursor/rules").exists(): + if (project_root / ".cursor/rules").exists(): integrator = InstructionIntegrator() result = integrator.sync_integration_cursor(apm_package, project_root, managed_files=_buckets["rules_cursor"] if _buckets else None) @@ -367,11 +373,11 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f return counts -def _cleanup_stale_mcp(apm_package, lockfile, lockfile_path, old_mcp_servers): +def _cleanup_stale_mcp(apm_package, lockfile, lockfile_path, old_mcp_servers, modules_dir=None): """Remove MCP servers that are no longer needed after uninstall.""" if not old_mcp_servers: return - apm_modules_path = Path.cwd() / APM_MODULES_DIR + apm_modules_path = modules_dir if modules_dir is not None else Path.cwd() / APM_MODULES_DIR remaining_mcp = MCPIntegrator.collect_transitive(apm_modules_path, lockfile_path, trust_private=True) try: remaining_root_mcp = apm_package.get_mcp_dependencies() diff --git a/src/apm_cli/core/scope.py b/src/apm_cli/core/scope.py new file mode 100644 index 00000000..694eed60 --- /dev/null +++ b/src/apm_cli/core/scope.py @@ -0,0 +1,259 @@ +"""Installation scope resolution for APM packages. + +Defines where packages are deployed based on scope: + +- **project** (default): Deploy to the current working directory. + Manifest, lockfile, and modules live at the project root. +- **user**: Deploy to user-level directories (``~/.claude/``, etc.). + Manifest, lockfile, and modules live under ``~/.apm/``. + +User-scope support varies by target: + +- **Claude Code** (fully supported): reads ``~/.claude/`` for global + commands, agents, skills, and ``CLAUDE.md``. + Ref: https://docs.anthropic.com/en/docs/claude-code/settings +- **Copilot CLI** (partially supported): Copilot CLI reads user-level + agents, skills, and instructions from ``~/.copilot/``. Copilot CLI + does not support prompts. + Ref: https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-custom-agents-for-cli +- **VS Code** (partially supported): VS Code supports user-level MCP + servers via user ``mcp.json``, but APM's MCP integrator currently + only writes to workspace ``.vscode/mcp.json``. + Ref: https://code.visualstudio.com/docs/copilot/customization/mcp-servers +- **Cursor** (not supported): user-level rules are managed via the + Cursor Settings UI, not the filesystem. + Ref: https://cursor.com/docs/rules +- **OpenCode** (not supported): no official documentation confirms whether + ``~/.opencode/`` is read at user level. +""" + +from __future__ import annotations + +from enum import Enum +from pathlib import Path +from typing import Dict, List + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +USER_APM_DIR = ".apm" +"""Directory under ``$HOME`` for user-scope metadata.""" + + +# --------------------------------------------------------------------------- +# Enum +# --------------------------------------------------------------------------- + + +class InstallScope(Enum): + """Controls where packages are deployed.""" + + PROJECT = "project" + USER = "user" + + +# --------------------------------------------------------------------------- +# Path resolution +# --------------------------------------------------------------------------- + + +def get_deploy_root(scope: InstallScope) -> Path: + """Return the root used to construct deployment paths. + + For project scope this is ``Path.cwd()``. + For user scope this is ``Path.home()`` so that integrators produce + paths like ``~/.claude/commands/``. + """ + if scope is InstallScope.USER: + return Path.home() + return Path.cwd() + + +def get_apm_dir(scope: InstallScope) -> Path: + """Return the directory that holds APM metadata (manifest, lockfile, modules). + + * Project scope: ``/`` + * User scope: ``~/.apm/`` + """ + if scope is InstallScope.USER: + return Path.home() / USER_APM_DIR + return Path.cwd() + + +def get_modules_dir(scope: InstallScope) -> Path: + """Return the ``apm_modules`` directory for *scope*.""" + from ..constants import APM_MODULES_DIR + + return get_apm_dir(scope) / APM_MODULES_DIR + + +def get_manifest_path(scope: InstallScope) -> Path: + """Return the ``apm.yml`` path for *scope*.""" + from ..constants import APM_YML_FILENAME + + return get_apm_dir(scope) / APM_YML_FILENAME + + +def get_lockfile_dir(scope: InstallScope) -> Path: + """Return the directory containing the lockfile for *scope*.""" + return get_apm_dir(scope) + + +def ensure_user_dirs() -> Path: + """Create ``~/.apm/`` and ``~/.apm/apm_modules/`` if they do not exist. + + Returns the user APM root (``~/.apm/``). + """ + from ..constants import APM_MODULES_DIR + + user_root = Path.home() / USER_APM_DIR + user_root.mkdir(parents=True, exist_ok=True) + (user_root / APM_MODULES_DIR).mkdir(exist_ok=True) + return user_root + + +# --------------------------------------------------------------------------- +# Per-target user-scope support +# +# Tracks which AI tools natively read from a user-level directory +# (``~/./``) so APM can warn when deploying primitives to a +# target that does not support user-scope. +# +# Evidence / references: +# +# * Claude Code -- ``~/.claude/`` is the documented user-level config +# directory. Claude reads CLAUDE.md, commands/, agents/, skills/ +# from it and merges them with project-level ``.claude/``. +# Ref: https://docs.anthropic.com/en/docs/claude-code/settings +# +# * Copilot CLI -- ``~/.copilot/`` is the documented user-level +# directory for custom agents, skills, and instructions. Copilot CLI +# does not support prompts. +# Ref: https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-custom-agents-for-cli +# Ref (skills): https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-skills +# Ref (instructions): https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-custom-instructions +# +# * VS Code -- supports user-level MCP servers via user mcp.json. +# APM's MCP integrator currently only writes to workspace +# ``.vscode/mcp.json``. User mcp.json support is planned. +# Ref: https://code.visualstudio.com/docs/copilot/customization/mcp-servers +# +# * Cursor -- user-level rules are configured via the Cursor Settings +# UI (Settings > Rules for AI). The ``.cursor/rules/`` directory is +# project-scoped only. +# Ref: https://cursor.com/docs/rules +# +# * OpenCode -- no official documentation confirms user-level reading +# from ``~/.opencode/``. Marked as not supported. +# --------------------------------------------------------------------------- + +USER_SCOPE_TARGETS: Dict[str, Dict[str, object]] = { + "claude": { + "supported": True, + "user_root": "~/.claude", + "primitives": ["agents", "commands", "skills", "hooks"], + "description": "User-level Claude commands, agents, and settings", + "reference": "https://docs.anthropic.com/en/docs/claude-code/settings", + }, + "copilot_cli": { + "supported": "partial", + "user_root": "~/.copilot", + "primitives": ["agents", "skills", "instructions"], + "unsupported_primitives": ["prompts"], + "description": "Partially supported -- agents, skills, instructions deploy to ~/.copilot/; Copilot CLI does not support prompts", + "reference": "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-custom-agents-for-cli", + "reference_links": { + "agents": "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-custom-agents-for-cli", + "skills": "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-skills", + "instructions": "https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-custom-instructions", + }, + }, + "vscode": { + "supported": "partial", + "user_root": "~/.vscode", + "primitives": ["mcp_servers"], + "description": "Partially supported -- VS Code reads user-level MCP servers from user mcp.json, but APM currently only writes to workspace .vscode/mcp.json", + "reference": "https://code.visualstudio.com/docs/copilot/customization/mcp-servers", + }, + "cursor": { + "supported": False, + "user_root": "~/.cursor", + "primitives": [], + "description": "Not supported -- user rules are managed via Cursor Settings UI", + "reference": "https://cursor.com/docs/rules", + }, + "opencode": { + "supported": False, + "user_root": "~/.opencode", + "primitives": [], + "description": "Not supported -- no official documentation for user-level config", + "reference": "", + }, +} + + +def get_unsupported_targets() -> List[str]: + """Return target names that do not support user-scope deployment.""" + return [ + name for name, info in USER_SCOPE_TARGETS.items() + if info["supported"] is False + ] + + +def warn_unsupported_user_scope() -> str: + """Return a warning message listing targets that lack user-scope support. + + Returns an empty string when all targets are fully supported. + + The message distinguishes three categories: + + * **fully supported** -- ``supported is True`` + * **partially supported** -- ``supported == "partial"`` + * **not supported** -- ``supported is False`` + + When some targets have ``unsupported_primitives``, a second line is + added listing those primitives per target. + """ + fully_supported = [ + name for name, info in USER_SCOPE_TARGETS.items() + if info["supported"] is True + ] + partially_supported = [ + name for name, info in USER_SCOPE_TARGETS.items() + if info["supported"] == "partial" + ] + unsupported = get_unsupported_targets() + + if not unsupported and not partially_supported: + return "" + + parts: List[str] = [] + + supported_names = ", ".join(fully_supported) + parts.append( + f"[!] User-scope primitives are fully supported by {supported_names}." + ) + + if partially_supported: + partial_names = ", ".join(partially_supported) + parts[0] += f" Partially supported: {partial_names}." + + if unsupported: + unsupported_names = ", ".join(unsupported) + parts[0] += f" Targets without native user-level support: {unsupported_names}" + + # Collect per-target unsupported primitives + unsupported_prims: List[str] = [] + for name, info in USER_SCOPE_TARGETS.items(): + prims = info.get("unsupported_primitives", []) + if prims: + unsupported_prims.append(f"{name} ({', '.join(prims)})") + if unsupported_prims: + parts.append( + "[!] Some primitives are not supported: " + + "; ".join(unsupported_prims) + ) + + return "\n".join(parts) diff --git a/tests/integration/test_global_scope_e2e.py b/tests/integration/test_global_scope_e2e.py new file mode 100644 index 00000000..a87f56a4 --- /dev/null +++ b/tests/integration/test_global_scope_e2e.py @@ -0,0 +1,421 @@ +"""Integration tests for the --global / -g scoped installation feature. + +Tests the user-scope installation lifecycle end-to-end: +- Directory structure creation under ~/.apm/ +- Manifest and lockfile placement at user scope +- Install and uninstall with --global flag +- Cross-platform path resolution (HOME vs USERPROFILE) +- Warning output for unsupported targets + +These tests override HOME (and USERPROFILE on Windows) to use a temporary +directory so they are safe to run without affecting the real user home. +They do NOT require network access -- they validate scope plumbing, path +resolution, and CLI output using local fixtures only. +""" + +import os +import platform +import shutil +import subprocess +import sys +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 fake_home(tmp_path): + """Create an isolated home directory for user-scope tests. + + Sets HOME (Unix) and USERPROFILE (Windows) so that ``Path.home()`` + inside subprocesses resolves to a temporary directory. + """ + home_dir = tmp_path / "fakehome" + home_dir.mkdir() + return home_dir + + +def _env_with_home(fake_home): + """Return an env dict with HOME/USERPROFILE pointing to *fake_home*.""" + env = os.environ.copy() + env["HOME"] = str(fake_home) + if sys.platform == "win32": + env["USERPROFILE"] = str(fake_home) + return env + + +def _run_apm(apm_command, args, cwd, fake_home, timeout=60): + """Run an apm CLI command with an overridden home directory.""" + return subprocess.run( + [apm_command] + args, + cwd=cwd, + capture_output=True, + text=True, + timeout=timeout, + env=_env_with_home(fake_home), + ) + + +@pytest.fixture +def local_package(tmp_path): + """Create a minimal local APM package for testing global install. + + Layout: + local-pkg/ + +-- apm.yml + +-- .apm/ + +-- instructions/ + +-- test.instructions.md + """ + pkg = tmp_path / "local-pkg" + pkg.mkdir() + (pkg / "apm.yml").write_text(yaml.dump({ + "name": "local-pkg", + "version": "1.0.0", + "description": "Test package for global scope", + })) + instructions_dir = pkg / ".apm" / "instructions" + instructions_dir.mkdir(parents=True) + (instructions_dir / "test.instructions.md").write_text( + "---\napplyTo: '**'\n---\n# Test instruction\nTest content." + ) + return pkg + + +# --------------------------------------------------------------------------- +# User-scope directory creation +# --------------------------------------------------------------------------- + + +class TestGlobalDirectoryCreation: + """Verify that --global creates ~/.apm/ and its children.""" + + def test_global_flag_creates_apm_dir(self, apm_command, fake_home): + """apm install --global should create ~/.apm/ even when the command + ultimately fails (e.g. no manifest and no packages).""" + result = _run_apm(apm_command, ["install", "--global"], fake_home, fake_home) + + apm_dir = fake_home / ".apm" + assert apm_dir.is_dir(), ( + f"~/.apm/ not created. stdout: {result.stdout}\nstderr: {result.stderr}" + ) + + def test_global_flag_creates_modules_subdir(self, apm_command, fake_home): + """apm install --global should create ~/.apm/apm_modules/.""" + _run_apm(apm_command, ["install", "--global"], fake_home, fake_home) + + modules = fake_home / ".apm" / "apm_modules" + assert modules.is_dir(), "~/.apm/apm_modules/ not created" + + def test_short_flag_g_creates_apm_dir(self, apm_command, fake_home): + """-g short flag should behave identically to --global.""" + _run_apm(apm_command, ["install", "-g"], fake_home, fake_home) + + assert (fake_home / ".apm").is_dir(), "-g did not create ~/.apm/" + assert (fake_home / ".apm" / "apm_modules").is_dir() + + def test_directory_creation_is_idempotent(self, apm_command, fake_home): + """Running --global twice should not raise or corrupt the directory.""" + _run_apm(apm_command, ["install", "--global"], fake_home, fake_home) + _run_apm(apm_command, ["install", "--global"], fake_home, fake_home) + + assert (fake_home / ".apm").is_dir() + assert (fake_home / ".apm" / "apm_modules").is_dir() + + +# --------------------------------------------------------------------------- +# CLI output / warnings +# --------------------------------------------------------------------------- + + +class TestGlobalScopeOutput: + """Verify CLI output when using --global.""" + + def test_shows_user_scope_info(self, apm_command, fake_home): + """Install --global should display user scope info message.""" + result = _run_apm(apm_command, ["install", "--global"], fake_home, fake_home) + combined = result.stdout + result.stderr + assert "user scope" in combined.lower() or "~/.apm/" in combined, ( + f"Missing scope info in output: {combined}" + ) + + def test_warns_about_unsupported_targets(self, apm_command, fake_home): + """Install --global should warn about targets that lack user-scope support.""" + result = _run_apm(apm_command, ["install", "--global"], fake_home, fake_home) + combined = result.stdout + result.stderr + assert "cursor" in combined.lower(), ( + f"Missing cursor warning in output: {combined}" + ) + + def test_uninstall_global_shows_scope_info(self, apm_command, fake_home): + """Uninstall --global should mention user scope in output.""" + # Create a minimal manifest so uninstall doesn't fail on missing apm.yml + apm_dir = fake_home / ".apm" + apm_dir.mkdir(parents=True, exist_ok=True) + (apm_dir / "apm.yml").write_text(yaml.dump({ + "name": "global-project", + "version": "1.0.0", + "dependencies": {"apm": ["test/pkg"]}, + })) + + result = _run_apm( + apm_command, + ["uninstall", "--global", "test/pkg"], + fake_home, + fake_home, + ) + combined = result.stdout + result.stderr + assert "user scope" in combined.lower(), ( + f"Missing scope info in uninstall output: {combined}" + ) + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +class TestGlobalErrorHandling: + """Verify error paths for --global installs.""" + + def test_no_manifest_no_packages_errors(self, apm_command, fake_home): + """--global without packages and without ~/.apm/apm.yml should fail.""" + result = _run_apm(apm_command, ["install", "--global"], fake_home, fake_home) + assert result.returncode != 0 + combined = result.stdout + result.stderr + # The error message includes the full path which may be line-wrapped + # by Rich, so check for the key parts separately + assert ".apm" in combined and "found" in combined.lower(), ( + f"Error should mention missing manifest: {combined}" + ) + + def test_uninstall_global_no_manifest_errors(self, apm_command, fake_home): + """Uninstall --global without ~/.apm/apm.yml should fail.""" + result = _run_apm( + apm_command, + ["uninstall", "--global", "test/pkg"], + fake_home, + fake_home, + ) + assert result.returncode != 0 + combined = result.stdout + result.stderr + assert ".apm" in combined and ("apm.yml" in combined or "found" in combined.lower()), ( + f"Error should mention missing manifest: {combined}" + ) + + +# --------------------------------------------------------------------------- +# Manifest creation and placement +# --------------------------------------------------------------------------- + + +class TestGlobalManifestPlacement: + """Verify that manifest/lockfile are written under ~/.apm/.""" + + def test_auto_bootstrap_creates_user_manifest( + self, apm_command, fake_home, local_package + ): + """Installing a local package with --global auto-creates ~/.apm/apm.yml.""" + result = _run_apm( + apm_command, + ["install", "--global", str(local_package)], + fake_home, + fake_home, + ) + + user_manifest = fake_home / ".apm" / "apm.yml" + assert user_manifest.exists(), ( + f"~/.apm/apm.yml not created. " + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + + data = yaml.safe_load(user_manifest.read_text()) + assert "dependencies" in data + apm_deps = data.get("dependencies", {}).get("apm", []) + assert any(str(local_package) in str(d) for d in apm_deps), ( + f"Package not recorded in manifest: {apm_deps}" + ) + + def test_user_manifest_does_not_pollute_cwd( + self, apm_command, fake_home, local_package + ): + """--global must not create apm.yml in the working directory.""" + work_dir = fake_home / "workdir" + work_dir.mkdir() + + _run_apm( + apm_command, + ["install", "--global", str(local_package)], + work_dir, + fake_home, + ) + + assert not (work_dir / "apm.yml").exists(), ( + "apm.yml was incorrectly created in the working directory" + ) + + def test_lockfile_placed_under_user_dir( + self, apm_command, fake_home, local_package + ): + """Lockfile should be created under ~/.apm/, not in the working directory.""" + work_dir = fake_home / "workdir" + work_dir.mkdir() + + result = _run_apm( + apm_command, + ["install", "--global", str(local_package)], + work_dir, + fake_home, + ) + + # Lockfile should NOT be in the working directory regardless of outcome + assert not (work_dir / "apm.lock.yaml").exists(), ( + "Lockfile was incorrectly created in the working directory" + ) + assert not (work_dir / "apm.lock").exists(), ( + "Legacy lockfile was incorrectly created in the working directory" + ) + + # If a lockfile was created, it must be under ~/.apm/ + user_lockfile = fake_home / ".apm" / "apm.lock.yaml" + if user_lockfile.exists(): + # Sanity: should be parseable YAML + data = yaml.safe_load(user_lockfile.read_text()) + assert isinstance(data, dict) + + +# --------------------------------------------------------------------------- +# Cross-platform path resolution +# --------------------------------------------------------------------------- + + +class TestCrossPlatformPaths: + """Verify path resolution works on the current platform.""" + + def test_home_based_paths_are_absolute(self, apm_command, fake_home): + """All user-scope paths should resolve to absolute paths.""" + from apm_cli.core.scope import ( + InstallScope, + get_apm_dir, + get_deploy_root, + get_lockfile_dir, + get_manifest_path, + get_modules_dir, + ) + from unittest.mock import patch + + with patch.object(Path, "home", return_value=fake_home): + for fn in [get_apm_dir, get_deploy_root, get_lockfile_dir, + get_manifest_path, get_modules_dir]: + result = fn(InstallScope.USER) + assert result.is_absolute(), ( + f"{fn.__name__}(USER) returned non-absolute path: {result}" + ) + + def test_forward_slash_paths_on_all_platforms(self, apm_command, fake_home): + """User-scope paths should use forward slashes (POSIX) when + stored as strings, matching the lockfile convention.""" + from apm_cli.core.scope import InstallScope, get_apm_dir + from unittest.mock import patch + + with patch.object(Path, "home", return_value=fake_home): + apm_dir = get_apm_dir(InstallScope.USER) + posix_str = apm_dir.as_posix() + # Should not contain backslashes (even on Windows the as_posix() + # call should convert them) + assert "\\" not in posix_str, ( + f"Path contains backslashes: {posix_str}" + ) + + def test_user_root_strings_start_with_tilde(self): + """USER_SCOPE_TARGETS user_root values should start with ~/.""" + from apm_cli.core.scope import USER_SCOPE_TARGETS + + for name, info in USER_SCOPE_TARGETS.items(): + assert info["user_root"].startswith("~/"), ( + f"{name} user_root does not start with '~/': {info['user_root']}" + ) + + +# --------------------------------------------------------------------------- +# Uninstall lifecycle (global scope) +# --------------------------------------------------------------------------- + + +class TestGlobalUninstallLifecycle: + """Test uninstall --global removes packages from user-scope metadata.""" + + def test_uninstall_removes_package_from_user_manifest( + self, apm_command, fake_home + ): + """Uninstall --global should remove the package entry from ~/.apm/apm.yml.""" + apm_dir = fake_home / ".apm" + apm_dir.mkdir(parents=True, exist_ok=True) + (apm_dir / "apm_modules").mkdir(exist_ok=True) + + # Seed the manifest with a package + manifest = apm_dir / "apm.yml" + manifest.write_text(yaml.dump({ + "name": "global-project", + "version": "1.0.0", + "dependencies": {"apm": ["test/pkg-to-remove"]}, + })) + + result = _run_apm( + apm_command, + ["uninstall", "--global", "test/pkg-to-remove"], + fake_home, + fake_home, + ) + + data = yaml.safe_load(manifest.read_text()) + apm_deps = data.get("dependencies", {}).get("apm", []) + assert "test/pkg-to-remove" not in apm_deps, ( + f"Package not removed from manifest: {apm_deps}\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + + def test_uninstall_global_package_not_found_warns( + self, apm_command, fake_home + ): + """Uninstalling a package that is not in the manifest should warn.""" + apm_dir = fake_home / ".apm" + apm_dir.mkdir(parents=True, exist_ok=True) + (apm_dir / "apm_modules").mkdir(exist_ok=True) + + manifest = apm_dir / "apm.yml" + manifest.write_text(yaml.dump({ + "name": "global-project", + "version": "1.0.0", + "dependencies": {"apm": []}, + })) + + result = _run_apm( + apm_command, + ["uninstall", "--global", "nonexistent/pkg"], + fake_home, + fake_home, + ) + + combined = result.stdout + result.stderr + assert "not found" in combined.lower() or "not in apm.yml" in combined.lower(), ( + f"Expected 'not found' warning: {combined}" + ) diff --git a/tests/unit/core/test_scope.py b/tests/unit/core/test_scope.py new file mode 100644 index 00000000..b43f1fbe --- /dev/null +++ b/tests/unit/core/test_scope.py @@ -0,0 +1,246 @@ +"""Tests for installation scope resolution.""" + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from apm_cli.core.scope import ( + InstallScope, + USER_APM_DIR, + USER_SCOPE_TARGETS, + ensure_user_dirs, + get_apm_dir, + get_deploy_root, + get_lockfile_dir, + get_manifest_path, + get_modules_dir, + get_unsupported_targets, + warn_unsupported_user_scope, +) + + +# --------------------------------------------------------------------------- +# InstallScope enum +# --------------------------------------------------------------------------- + + +class TestInstallScope: + """Basic enum sanity checks.""" + + def test_values(self): + assert InstallScope.PROJECT.value == "project" + assert InstallScope.USER.value == "user" + + def test_from_string(self): + assert InstallScope("project") is InstallScope.PROJECT + assert InstallScope("user") is InstallScope.USER + + def test_invalid_raises(self): + with pytest.raises(ValueError): + InstallScope("global") + + +# --------------------------------------------------------------------------- +# get_deploy_root +# --------------------------------------------------------------------------- + + +class TestGetDeployRoot: + """Tests for get_deploy_root.""" + + def test_project_returns_cwd(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert get_deploy_root(InstallScope.PROJECT) == tmp_path + + def test_user_returns_home(self, tmp_path): + with patch.object(Path, "home", return_value=tmp_path): + assert get_deploy_root(InstallScope.USER) == tmp_path + + +# --------------------------------------------------------------------------- +# get_apm_dir +# --------------------------------------------------------------------------- + + +class TestGetApmDir: + """Tests for get_apm_dir.""" + + def test_project_is_cwd(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert get_apm_dir(InstallScope.PROJECT) == tmp_path + + def test_user_is_home_dot_apm(self, tmp_path): + with patch.object(Path, "home", return_value=tmp_path): + assert get_apm_dir(InstallScope.USER) == tmp_path / USER_APM_DIR + + +# --------------------------------------------------------------------------- +# get_modules_dir +# --------------------------------------------------------------------------- + + +class TestGetModulesDir: + """Tests for get_modules_dir.""" + + def test_project_modules(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert get_modules_dir(InstallScope.PROJECT) == tmp_path / "apm_modules" + + def test_user_modules(self, tmp_path): + with patch.object(Path, "home", return_value=tmp_path): + assert get_modules_dir(InstallScope.USER) == tmp_path / ".apm" / "apm_modules" + + +# --------------------------------------------------------------------------- +# get_manifest_path +# --------------------------------------------------------------------------- + + +class TestGetManifestPath: + """Tests for get_manifest_path.""" + + def test_project_manifest(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert get_manifest_path(InstallScope.PROJECT) == tmp_path / "apm.yml" + + def test_user_manifest(self, tmp_path): + with patch.object(Path, "home", return_value=tmp_path): + assert get_manifest_path(InstallScope.USER) == tmp_path / ".apm" / "apm.yml" + + +# --------------------------------------------------------------------------- +# get_lockfile_dir +# --------------------------------------------------------------------------- + + +class TestGetLockfileDir: + """Tests for get_lockfile_dir.""" + + def test_project_lockfile(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert get_lockfile_dir(InstallScope.PROJECT) == tmp_path + + def test_user_lockfile(self, tmp_path): + with patch.object(Path, "home", return_value=tmp_path): + assert get_lockfile_dir(InstallScope.USER) == tmp_path / ".apm" + + +# --------------------------------------------------------------------------- +# ensure_user_dirs +# --------------------------------------------------------------------------- + + +class TestEnsureUserDirs: + """Tests for ensure_user_dirs.""" + + def test_creates_dirs(self, tmp_path): + with patch.object(Path, "home", return_value=tmp_path): + result = ensure_user_dirs() + assert result == tmp_path / ".apm" + assert result.is_dir() + assert (result / "apm_modules").is_dir() + + def test_idempotent(self, tmp_path): + with patch.object(Path, "home", return_value=tmp_path): + ensure_user_dirs() + ensure_user_dirs() # Should not raise + assert (tmp_path / ".apm").is_dir() + + +# --------------------------------------------------------------------------- +# USER_SCOPE_TARGETS registry +# --------------------------------------------------------------------------- + + +class TestUserScopeTargets: + """Validate the target support registry.""" + + def test_all_known_targets_present(self): + expected = {"copilot_cli", "vscode", "claude", "cursor", "opencode"} + assert set(USER_SCOPE_TARGETS.keys()) == expected + + def test_each_target_has_required_keys(self): + for name, info in USER_SCOPE_TARGETS.items(): + assert "supported" in info, f"{name} missing 'supported'" + assert "user_root" in info, f"{name} missing 'user_root'" + assert "description" in info, f"{name} missing 'description'" + assert "primitives" in info, f"{name} missing 'primitives'" + assert "reference" in info, f"{name} missing 'reference'" + + def test_user_roots_start_with_tilde(self): + for name, info in USER_SCOPE_TARGETS.items(): + assert info["user_root"].startswith("~/"), ( + f"{name} user_root should start with '~/'" + ) + + def test_claude_is_supported(self): + assert USER_SCOPE_TARGETS["claude"]["supported"] is True + + def test_copilot_cli_is_partially_supported(self): + assert USER_SCOPE_TARGETS["copilot_cli"]["supported"] == "partial" + + def test_vscode_is_partially_supported(self): + assert USER_SCOPE_TARGETS["vscode"]["supported"] == "partial" + + def test_cursor_is_not_supported(self): + assert USER_SCOPE_TARGETS["cursor"]["supported"] is False + + def test_opencode_is_not_supported(self): + assert USER_SCOPE_TARGETS["opencode"]["supported"] is False + + def test_claude_has_primitives(self): + assert len(USER_SCOPE_TARGETS["claude"]["primitives"]) > 0 + + def test_copilot_cli_has_primitives(self): + assert len(USER_SCOPE_TARGETS["copilot_cli"]["primitives"]) > 0 + + def test_copilot_cli_has_unsupported_primitives(self): + assert "prompts" in USER_SCOPE_TARGETS["copilot_cli"]["unsupported_primitives"] + + def test_vscode_has_primitives(self): + assert len(USER_SCOPE_TARGETS["vscode"]["primitives"]) > 0 + + def test_unsupported_targets_have_no_primitives(self): + for name, info in USER_SCOPE_TARGETS.items(): + if info["supported"] is False: + assert info["primitives"] == [], ( + f"{name} is unsupported but lists primitives" + ) + + +# --------------------------------------------------------------------------- +# get_unsupported_targets / warn_unsupported_user_scope +# --------------------------------------------------------------------------- + + +class TestScopeWarnings: + """Tests for unsupported-target warnings.""" + + def test_get_unsupported_targets(self): + unsupported = get_unsupported_targets() + assert "cursor" in unsupported + assert "opencode" in unsupported + # Partially supported targets should NOT appear in unsupported + assert "copilot_cli" not in unsupported + assert "vscode" not in unsupported + assert "claude" not in unsupported + + def test_warn_message_includes_unsupported_names(self): + msg = warn_unsupported_user_scope() + assert msg # non-empty + assert "cursor" in msg + assert "opencode" in msg + # Claude is fully supported and should be listed as such + assert "claude" in msg + assert "fully supported" in msg.lower() + # Partially supported targets should be listed + assert "copilot_cli" in msg + assert "vscode" in msg + assert "partially supported" in msg.lower() + + def test_warn_message_includes_unsupported_primitives(self): + msg = warn_unsupported_user_scope() + assert "prompts" in msg.lower() + assert "copilot_cli (prompts)" in msg diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py index 52ab9d55..b3624623 100644 --- a/tests/unit/test_install_command.py +++ b/tests/unit/test_install_command.py @@ -600,3 +600,115 @@ def test_hint_caps_at_five(self, tmp_path, capsys): captured = capsys.readouterr() assert "apm install" in captured.out assert "... and 3 more" in captured.out + + +# --------------------------------------------------------------------------- +# Global scope (--global / -g) tests +# --------------------------------------------------------------------------- + + +class TestInstallGlobalFlag: + """Tests for the --global / -g flag on apm install.""" + + def setup_method(self): + self.runner = CliRunner() + try: + self.original_dir = os.getcwd() + except FileNotFoundError: + self.original_dir = str(Path(__file__).parent.parent.parent) + os.chdir(self.original_dir) + + def teardown_method(self): + try: + os.chdir(self.original_dir) + except (FileNotFoundError, OSError): + repo_root = Path(__file__).parent.parent.parent + os.chdir(str(repo_root)) + + def test_global_flag_shows_scope_info(self): + """--global flag should display user scope info message and unsupported target warning.""" + with tempfile.TemporaryDirectory() as tmp_dir: + try: + os.chdir(tmp_dir) + # Create a fake home with no manifest so the command errors early + fake_home = Path(tmp_dir) / "fakehome" + fake_home.mkdir() + with patch.object(Path, "home", return_value=fake_home): + result = self.runner.invoke(cli, ["install", "--global"]) + assert result.exit_code == 1 + assert "user scope" in result.output.lower() or "~/.apm/" in result.output + # Should warn about unsupported targets + assert "cursor" in result.output.lower() + finally: + os.chdir(self.original_dir) + + def test_global_short_flag_g(self): + """-g short flag creates user dirs and shows scope info like --global.""" + with tempfile.TemporaryDirectory() as tmp_dir: + try: + os.chdir(tmp_dir) + fake_home = Path(tmp_dir) / "fakehome" + fake_home.mkdir() + with patch.object(Path, "home", return_value=fake_home): + result = self.runner.invoke(cli, ["install", "-g"]) + # Should create ~/.apm/ directory + assert (fake_home / ".apm").is_dir() + assert (fake_home / ".apm" / "apm_modules").is_dir() + assert "user scope" in result.output.lower() or "~/.apm/" in result.output + # Should warn about unsupported targets + assert "cursor" in result.output.lower() + finally: + os.chdir(self.original_dir) + + @patch("apm_cli.commands.install._validate_package_exists") + @patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True) + @patch("apm_cli.commands.install.APMPackage") + @patch("apm_cli.commands.install._install_apm_dependencies") + def test_global_creates_user_apm_yml( + self, mock_install_apm, mock_apm_package, mock_validate + ): + """--global auto-creates ~/.apm/apm.yml when installing packages.""" + with tempfile.TemporaryDirectory() as tmp_dir: + try: + os.chdir(tmp_dir) + fake_home = Path(tmp_dir) / "fakehome" + fake_home.mkdir() + + mock_validate.return_value = True + mock_pkg = MagicMock() + mock_pkg.get_apm_dependencies.return_value = [ + MagicMock(repo_url="test/pkg", reference="main") + ] + mock_pkg.get_mcp_dependencies.return_value = [] + mock_pkg.get_dev_apm_dependencies.return_value = [] + mock_pkg.target = None + mock_apm_package.from_apm_yml.return_value = mock_pkg + mock_install_apm.return_value = InstallResult( + diagnostics=MagicMock(has_diagnostics=False, has_critical_security=False) + ) + + with patch.object(Path, "home", return_value=fake_home): + result = self.runner.invoke( + cli, ["install", "--global", "test/pkg"] + ) + + assert result.exit_code == 0 + user_manifest = fake_home / ".apm" / "apm.yml" + assert user_manifest.exists(), f"Expected {user_manifest} to exist" + assert (fake_home / ".apm" / "apm_modules").is_dir() + finally: + os.chdir(self.original_dir) + + def test_global_without_packages_and_no_manifest_errors(self): + """--global without packages and no ~/.apm/apm.yml shows error.""" + with tempfile.TemporaryDirectory() as tmp_dir: + try: + os.chdir(tmp_dir) + fake_home = Path(tmp_dir) / "fakehome" + fake_home.mkdir() + with patch.object(Path, "home", return_value=fake_home): + result = self.runner.invoke(cli, ["install", "--global"]) + assert result.exit_code == 1 + assert "apm.yml" in result.output + finally: + os.chdir(self.original_dir)