Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6018a0b
Initial plan
Copilot Mar 25, 2026
0181b05
Initial plan for scoped installation feature
Copilot Mar 25, 2026
a0f2010
feat: add --global / -g flag for user-scope package installation
Copilot Mar 25, 2026
6dfbf6d
docs: add scoped installation guide and update CLI reference and CHAN…
Copilot Mar 25, 2026
4963e63
fix: address code review feedback on scope implementation
Copilot Mar 25, 2026
144c9fa
fix: document per-target user-scope support with evidence and add ins…
Copilot Mar 25, 2026
11ee6ac
test: add integration tests for global scope feature (17 tests)
Copilot Mar 25, 2026
7486d4b
fix: address code review feedback on integration tests
Copilot Mar 25, 2026
e07314d
Update test_install_command.py
sergio-sisternes-epam Mar 26, 2026
0c5959d
Update CHANGELOG.md
sergio-sisternes-epam Mar 26, 2026
4d465a0
Update cli.py
sergio-sisternes-epam Mar 26, 2026
1725400
fix: move InstallLogger creation before scope init to prevent Unbound…
Copilot Mar 26, 2026
98c0a02
fix: differentiate VS Code and Copilot CLI as separate targets in use…
Copilot Mar 26, 2026
46a7e0e
fix: improve warning message clarity and fix user_root consistency fo…
Copilot Mar 26, 2026
4d3c6a8
fix: expand Copilot CLI primitives to include skills and instructions…
Copilot Mar 26, 2026
4af0d4a
refactor: improve test variable naming and assertion structure
Copilot Mar 26, 2026
07af560
fix: change Copilot CLI status to "Partially supported" in docs table
Copilot Mar 26, 2026
e65e128
fix: change copilot_cli and vscode support status to "partial" to mat…
Copilot Mar 26, 2026
559c529
fix: update project scope primitives for target consistency and VS Co…
Copilot Mar 26, 2026
f5dceda
Update scoped-installation.md
sergio-sisternes-epam Mar 26, 2026
aeda09b
fix: tag OpenCode as "Not supported" instead of "Unverified" for cons…
Copilot Mar 26, 2026
8ea0bf5
fix: address pending review feedback - scope-aware hints, VS Code use…
Copilot Mar 26, 2026
70294b3
test: strengthen VS Code user_root assertion to check exact placehold…
Copilot Mar 26, 2026
562fdc2
fix: downgrade copilot_cli and vscode to "Not supported" at user scop…
Copilot Mar 26, 2026
ac56de0
style: break long skills existence check into readable multi-line form
Copilot Mar 26, 2026
81cdfbd
fix: restore tri-state support matrix for Copilot CLI (partial) and V…
Copilot Mar 26, 2026
0f37a14
test: improve unsupported primitives assertion to check exact format
Copilot Mar 26, 2026
b10a6e5
docs: clarify that Copilot CLI does not support prompts
Copilot Mar 26, 2026
1847f50
merge: resolve conflicts with main (--target flag and --global coexist)
Copilot Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions docs/src/content/docs/guides/scoped-installation.md
Original file line number Diff line number Diff line change
@@ -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 |
Comment on lines +42 to +48
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-target support table is written with || at the start of each row (including the header and separator). In Markdown this adds an extra empty column and often renders the table incorrectly. Use a single leading | for each row so the table renders as intended.

Copilot uses AI. Check for mistakes.

### 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 |
Comment on lines +58 to +63
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same table-formatting issue for the "When to use each scope" table: rows start with ||, which will render an empty first column. Switch to a single leading | for each row.

Copilot uses AI. Check for mistakes.
8 changes: 8 additions & 0 deletions docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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:**
Expand Down Expand Up @@ -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
Expand All @@ -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:**
Expand Down
6 changes: 4 additions & 2 deletions src/apm_cli/commands/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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)
86 changes: 63 additions & 23 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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).
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_deploy_root is imported in the install command's scope-resolution block but not used in install() (only used inside _install_apm_dependencies). Dropping unused imports here will reduce confusion about which paths are actually used at this layer.

Suggested change
from ..core.scope import InstallScope, get_deploy_root, get_apm_dir, get_manifest_path, get_modules_dir, ensure_user_dirs, warn_unsupported_user_scope
from ..core.scope import InstallScope, get_apm_dir, get_manifest_path, get_modules_dir, ensure_user_dirs, warn_unsupported_user_scope

Copilot uses AI. Check for mistakes.
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)

Comment on lines 556 to +574
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the new user-scope flow, several operations (importing scope helpers, ensure_user_dirs(), warn_unsupported_user_scope()) run before logger is instantiated, but the surrounding except Exception block later calls logger.error(...). If an exception is raised before logger is assigned, this will trigger an UnboundLocalError and mask the original failure. Create the InstallLogger before any scope initialization (or guard the exception handler to fall back to _rich_error when logger is not available).

Copilot uses AI. Check for mistakes.
# 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 <org/repo> to auto-create + install")
logger.error(f"No {manifest_display} found")
if scope is InstallScope.USER:
logger.progress("Run 'apm install -g <org/repo>' to auto-create + install")
else:
logger.progress("Run 'apm init' to create one, or:")
logger.progress(" apm install <org/repo> 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:
Expand All @@ -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(
Expand Down Expand Up @@ -643,15 +675,15 @@ 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).
# We always read this — even when --only=apm — so we can restore the
# 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)
Expand All @@ -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,
)
Comment on lines 704 to 709
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InstallScope is plumbed into _install_apm_dependencies() (scope=scope), but the later MCP installation path is not scope-aware (MCPIntegrator reads apm.yml from Path('apm.yml') and uses Path.cwd() for VS Code/Cursor/OpenCode detection and config writes). This makes --global installs inconsistent: APM deps/lockfile use ~/.apm, while MCP configuration may still read/write workspace files. Consider plumbing scope/manifest_path into the MCP install flow as well so global installs do not affect the current project directory.

Copilot uses AI. Check for mistakes.
apm_count = install_result.installed_count
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In user scope, _install_apm_dependencies() sets project_root = get_deploy_root(scope) (HOME). That same project_root is later used to resolve relative local package paths in _copy_local_package() (via (project_root / local).resolve()), so apm install -g ./relative/path will incorrectly resolve against ~ instead of the current working directory. Split the concepts (e.g., deploy_root = get_deploy_root(scope) for primitives, but keep cwd = Path.cwd() for resolving local paths) and pass the correct base into _copy_local_package() / other path resolution that should remain cwd-relative.

Suggested change
project_root = get_deploy_root(scope)
deploy_root = get_deploy_root(scope)
project_root = Path.cwd()

Copilot uses AI. Check for mistakes.
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:
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 <pkg>),
# merge new entries into the existing lockfile instead of
Expand Down
Loading
Loading