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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Day-0 install parity with `npx skills add`**: every public repo that installs cleanly with `npx skills add owner/repo` now installs cleanly with `apm install owner/repo`. APM recognises the `skills/<name>/SKILL.md` convention used by `vercel-labs/agent-skills`, `xixu-me/skills`, `larksuite/cli`, and the rest of the agentskills.io ecosystem as a first-class package shape (`SKILL_BUNDLE`). `apm.yml` is OPTIONAL for these packages -- adding it is strictly additive (lockfile + pinning) and never regresses installability. Multi-skill bundles install all skills by default; `--skill <NAME>` (repeatable) selects a subset. The selection is **persisted** in `apm.yml` (`skills:` field) and `apm.lock.yaml` (`skill_subset`), so bare `apm install` is deterministic. Use `--skill '*'` to reset to all skills. `apm audit --ci` detects drift between manifest and lockfile skill subsets.

### Fixed

- Fixed TLS validation failure behind corporate TLS-intercepting proxies and firewalls: `install/validation.py` now uses `requests` (honouring `REQUESTS_CA_BUNDLE`) instead of stdlib `urllib`, and surfaces a single CA-trust hint at default verbosity instead of a misleading auth error. (#911)
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ apm install [PACKAGES...] [OPTIONS]
- `--allow-protocol-fallback` - Restore the legacy permissive cross-protocol fallback chain (HTTPS-then-SSH or vice-versa). Strict-by-default otherwise. Each retry emits a `[!]` warning naming both protocols. When the dependency URL carries a custom port, APM also emits a one-shot `[!]` warning before the first clone attempt noting that the same port will be reused across schemes (wrong on servers like Bitbucket Datacenter that serve SSH and HTTPS on different ports) -- to avoid the mismatch, omit this flag and pin the dependency with an explicit `ssh://` or `https://` URL.
- `--no-policy` -- Skip org policy enforcement for this invocation. Loudly logged. Does NOT bypass `apm audit --ci`. Available on `apm install`, `apm install <pkg>`, and `apm install --mcp <name>`.
- Equivalent env var: `APM_POLICY_DISABLE=1` (applies to the entire shell session). Note: `apm deps update` runs the install pipeline and is gated by policy but does not currently expose a `--no-policy` flag -- use `APM_POLICY_DISABLE=1` as the only escape hatch there.
- `--skill NAME` - Install only named skill(s) from a `SKILL_BUNDLE` package. Repeatable. The selection is **persisted** in `apm.yml` (as a `skills:` list in dict-form entries) and in `apm.lock.yaml` (as `skill_subset`), so subsequent bare `apm install` commands are deterministic. Use `--skill '*'` to reset and install all skills from the bundle.

**Transport env vars:**

Expand Down
67 changes: 66 additions & 1 deletion docs/src/content/docs/reference/package-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ sidebar:
order: 4
---

APM supports three package layouts, each with distinct install semantics.
APM supports four package layouts, each with distinct install semantics.
Pick the layout that matches the author's intent -- APM preserves it.

## Layout summary
Expand All @@ -13,6 +13,7 @@ Pick the layout that matches the author's intent -- APM preserves it.
|---|---|---|
| `.apm/` (with or without apm.yml) | "I have N independent primitives" | Hoist each primitive into the target's runtime dirs |
| `SKILL.md` (alone or with apm.yml -- HYBRID) | "I am one skill bundle" | Copy the whole bundle to `<target>/skills/<name>/` |
Comment on lines 14 to 15
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

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

The layout summary says an APM package can be just .apm/ "with or without apm.yml", but the current detection/validation logic only classifies APM_PACKAGE when apm.yml is present (and .apm/ is a directory). Please either update the docs to require apm.yml for .apm/ packages, or adjust detection if "apm.yml optional" is still intended for .apm/-only packages.

See below for a potential fix:

| `apm.yml` + `.apm/` | "I have N independent primitives" | Hoist each primitive into the target's runtime dirs |
| `SKILL.md` (alone or with apm.yml -- HYBRID) | "I am one skill bundle" | Copy the whole bundle to `<target>/skills/<name>/` |
| `skills/<name>/SKILL.md` (nested) | "I ship many skills in one repo" | Promote each nested skill to `<target>/skills/<name>/` |
| `plugin.json` / `.claude-plugin/` | Claude plugin collection | Dissect via plugin artifact mapping |

## APM package (`.apm/` directory)

The classic APM layout. This package type is detected when `apm.yml` is
present at the package root and primitives live under `.apm/` in typed
subdirectories. `apm install` hoists each primitive into the consumer's
runtime directories individually.

Copilot uses AI. Check for mistakes.
| `skills/<name>/SKILL.md` (nested) | "I ship many skills in one repo" | Promote each nested skill to `<target>/skills/<name>/` |
| `plugin.json` / `.claude-plugin/` | Claude plugin collection | Dissect via plugin artifact mapping |

## APM package (`.apm/` directory)
Expand Down Expand Up @@ -96,6 +97,70 @@ agent runtime expects. `apm pack` warns when `apm.yml.description` is
missing so the human-facing surfaces do not degrade silently while
the agent runtime keeps working.

## Skill collection (`skills/<name>/SKILL.md`)

A multi-skill package following the [agentskills.io](https://agentskills.io) /
`npx skills` convention. Each skill lives in its own subdirectory under
`skills/` with its own `SKILL.md`.

An optional `apm.yml` at the root provides version metadata and dependencies.
If absent, APM synthesizes minimal metadata from the directory name.

```
azure-skills/
+-- skills/
| +-- cosmos-db/
| | +-- SKILL.md
| | +-- examples/
| +-- functions/
| | +-- SKILL.md
| +-- aks/
| +-- SKILL.md
+-- apm.yml # optional
```

**What gets installed:** each `skills/<name>/` directory is promoted to
`<target>/skills/<name>/`, preserving internal structure. Equivalent to
installing N separate CLAUDE_SKILL packages.

**Selective install:** use `--skill <name>` to install only specific skills
from the bundle (repeatable). The selection is **persisted** in `apm.yml`
(as a `skills:` field) and `apm.lock.yaml` (as `skill_subset`), so
subsequent bare `apm install` commands are deterministic.
Use `--skill '*'` to reset and install all skills.

```bash
# Install only two skills (persisted to apm.yml):
apm install microsoft/azure-skills --skill cosmos-db --skill functions

# Bare reinstall respects the persisted selection:
apm install

# Reset to all skills:
apm install microsoft/azure-skills --skill '*'
```

The `apm.yml` entry is promoted to dict form with a `skills:` list:

```yaml
dependencies:
apm:
- git: microsoft/azure-skills
skills:
- cosmos-db
- functions
```

**Validation rules:**
- Frontmatter `name` field (if present) must match the directory name.
- Frontmatter `description` should be present (warning if absent).
- All frontmatter values must be ASCII-only.
Comment on lines +155 to +157
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

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

Docs say these validation rules are hard requirements ("must match", "must be ASCII-only"), but the implementation in _validate_skill_bundle only emits warnings for name mismatch and non-ASCII frontmatter. Please align the docs to the actual behavior (warn vs error).

Suggested change
- Frontmatter `name` field (if present) must match the directory name.
- Frontmatter `description` should be present (warning if absent).
- All frontmatter values must be ASCII-only.
- Frontmatter `name` field (if present) should match the directory name;
mismatches emit a warning.
- Frontmatter `description` should be present (warning if absent).
- Frontmatter values should be ASCII-only; non-ASCII values emit a warning.

Copilot uses AI. Check for mistakes.
- Directory names must pass path-traversal checks.

**When to choose:** you maintain a curated collection of independent skills
in one repository (e.g. all Azure skills, all Firebase skills). Consumers
can install the full set or cherry-pick with `--skill`.

## Plugin collection (`plugin.json`)

A Claude-native plugin layout. APM dissects the plugin artifacts and maps
Expand Down
2 changes: 1 addition & 1 deletion packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` refresh refs, `--force` overwrite, `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated), `--dev`, `-g` global, `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--mcp NAME` add MCP entry, `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry |
| `apm install [PKGS...]` | Install APM and MCP dependencies (supports APM packages, Claude skills (SKILL.md), and plugin collections (plugin.json)) | `--update` refresh refs, `--force` overwrite, `--dry-run`, `--verbose`, `--only [apm\|mcp]`, `--target` (comma-separated), `--dev`, `-g` global, `--trust-transitive-mcp`, `--parallel-downloads N`, `--allow-insecure`, `--allow-insecure-host HOSTNAME`, `--skill NAME` install named skill(s) from SKILL_BUNDLE (repeatable; persisted in apm.yml; `'*'` resets to all), `--mcp NAME` add MCP entry, `--transport`, `--url`, `--env KEY=VAL`, `--header KEY=VAL`, `--mcp-version`, `--registry URL` custom MCP registry |
| `apm uninstall PKGS...` | Remove packages | `--dry-run`, `-g` global |
| `apm prune` | Remove orphaned packages | `--dry-run` |
| `apm deps list` | List installed packages | `-g` global, `--all` both scopes, `--insecure` |
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ warn_unused_configs = true
addopts = "-m 'not benchmark'"
markers = [
"integration: marks tests as integration tests that may require network access",
"live: marks tests that hit real GitHub repos (requires network + optional GITHUB_TOKEN)",
"slow: marks tests as slow running tests",
"benchmark: marks performance benchmark tests (deselected by default, run with -m benchmark)",
]
81 changes: 81 additions & 0 deletions src/apm_cli/commands/_apm_yml_writer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Write-back helper for persisting skill subset selection in apm.yml.

Single helper ``set_skill_subset_for_entry`` is the one source of truth
for promoting entries to dict form and setting/clearing the ``skills:``
field. Keeps write-back logic isolated and unit-testable.
"""

from pathlib import Path
from typing import List, Optional

from ..models.dependency.reference import DependencyReference
from ..utils.yaml_io import dump_yaml, load_yaml


def set_skill_subset_for_entry(
manifest_path: Path,
repo_url: str,
subset: Optional[List[str]],
) -> bool:
"""Promote entry to dict form and set/clear skills: field.

subset=None or empty list -> remove skills: from entry (reset to all).
subset=[...] -> set skills: to sorted+deduped list.

Returns True if file was modified.
"""
data = load_yaml(manifest_path) or {}
deps_section = data.get("dependencies", {})
apm_deps = deps_section.get("apm", [])
if not apm_deps:
return False

modified = False
new_deps = []

for entry in apm_deps:
if _entry_matches(entry, repo_url):
entry = _apply_subset(entry, subset)
modified = True
new_deps.append(entry)

if not modified:
return False

deps_section["apm"] = new_deps
data["dependencies"] = deps_section
dump_yaml(data, manifest_path)
return True


def _entry_matches(entry, repo_url: str) -> bool:
"""Check if an apm.yml entry matches the given repo_url."""
try:
if isinstance(entry, str):
ref = DependencyReference.parse(entry)
elif isinstance(entry, dict):
ref = DependencyReference.parse_from_dict(entry)
else:
return False
return ref.repo_url == repo_url
except (ValueError, TypeError, AttributeError, KeyError):
return False


def _apply_subset(entry, subset: Optional[List[str]]):
"""Apply skill subset to an entry, promoting to dict form if needed."""
# Parse current entry to get canonical info
if isinstance(entry, str):
ref = DependencyReference.parse(entry)
elif isinstance(entry, dict):
ref = DependencyReference.parse_from_dict(entry)
else:
return entry

# Determine if we should set or clear
if subset:
ref.skill_subset = sorted(set(subset))
else:
ref.skill_subset = None

return ref.to_apm_yml_entry()
72 changes: 50 additions & 22 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -1050,43 +1050,29 @@ def _run_mcp_install(
"or a stdio command (self-defined entries)."
),
)
@click.option(
"--no-policy",
"no_policy",
is_flag=True,
default=False,
help="Skip org policy enforcement for this invocation. Loudly logged. Does NOT bypass apm audit --ci.",
)
@click.option("--skill", "skill_names", multiple=True, metavar="NAME", help="Install only named skill(s) from a SKILL_BUNDLE. Repeatable. Persisted in apm.yml and apm.lock so bare 'apm install' is deterministic. Use --skill '*' to reset to all skills.")
@click.option("--no-policy", "no_policy", is_flag=True, default=False, help="Skip org policy enforcement for this invocation. Does NOT bypass apm audit --ci.")
@click.pass_context
def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbose, trust_transitive_mcp, parallel_downloads, dev, target, allow_insecure, allow_insecure_hosts, global_, use_ssh, use_https, allow_protocol_fallback, mcp_name, transport, url, env_pairs, header_pairs, mcp_version, registry_url, no_policy):
def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbose, trust_transitive_mcp, parallel_downloads, dev, target, allow_insecure, allow_insecure_hosts, global_, use_ssh, use_https, allow_protocol_fallback, mcp_name, transport, url, env_pairs, header_pairs, mcp_version, registry_url, skill_names, no_policy):
"""Install APM and MCP dependencies from apm.yml (like npm install).

Detects AI runtimes from your apm.yml scripts and installs MCP servers for
all detected runtimes; also installs APM package dependencies from GitHub.
--only filters by type (apm or mcp).

HTTP dependencies require `allow_insecure: true` in apm.yml and
`--allow-insecure` on the install command. Transitive HTTP dependencies are
allowed automatically when they stay on the same host as a direct HTTP
dependency, or explicitly with `--allow-insecure-host <hostname>`.

Examples:
apm install # Install existing deps from apm.yml
apm install org/pkg1 # Add package to apm.yml and install
apm install org/pkg1 org/pkg2 # Add multiple packages and install
apm install --exclude codex # Install for all except Codex CLI
apm install --only=apm # Install only APM dependencies
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/)
apm install --allow-insecure http://my-server.example.com/owner/repo # Install from HTTP URL with allow_insecure
apm install --allow-insecure-host mirror.example.com # Allow transitive HTTP dependencies from mirror.example.com

MCP servers (also: 'apm mcp install'):
apm install --mcp io.github.github/github-mcp-server # registry shorthand
apm install --mcp api --url https://example.com/mcp # remote http/sse
apm install --mcp fetch -- npx -y @modelcontextprotocol/server-fetch # stdio (post-- argv)
apm install --allow-insecure http://... # HTTP URL (needs allow_insecure)
apm install --skill my-skill org/bundle # Install one skill from bundle
apm install --mcp io.github.github/github-mcp-server # MCP registry
apm install --mcp api --url https://example.com/mcp # MCP remote
apm install --mcp fetch -- npx -y @mcp/server-fetch # MCP stdio
"""
# C1 #856: defaults BEFORE try so the finally clause never sees an
# UnboundLocalError if InstallLogger(...) raises during construction.
Expand Down Expand Up @@ -1149,6 +1135,14 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
registry_url=validated_registry_url,
)

# Normalize --skill: '*' means all (same as absent). Reject with --mcp.
_skill_subset = None
if skill_names:
if mcp_name is not None:
raise click.UsageError("--skill cannot be combined with --mcp.")
if not any(s == "*" for s in skill_names):
_skill_subset = builtins.tuple(skill_names)

if mcp_name is not None:
# MCP install routing block. This branch has accreted
# significantly (--mcp / --registry / --transport / --env /
Expand Down Expand Up @@ -1463,11 +1457,41 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
protocol_pref=protocol_pref,
allow_protocol_fallback=allow_protocol_fallback,
no_policy=no_policy,
skill_subset=_skill_subset,
skill_subset_from_cli=bool(skill_names),
)
apm_count = install_result.installed_count
prompt_count = install_result.prompts_integrated
agent_count = install_result.agents_integrated
apm_diagnostics = install_result.diagnostics

# -- Skill subset write-back (Phase 11) --
# When CLI provided --skill on a SKILL_BUNDLE package, persist
# the subset selection in apm.yml so bare `apm install` is
# deterministic.
if skill_names and packages:
from ._apm_yml_writer import set_skill_subset_for_entry

_star_sentinel = any(s == "*" for s in skill_names)
for dep_key, pkg_type in install_result.package_types.items():
if pkg_type == "skill_bundle":
if _star_sentinel:
# Explicit-all: REMOVE any persisted skills:
if set_skill_subset_for_entry(manifest_path, dep_key, None):
logger.success(f"Cleared skill subset for {dep_key}")
else:
subset_list = sorted(builtins.set(_skill_subset))
if set_skill_subset_for_entry(manifest_path, dep_key, subset_list):
logger.success(
f"Persisted skill subset for {dep_key}: "
f"[{', '.join(subset_list)}]"
)
elif pkg_type != "skill_bundle" and not _star_sentinel:
# Non-bundle: warn but do NOT persist
logger.warning(
f"--skill ignored for {dep_key} "
f"(package type: {pkg_type}, not a skill bundle)"
)
except InsecureDependencyPolicyError:
_maybe_rollback_manifest(_snapshot_manifest_path, _manifest_snapshot, logger)
sys.exit(1)
Expand Down Expand Up @@ -1664,6 +1688,8 @@ def _install_apm_dependencies(
protocol_pref=None,
allow_protocol_fallback: "Optional[bool]" = None,
no_policy: bool = False,
skill_subset: "Optional[builtins.tuple]" = None,
skill_subset_from_cli: bool = False,
):
"""Thin wrapper -- builds an :class:`InstallRequest` and delegates to
:class:`apm_cli.install.service.InstallService`.
Expand Down Expand Up @@ -1696,5 +1722,7 @@ def _install_apm_dependencies(
protocol_pref=protocol_pref,
allow_protocol_fallback=allow_protocol_fallback,
no_policy=no_policy,
skill_subset=skill_subset,
skill_subset_from_cli=skill_subset_from_cli,
)
return InstallService().run(request)
5 changes: 5 additions & 0 deletions src/apm_cli/deps/lockfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class LockedDependency:
marketplace_plugin_name: Optional[str] = None # Plugin name in marketplace
is_insecure: bool = False # True when the locked source was http://
allow_insecure: bool = False # True when the manifest explicitly allowed HTTP
skill_subset: List[str] = field(default_factory=list) # Sorted skill names for SKILL_BUNDLE

def get_unique_key(self) -> str:
"""Returns unique key for this dependency."""
Expand Down Expand Up @@ -100,6 +101,8 @@ def to_dict(self) -> Dict[str, Any]:
result["is_insecure"] = True
if self.allow_insecure:
result["allow_insecure"] = True
if self.skill_subset:
result["skill_subset"] = sorted(self.skill_subset)
return result

@classmethod
Expand Down Expand Up @@ -153,6 +156,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "LockedDependency":
marketplace_plugin_name=data.get("marketplace_plugin_name"),
is_insecure=data.get("is_insecure", False),
allow_insecure=data.get("allow_insecure", False),
skill_subset=list(data.get("skill_subset") or []),
)

@classmethod
Expand Down Expand Up @@ -201,6 +205,7 @@ def from_dependency_ref(
is_dev=is_dev,
is_insecure=dep_ref.is_insecure,
allow_insecure=dep_ref.allow_insecure,
skill_subset=sorted(dep_ref.skill_subset) if isinstance(getattr(dep_ref, "skill_subset", None), list) else [],
)

def to_dependency_ref(self) -> DependencyReference:
Expand Down
Loading
Loading