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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Audit hardening — `apm unpack` content scanning, SARIF/JSON/Markdown `--format`/`--output` for CI capture, `SecurityGate` policy engine, non-zero exits on critical findings (#330)
- Install output now shows resolved git ref alongside package name (e.g. `✓ owner/repo @ main (a1b2c3d4)`) (#340)
- One-time info hint when dependencies have no explicit ref: `Tip: Pin versions with #tag or #sha for reproducible installs` (#340)

### Fixed

- File-level downloads from private repos now use OS credential helpers (macOS Keychain, `gh auth login`, Windows Credential Manager) — closes auth gap between folder and file dependencies (#332)
- Lockfile now preserves the host for GitHub Enterprise custom domains so subsequent `apm install` clones from the correct server (#338)

### Removed

- Shorthand `@alias` syntax removed from dependency strings — the `@` separator conflicted with conventions in npm, Go, and Cargo where `@` denotes scope or version. Use the dict format `alias:` field instead (#340)

## [0.8.0] - 2026-03-16

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ dependencies:
# Specific agent primitives from any repository
- github/awesome-copilot/agents/api-architect.agent.md
# A full APM package with instructions, skills, prompts, hooks...
- microsoft/apm-sample-package
- microsoft/apm-sample-package#v1.0.0
```
```bash
Expand Down Expand Up @@ -88,7 +88,7 @@ pip install apm-cli
Then start adding packages:

```bash
apm install microsoft/apm-sample-package
apm install microsoft/apm-sample-package#v1.0.0
```

See the **[Getting Started guide](https://microsoft.github.io/apm/getting-started/quick-start/)** for the full walkthrough.
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/getting-started/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ dependencies:
This is where it gets interesting. Install a package and watch what happens:
```bash
apm install microsoft/apm-sample-package
apm install microsoft/apm-sample-package#v1.0.0
```

APM downloads the package, resolves its dependencies, and deploys files directly into the directories your AI tools already watch:
Expand Down Expand Up @@ -107,7 +107,7 @@ name: my-project
version: 1.0.0
dependencies:
apm:
- microsoft/apm-sample-package
- microsoft/apm-sample-package#v1.0.0
```
## That's it
Expand Down
8 changes: 4 additions & 4 deletions docs/src/content/docs/guides/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ version: 1.0.0
dependencies:
apm:
# GitHub shorthand (default)
- microsoft/apm-sample-package
- microsoft/apm-sample-package#v1.0.0
- github/awesome-copilot/skills/review-and-refactor

# Full HTTPS git URL (any host)
Expand Down Expand Up @@ -136,7 +136,7 @@ dependencies:
ref: v2.0 # pin to a tag, branch, or commit
- git: git@bitbucket.org:team/rules.git
path: prompts/review.prompt.md
alias: review
alias: review # local alias (controls install directory name)
```

Fields: `git` (required), `path`, `ref`, `alias` (all optional). The `git` value is any HTTPS or SSH clone URL.
Expand Down Expand Up @@ -173,7 +173,7 @@ APM normalizes every dependency entry on write — no matter how you specify a p
| `./packages/my-skills` | `./packages/my-skills` |
| `/home/user/repos/my-pkg` | `/home/user/repos/my-pkg` |

Virtual paths, refs, and aliases are preserved:
Virtual paths and refs are preserved:

| You type | Stored in apm.yml |
|----------|-------------------|
Expand Down Expand Up @@ -405,7 +405,7 @@ version: 1.0.0
description: Corporate website with design standards and code review
dependencies:
apm:
- microsoft/apm-sample-package
- microsoft/apm-sample-package#v1.0.0
- github/awesome-copilot/skills/review-and-refactor
mcp:
- io.github.github/github-mcp-server
Expand Down
16 changes: 7 additions & 9 deletions docs/src/content/docs/reference/manifest-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ Grammar (ABNF-style):
```
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]
shorthand_form = [host "/"] owner "/" repo ["/" virtual_path] ["#" ref]
local_path_form = ("./" / "../" / "/" / "~/" / ".\\" / "..\\" / "~\\") path
```

Expand All @@ -181,17 +181,16 @@ local_path_form = ("./" / "../" / "/" / "~/" / ".\\" / "..\\" / "~\\") path
| `owner/repo` | REQUIRED | 2+ path segments of `[a-zA-Z0-9._-]+` | Repository path. GitHub uses exactly 2 segments (`owner/repo`). Non-GitHub hosts MAY use nested groups (e.g. `gitlab.com/group/sub/repo`). |
| `virtual_path` | OPTIONAL | Path segments after repo | Subdirectory, file, or collection within the repo. See §4.1.3. |
| `ref` | OPTIONAL | Branch, tag, or commit SHA | Git reference. Commit SHAs matched by `^[a-f0-9]{7,40}$`. Semver tags matched by `^v?\d+\.\d+\.\d+`. |
| `alias` | OPTIONAL | `^[a-zA-Z0-9._-]+$` | Local alias for the dependency. Appears after `#ref` in the string. |

**Examples:**

```yaml
dependencies:
apm:
# GitHub shorthand (default host)
- microsoft/apm-sample-package
- microsoft/apm-sample-package#v1.0.0
- microsoft/apm-sample-package@standards
# GitHub shorthand (default host) — each line shows a syntax variant
- microsoft/apm-sample-package # latest (lockfile pins commit SHA)
- microsoft/apm-sample-package#v1.0.0 # pinned to tag (immutable)
- microsoft/apm-sample-package#main # branch ref (may change over time)
# Non-GitHub hosts (FQDN preserved)
- gitlab.com/acme/coding-standards
Expand Down Expand Up @@ -418,12 +417,11 @@ scripts:
dependencies:
apm:
- microsoft/apm-sample-package
- gitlab.com/acme/coding-standards
- microsoft/apm-sample-package#v1.0.0
- gitlab.com/acme/coding-standards#main
- git: https://gitlab.com/acme/repo.git
path: instructions/security
ref: v2.0
alias: acme-sec
mcp:
- io.github.github/github-mcp-server
- name: my-private-server
Expand Down
9 changes: 5 additions & 4 deletions docs/src/content/docs/reference/primitive-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ version: 1.0.0
dependencies:
apm:
- company/standards#v1.0.0
- team/workflows@workflow-alias
- team/workflows
- user/utilities
```
Expand All @@ -151,9 +151,10 @@ project/
│ └── .apm/
│ ├── chatmodes/
│ └── instructions/
├── workflow-alias/ # From team/workflows (uses alias)
│ └── .apm/
│ └── contexts/
├── team/
│ └── workflows/ # From team/workflows
│ └── .apm/
│ └── contexts/
└── utilities/ # From user/utilities
└── .apm/
└── instructions/
Expand Down
29 changes: 26 additions & 3 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,7 @@ def _collect_descendants(node, visited=None):

# downloader already created above for transitive resolution
installed_count = 0
has_unpinned_deps = False

# Phase 4 (#171): Parallel package downloads using ThreadPoolExecutor
# Pre-download all non-cached packages in parallel for wall-clock speedup.
Expand Down Expand Up @@ -1352,9 +1353,20 @@ def _collect_descendants(node, visited=None):
display_name = (
str(dep_ref) if dep_ref.is_virtual else dep_ref.repo_url
)
ref_str = f" @{dep_ref.reference}" if dep_ref.reference else ""
# Show resolved ref from lockfile for consistency with fresh installs
ref_str = ""
if _dep_locked_chk and _dep_locked_chk.resolved_commit and _dep_locked_chk.resolved_commit != "cached":
short_sha = _dep_locked_chk.resolved_commit[:8]
if dep_ref.reference:
ref_str = f" @ {dep_ref.reference} ({short_sha})"
else:
ref_str = f" @ {short_sha}"
elif dep_ref.reference:
ref_str = f" @ {dep_ref.reference}"
_rich_info(f"✓ {display_name}{ref_str} (cached)")
installed_count += 1
if not dep_ref.reference:
has_unpinned_deps = True

# Still need to integrate prompts for cached packages (zero-config behavior)
if integrate_vscode or integrate_claude or integrate_opencode:
Expand Down Expand Up @@ -1749,11 +1761,19 @@ def _collect_descendants(node, visited=None):
progress.refresh() # Force immediate refresh to hide the bar

installed_count += 1
_rich_success(f"✓ {display_name}")

# Show resolved ref alongside package name for visibility
resolved = getattr(package_info, 'resolved_reference', None)
ref_suffix = f" @ {resolved}" if resolved else ""
_rich_success(f"✓ {display_name}{ref_suffix}")

# Track whether any dep had no explicit ref (for hint)
if not dep_ref.reference:
has_unpinned_deps = True

# Collect for lockfile: get resolved commit and depth
resolved_commit = None
if hasattr(package_info, 'resolved_reference') and package_info.resolved_reference:
if resolved:
resolved_commit = package_info.resolved_reference.resolved_commit
# Get depth from dependency tree
node = dependency_graph.dependency_tree.get_node(dep_ref.get_unique_key())
Expand Down Expand Up @@ -2179,6 +2199,9 @@ def _collect_descendants(node, visited=None):

_rich_success(f"Installed {installed_count} APM dependencies")

if has_unpinned_deps:
_rich_info("Tip: Pin versions with #tag or #sha for reproducible installs (e.g. owner/repo#v1.0.0)")

return installed_count, total_prompts_integrated, total_agents_integrated, diagnostics

except Exception as e:
Expand Down
35 changes: 7 additions & 28 deletions src/apm_cli/models/dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,6 @@ def to_canonical(self) -> str:
- Non-default hosts are preserved -> gitlab.com/owner/repo
- 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.
Expand Down Expand Up @@ -200,10 +199,6 @@ def to_canonical(self) -> str:
if self.reference:
result = f"{result}#{self.reference}"

# Append alias
if self.alias:
result = f"{result}@{self.alias}"

return result

def get_identity(self) -> str:
Expand Down Expand Up @@ -433,7 +428,10 @@ def parse_from_dict(cls, entry: dict) -> "DependencyReference":
if alias_override is not None:
if not isinstance(alias_override, str) or not alias_override.strip():
raise ValueError("'alias' field must be a non-empty string")
dep.alias = alias_override.strip()
alias_override = alias_override.strip()
if not re.match(r'^[a-zA-Z0-9._-]+$', alias_override):
raise ValueError(f"Invalid alias: {alias_override}. Aliases can only contain letters, numbers, dots, underscores, and hyphens")
dep.alias = alias_override

# Apply sub-path as virtual package
if sub_path:
Expand All @@ -452,8 +450,6 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
- user/repo#v1.0.0
- user/repo#commit_sha
- github.com/user/repo#ref
- user/repo@alias
- user/repo#ref@alias
- user/repo/path/to/file.prompt.md (virtual file package)
- user/repo/collections/name (virtual collection package)
- https://gitlab.com/owner/repo.git (generic HTTPS git URL)
Expand Down Expand Up @@ -515,10 +511,8 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
# Extract the core path before processing reference (#) and alias (@)
work_str = dependency_str

# Temporarily remove reference and alias for path segment counting
# Temporarily remove reference for path segment counting
temp_str = work_str
if '@' in temp_str and not temp_str.startswith('git@'):
temp_str = temp_str.rsplit('@', 1)[0]
if '#' in temp_str:
temp_str = temp_str.rsplit('#', 1)[0]

Expand Down Expand Up @@ -653,14 +647,10 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
host = ssh_match.group(1)
ssh_repo_part = ssh_match.group(2)

# Handle reference and alias in SSH URL (extract before .git stripping)
# Handle reference in SSH URL (extract before .git stripping)
reference = None
alias = None

if "@" in ssh_repo_part:
ssh_repo_part, alias = ssh_repo_part.rsplit("@", 1)
alias = alias.strip()

if "#" in ssh_repo_part:
repo_part, reference = ssh_repo_part.rsplit("#", 1)
reference = reference.strip()
Expand All @@ -673,13 +663,8 @@ def parse(cls, dependency_str: str) -> "DependencyReference":

repo_url = repo_part.strip()
else:
# Handle alias (@alias) for non-SSH URLs
alias = None
if "@" in dependency_str:
dependency_str, alias = dependency_str.rsplit("@", 1)
alias = alias.strip()

# Handle reference (#ref)
alias = None
reference = None
if "#" in dependency_str:
repo_part, reference = dependency_str.rsplit("#", 1)
Expand Down Expand Up @@ -881,10 +866,6 @@ def parse(cls, dependency_str: str) -> "DependencyReference":
ado_project = None
ado_repo = None

# Validate alias characters if present
if alias and not re.match(r'^[a-zA-Z0-9._-]+$', alias):
raise ValueError(f"Invalid alias: {alias}. Aliases can only contain letters, numbers, dots, underscores, and hyphens")

return cls(
repo_url=repo_url,
host=host,
Expand Down Expand Up @@ -943,8 +924,6 @@ def __str__(self) -> str:
result += f"/{self.virtual_path}"
if self.reference:
result += f"#{self.reference}"
if self.alias:
result += f"@{self.alias}"
return result


Expand Down
2 changes: 1 addition & 1 deletion templates/hello-world/apm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ scripts:

dependencies:
apm:
- microsoft/apm-sample-package # Design standards, prompts, and skills
- microsoft/apm-sample-package#v1.0.0 # Design standards, prompts, and skills
mcp:
- microsoft/azure-devops-mcp

Expand Down
Loading
Loading