Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
10fc88e
feat: support manifest namespaces for package skills
shreejaykurhade Apr 29, 2026
505f7e7
Merge remote-tracking branch 'origin/main' into feature/support-packa…
shreejaykurhade Apr 29, 2026
bf60ab0
docs: clarify namespaced skill lockfile example
shreejaykurhade Apr 29, 2026
618992e
docs: use placeholder domains in lockfile examples
shreejaykurhade Apr 29, 2026
5c4f2da
docs: clean up namespace examples
shreejaykurhade Apr 29, 2026
f64a6db
test: use neutral namespace examples
shreejaykurhade Apr 29, 2026
cb796e9
docs: align namespace examples with guide style
shreejaykurhade Apr 29, 2026
b407793
Merge branch 'main' into feature/support-package-namespaces
shreejaykurhade Apr 29, 2026
1dfc5c2
Merge branch 'main' into feature/support-package-namespaces
shreejaykurhade Apr 29, 2026
ab18a84
Merge branch 'main' into feature/support-package-namespaces
shreejaykurhade Apr 30, 2026
d099289
Merge branch 'main' into feature/support-package-namespaces
Apr 30, 2026
5fea93a
fix(skills): align self-entry ownership test with namespace-aware keying
Apr 30, 2026
d960d8e
style: ruff format
Copilot Apr 30, 2026
b70a76b
fix(namespace): surface namespace at every user-facing surface (PR #1…
Apr 30, 2026
4d7135d
fix(namespace): close round-3 panel findings (containment + logging)
Apr 30, 2026
0930705
fix(skills): make namespace deployment harness-safe
shreejaykurhade May 1, 2026
a763f29
Merge origin/main into feature/support-package-namespaces
shreejaykurhade May 7, 2026
b9d2753
Merge remote-tracking branch 'upstream/main' into feature/support-pac…
shreejaykurhade May 9, 2026
76298c0
fix(namespace): surface install paths and restore mcp warning tests
shreejaykurhade May 9, 2026
41caac8
Merge branch 'main' into feature/support-package-namespaces
shreejaykurhade May 9, 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
16 changes: 16 additions & 0 deletions docs/src/content/docs/guides/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,22 @@ name: my-project
target: vscode # or claude, or all
```

## Package Namespaces

Package authors can opt in to namespaced skill deployment with `namespace` in
`apm.yml`:

```yaml
name: acme-tools
version: 1.0.0
namespace: acme
type: skill
```

With this manifest, APM deploys package-owned skills under
`.github/skills/acme/<skill-name>/` (and the equivalent skills directory for
other targets). Packages without `namespace` keep the legacy flat layout.

## Best Practices

### 1. Clear Naming
Expand Down
5 changes: 3 additions & 2 deletions docs/src/content/docs/reference/lockfile-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ dependencies:
version: "2.1.0"
depth: 1
package_type: apm_package
namespace: acme
deployed_files:
- .github/instructions/security.instructions.md
- .github/agents/security-auditor.agent.md
- .github/skills/acme/security-review

- repo_url: https://github.com/acme-corp/common-prompts
resolved_commit: f6e5d4c3b2a1098765432109876543210fedcba9
Expand Down Expand Up @@ -121,6 +121,7 @@ fields:
| `depth` | integer | MUST | Dependency depth. `1` = direct dependency, `2`+ = transitive. |
| `resolved_by` | string | MAY | `repo_url` of the parent that introduced this transitive dependency. Present only when `depth >= 2`. |
| `package_type` | string | MUST | Package type: `apm_package`, `plugin`, `virtual`, or other registered types. |
| `namespace` | string | MAY | Manifest-declared namespace applied to package-owned skills. Omitted for legacy flat installs. |
| `content_hash` | string | MAY | SHA-256 hash of the package file tree, in the format `"sha256:<hex>"`. Used to verify cached packages on subsequent installs. Omitted for local path dependencies. See [section 4.4](#44-content-integrity). |
| `is_dev` | boolean | MAY | `true` if the dependency was resolved through [`devDependencies`](../manifest-schema/#5-devdependencies). Omitted when `false`. Dev deps are excluded from `apm pack --format plugin` bundles. |
| `deployed_files` | array of strings | MUST | Every file path APM deployed for this dependency, relative to project root. |
Expand Down
32 changes: 29 additions & 3 deletions docs/src/content/docs/reference/manifest-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ author: <string>
license: <string>
target: <enum>
type: <enum>
namespace: <string>
scripts: <map<string, string>>
includes: <enum | list<string>>
dependencies:
Expand Down Expand Up @@ -155,7 +156,32 @@ Declares how the package's content is processed during install and compile. Curr
| `hybrid` | Both AGENTS.md compilation and skill installation. |
| `prompts` | Commands/prompts only. No instructions or skills. |

### 3.8. `scripts`
### 3.8. `namespace`

| | |
|---|---|
| **Type** | `string` |
| **Required** | OPTIONAL |
| **Pattern** | `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |
| **Description** | Optional namespace for package-owned skills. |

When present, installed native skills and promoted `.apm/skills/` entries are
deployed under `skills/<namespace>/<skill-name>/` instead of the legacy flat
`skills/<skill-name>/` layout. Packages without `namespace` continue to install
flat for backward compatibility.

Namespace values MUST be a single safe path segment. Resolvers MUST reject empty
values, traversal (`.` or `..`), path separators, uppercase characters, and
filesystem-unsafe punctuation.

```yaml
name: acme-tools
version: 1.0.0
namespace: acme
type: skill
```

### 3.9. `scripts`

| | |
|---|---|
Expand All @@ -165,7 +191,7 @@ Declares how the package's content is processed during install and compile. Curr
| **Value** | Shell command string |
| **Description** | Named commands executed via `apm run <name>`. MUST support `--param key=value` substitution. |

### 3.9. `includes`
### 3.10. `includes`

| | |
|---|---|
Expand Down Expand Up @@ -197,7 +223,7 @@ includes: auto

When `policy.manifest.require_explicit_includes` is `true` (see [Governance guide](../../enterprise/governance-guide/)), only form 3 passes the policy check; `auto` and undeclared are rejected at install/audit time by the `explicit-includes` policy check (not at YAML parse time).

### 3.10. `policy`
### 3.11. `policy`

| | |
|---|---|
Expand Down
4 changes: 4 additions & 0 deletions src/apm_cli/deps/lockfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class LockedDependency:
depth: int = 1
resolved_by: Optional[str] = None
package_type: Optional[str] = None
namespace: Optional[str] = None
deployed_files: List[str] = field(default_factory=list)
deployed_file_hashes: Dict[str, str] = field(default_factory=dict)
source: Optional[str] = None # "local" for local deps, None/absent for remote
Expand Down Expand Up @@ -79,6 +80,8 @@ def to_dict(self) -> Dict[str, Any]:
result["resolved_by"] = self.resolved_by
if self.package_type:
result["package_type"] = self.package_type
if self.namespace:
result["namespace"] = self.namespace
if self.deployed_files:
result["deployed_files"] = sorted(self.deployed_files)
if self.deployed_file_hashes:
Expand Down Expand Up @@ -146,6 +149,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "LockedDependency":
depth=data.get("depth", 1),
resolved_by=data.get("resolved_by"),
package_type=data.get("package_type"),
namespace=data.get("namespace"),
deployed_files=deployed_files,
deployed_file_hashes=dict(data.get("deployed_file_hashes") or {}),
source=data.get("source"),
Expand Down
28 changes: 21 additions & 7 deletions src/apm_cli/drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,17 +244,25 @@ def build_download_ref(
locked_dep = existing_lockfile.get_dependency(dep_ref.get_unique_key())
if locked_dep:
overrides: Dict[str, Any] = {}
locked_host = getattr(locked_dep, "host", None)
locked_registry_prefix = getattr(locked_dep, "registry_prefix", None)
locked_resolved_ref = getattr(locked_dep, "resolved_ref", None)

# Prefer the lockfile host so re-installs fetch from the exact same
# source (proxy host preserved) — fixes air-gapped reproducibility.
# When registry_prefix is set, also restore the artifactory_prefix
# field on dep_ref so the downloader takes the proxy code-path and
# uses PROXY_REGISTRY_TOKEN for auth instead of the GitHub PAT.
if locked_dep.registry_prefix and locked_dep.host:
overrides["host"] = locked_dep.host
overrides["artifactory_prefix"] = locked_dep.registry_prefix
elif isinstance(getattr(locked_dep, "host", None), str) and locked_dep.host != dep_ref.host:
overrides["host"] = locked_dep.host
if (
isinstance(locked_registry_prefix, str)
and locked_registry_prefix
and isinstance(locked_host, str)
and locked_host
):
overrides["host"] = locked_host
overrides["artifactory_prefix"] = locked_registry_prefix
elif isinstance(locked_host, str) and locked_host != dep_ref.host:
overrides["host"] = locked_host

if getattr(locked_dep, "is_insecure", False) is True:
overrides["is_insecure"] = True
Expand All @@ -267,8 +275,14 @@ def build_download_ref(
overrides["reference"] = locked_dep.resolved_commit
# For proxy deps without a commit SHA (Artifactory zip archives),
# preserve the locked ref so we download the same ref on replay.
elif locked_dep.registry_prefix and locked_dep.resolved_ref and not dep_ref.reference:
overrides["reference"] = locked_dep.resolved_ref
elif (
isinstance(locked_registry_prefix, str)
and locked_registry_prefix
and isinstance(locked_resolved_ref, str)
and locked_resolved_ref
and not dep_ref.reference
):
overrides["reference"] = locked_resolved_ref

if overrides:
return _dataclass_replace(dep_ref, **overrides)
Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/install/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ class InstallContext:
intended_dep_keys: Set[str] = field(default_factory=set)
package_deployed_files: Dict[str, List[str]] = field(default_factory=dict)
package_types: Dict[str, str] = field(default_factory=dict)
package_namespaces: Dict[str, str] = field(default_factory=dict)
package_hashes: Dict[str, str] = field(default_factory=dict)
installed_count: int = 0 # integrate
unpinned_count: int = 0 # integrate
Expand Down
6 changes: 6 additions & 0 deletions src/apm_cli/install/phases/lockfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def build_and_save(self) -> None:
# Attach deployed_files and package_type to each LockedDependency
self._attach_deployed_files(lockfile)
self._attach_package_types(lockfile)
self._attach_package_namespaces(lockfile)
# Apply CLI --skill override to lockfile entries (skill_bundle only)
self._attach_skill_subset_override(lockfile)
# Attach content hashes captured at download/verify time
Expand Down Expand Up @@ -126,6 +127,11 @@ def _attach_package_types(self, lockfile: LockFile) -> None:
if dep_key in lockfile.dependencies:
lockfile.dependencies[dep_key].package_type = pkg_type

def _attach_package_namespaces(self, lockfile: LockFile) -> None:
for dep_key, namespace in self.ctx.package_namespaces.items():
if dep_key in lockfile.dependencies:
lockfile.dependencies[dep_key].namespace = namespace

def _attach_skill_subset_override(self, lockfile: LockFile) -> None:
"""Apply CLI --skill override to lockfile skill_bundle entries.

Expand Down
6 changes: 6 additions & 0 deletions src/apm_cli/install/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ def acquire(self) -> Optional[Materialization]:

if local_info.package_type:
ctx.package_types[dep_key] = local_info.package_type.value
if getattr(local_info.package, "namespace", None):
ctx.package_namespaces[dep_key] = local_info.package.namespace

return Materialization(
package_info=local_info,
Expand Down Expand Up @@ -361,6 +363,8 @@ def acquire(self) -> Optional[Materialization]:
ctx.package_hashes[dep_key] = _compute_hash(install_path)
if cached_package_info.package_type:
ctx.package_types[dep_key] = cached_package_info.package_type.value
if getattr(cached_package_info.package, "namespace", None):
ctx.package_namespaces[dep_key] = cached_package_info.package.namespace

return Materialization(
package_info=cached_package_info,
Expand Down Expand Up @@ -529,6 +533,8 @@ def acquire(self) -> Optional[Materialization]:

if hasattr(package_info, "package_type") and package_info.package_type:
ctx.package_types[dep_key] = package_info.package_type.value
if getattr(package_info.package, "namespace", None):
ctx.package_namespaces[dep_key] = package_info.package.namespace

if hasattr(package_info, "package_type"):
package_type = package_info.package_type
Expand Down
Loading
Loading