Skip to content

Commit 5d24126

Browse files
Merge branch 'main' into fix/602-init-confirm-loop
2 parents 3ff22f7 + 699ff32 commit 5d24126

File tree

9 files changed

+432
-34
lines changed

9 files changed

+432
-34
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
### Fixed
1616

1717
- Fix `apm init` showing overwrite confirmation prompt three times on Windows CP950 terminals (#602)
18+
- Fix `apm marketplace add` silently failing for private repos by using credentials when probing `marketplace.json` (#701)
1819
- Pin codex setup to `rust-v0.118.0` for security and reproducibility; update config to `wire_api = "responses"` (#663)
1920
- Propagate headers and environment variables through OpenCode MCP adapter with defensive copies to prevent mutation (#622)
2021
- Fix `apm install` hanging indefinitely when corporate firewalls silently drop SSH packets by setting `GIT_SSH_COMMAND` with `ConnectTimeout=30` (#652)

src/apm_cli/commands/config.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def get(key):
147147
apm config get auto-integrate
148148
apm config get
149149
"""
150-
from ..config import get_config, get_auto_integrate
150+
from ..config import get_auto_integrate
151151

152152
logger = CommandLogger("config get")
153153
if key:
@@ -162,12 +162,8 @@ def get(key):
162162
)
163163
sys.exit(1)
164164
else:
165-
# Show all config
166-
config_data = get_config()
165+
# Show all user-settable keys with their effective values (including
166+
# defaults). Iterating raw config keys would hide settings that
167+
# have not been written yet (e.g. auto_integrate on a fresh install).
167168
logger.progress("APM Configuration:")
168-
for k, v in config_data.items():
169-
# Map internal keys to user-friendly names
170-
if k == "auto_integrate":
171-
click.echo(f" auto-integrate: {v}")
172-
else:
173-
click.echo(f" {k}: {v}")
169+
click.echo(f" auto-integrate: {get_auto_integrate()}")

src/apm_cli/deps/github_downloader.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
is_azure_devops_hostname,
4343
is_github_hostname
4444
)
45+
from ..utils.yaml_io import yaml_to_str
4546

4647

4748
def normalize_collection_path(virtual_path: str) -> str:
@@ -1515,11 +1516,13 @@ def download_virtual_file_package(self, dep_ref: DependencyReference, target_pat
15151516
# If frontmatter parsing fails, use default description
15161517
pass
15171518

1518-
apm_yml_content = f"""name: {package_name}
1519-
version: 1.0.0
1520-
description: {description}
1521-
author: {dep_ref.repo_url.split('/')[0]}
1522-
"""
1519+
apm_yml_data = {
1520+
"name": package_name,
1521+
"version": "1.0.0",
1522+
"description": description,
1523+
"author": dep_ref.repo_url.split('/')[0],
1524+
}
1525+
apm_yml_content = yaml_to_str(apm_yml_data)
15231526

15241527
apm_yml_path = target_path / "apm.yml"
15251528
apm_yml_path.write_text(apm_yml_content, encoding='utf-8')
@@ -1655,17 +1658,15 @@ def download_collection_package(self, dep_ref: DependencyReference, target_path:
16551658
# Generate apm.yml with collection metadata
16561659
package_name = dep_ref.get_virtual_package_name()
16571660

1658-
apm_yml_content = f"""name: {package_name}
1659-
version: 1.0.0
1660-
description: {manifest.description}
1661-
author: {dep_ref.repo_url.split('/')[0]}
1662-
"""
1663-
1664-
# Add tags if present
1661+
apm_yml_data = {
1662+
"name": package_name,
1663+
"version": "1.0.0",
1664+
"description": manifest.description,
1665+
"author": dep_ref.repo_url.split('/')[0],
1666+
}
16651667
if manifest.tags:
1666-
apm_yml_content += f"\ntags:\n"
1667-
for tag in manifest.tags:
1668-
apm_yml_content += f" - {tag}\n"
1668+
apm_yml_data["tags"] = list(manifest.tags)
1669+
apm_yml_content = yaml_to_str(apm_yml_data)
16691670

16701671
apm_yml_path = target_path / "apm.yml"
16711672
apm_yml_path.write_text(apm_yml_content, encoding='utf-8')

src/apm_cli/integration/hook_integrator.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,11 @@ def _integrate_merged_hooks(
491491
hooks_integrated = 0
492492
scripts_copied = 0
493493
target_paths: List[Path] = []
494+
# Events whose prior-owned entries have already been cleared on
495+
# this install run. Packages can contribute to the same event
496+
# from multiple hook files — we must only strip once so earlier
497+
# files' fresh entries aren't wiped by later iterations.
498+
cleared_events: set = set()
494499

495500
# Read existing JSON config
496501
json_path = target_dir / config.config_filename
@@ -531,6 +536,20 @@ def _integrate_merged_hooks(
531536
if isinstance(entry, dict):
532537
entry["_apm_source"] = package_name
533538

539+
# Idempotent upsert: drop any prior entries owned by this
540+
# package before appending fresh ones. Without this, every
541+
# `apm install` re-run duplicates the package's hooks
542+
# because `.extend()` is unconditional. See microsoft/apm#708.
543+
# Only strip once per event per install run — a package
544+
# with multiple hook files targeting the same event
545+
# contributes each file's entries in turn, and stripping
546+
# on every iteration would erase earlier files' work.
547+
if event_name not in cleared_events:
548+
json_config["hooks"][event_name] = [
549+
e for e in json_config["hooks"][event_name]
550+
if not (isinstance(e, dict) and e.get("_apm_source") == package_name)
551+
]
552+
cleared_events.add(event_name)
534553
json_config["hooks"][event_name].extend(entries)
535554

536555
hooks_integrated += 1

src/apm_cli/marketplace/client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Fetch, parse, and cache marketplace.json from GitHub repositories.
22
3-
Uses ``AuthResolver.try_with_fallback(unauth_first=True)`` for public-first
4-
access with automatic credential fallback for private marketplace repos.
3+
Uses ``AuthResolver.try_with_fallback(unauth_first=False)`` for auth-first
4+
access so private marketplace repos are fetched with credentials when available.
55
When ``PROXY_REGISTRY_URL`` is set, fetches are routed through the registry
66
proxy (Artifactory Archive Entry Download) before falling back to the
77
GitHub Contents API. When ``PROXY_REGISTRY_ONLY=1``, the GitHub fallback
@@ -243,7 +243,7 @@ def _do_fetch(token, _git_env):
243243
source.host,
244244
_do_fetch,
245245
org=source.owner,
246-
unauth_first=True,
246+
unauth_first=False,
247247
)
248248
except Exception as exc:
249249
raise MarketplaceFetchError(source.name, str(exc)) from exc

tests/test_github_downloader.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1656,5 +1656,181 @@ def test_try_raw_download_returns_content_on_200(self):
16561656
assert result == b'hello world'
16571657

16581658

1659+
class TestVirtualFilePackageYamlGeneration:
1660+
"""Tests that apm.yml for virtual packages is always valid YAML."""
1661+
1662+
def _make_dep_ref(self, virtual_path):
1663+
"""Helper: build a minimal DependencyReference for a virtual file."""
1664+
from apm_cli.models.apm_package import DependencyReference
1665+
dep_ref = Mock(spec=DependencyReference)
1666+
dep_ref.is_virtual = True
1667+
dep_ref.virtual_path = virtual_path
1668+
dep_ref.reference = "main"
1669+
dep_ref.repo_url = "github/awesome-copilot"
1670+
dep_ref.get_virtual_package_name.return_value = "awesome-copilot-swe-subagent"
1671+
dep_ref.to_github_url.return_value = f"https://github.com/github/awesome-copilot/blob/main/{virtual_path}"
1672+
dep_ref.is_virtual_file.return_value = True
1673+
dep_ref.VIRTUAL_FILE_EXTENSIONS = [".prompt.md", ".instructions.md", ".chatmode.md", ".agent.md"]
1674+
return dep_ref
1675+
1676+
def _make_collection_dep_ref(self, virtual_path):
1677+
"""Helper: build a minimal DependencyReference for a virtual collection."""
1678+
from apm_cli.models.apm_package import DependencyReference
1679+
dep_ref = Mock(spec=DependencyReference)
1680+
dep_ref.is_virtual = True
1681+
dep_ref.virtual_path = virtual_path
1682+
dep_ref.reference = "main"
1683+
dep_ref.repo_url = "github/my-org"
1684+
dep_ref.get_virtual_package_name.return_value = "my-org-my-collection"
1685+
dep_ref.to_github_url.return_value = f"https://github.com/github/my-org/blob/main/{virtual_path}"
1686+
dep_ref.is_virtual_collection.return_value = True
1687+
return dep_ref
1688+
1689+
def test_yaml_with_colon_in_description(self, tmp_path):
1690+
"""apm.yml must be valid when the agent description contains a colon."""
1691+
import yaml
1692+
1693+
agent_content = (
1694+
b"---\n"
1695+
b"name: 'SWE'\n"
1696+
b"description: 'Senior software engineer subagent for implementation tasks:"
1697+
b" feature development, debugging, refactoring, and testing.'\n"
1698+
b"tools: ['vscode']\n"
1699+
b"---\n\n## Body\n"
1700+
)
1701+
1702+
dep_ref = self._make_dep_ref("agents/swe-subagent.agent.md")
1703+
target_path = tmp_path / "pkg"
1704+
1705+
downloader = GitHubPackageDownloader()
1706+
with patch.dict(os.environ, {}, clear=True), _CRED_FILL_PATCH:
1707+
with patch.object(downloader, "download_raw_file", return_value=agent_content):
1708+
downloader.download_virtual_file_package(dep_ref, target_path)
1709+
1710+
apm_yml_path = target_path / "apm.yml"
1711+
assert apm_yml_path.exists(), "apm.yml was not created"
1712+
1713+
content = apm_yml_path.read_text(encoding="utf-8")
1714+
parsed = yaml.safe_load(content) # must not raise
1715+
1716+
expected = (
1717+
"Senior software engineer subagent for implementation tasks:"
1718+
" feature development, debugging, refactoring, and testing."
1719+
)
1720+
assert parsed["description"] == expected
1721+
1722+
def test_yaml_with_colon_in_name(self, tmp_path):
1723+
"""apm.yml must be valid even when the package name contains a colon."""
1724+
import yaml
1725+
1726+
dep_ref = self._make_dep_ref("agents/my-agent.agent.md")
1727+
dep_ref.get_virtual_package_name.return_value = "org-name: special"
1728+
1729+
agent_content = b"---\nname: 'plain'\ndescription: 'plain'\n---\n"
1730+
target_path = tmp_path / "pkg"
1731+
1732+
downloader = GitHubPackageDownloader()
1733+
with patch.dict(os.environ, {}, clear=True), _CRED_FILL_PATCH:
1734+
with patch.object(downloader, "download_raw_file", return_value=agent_content):
1735+
downloader.download_virtual_file_package(dep_ref, target_path)
1736+
1737+
content = (target_path / "apm.yml").read_text(encoding="utf-8")
1738+
parsed = yaml.safe_load(content)
1739+
assert parsed["name"] == "org-name: special"
1740+
1741+
def test_yaml_without_special_characters_still_valid(self, tmp_path):
1742+
"""apm.yml generation must still work for ordinary descriptions."""
1743+
import yaml
1744+
1745+
agent_content = (
1746+
b"---\n"
1747+
b"name: 'Simple Agent'\n"
1748+
b"description: 'A simple agent without special chars'\n"
1749+
b"---\n"
1750+
)
1751+
1752+
dep_ref = self._make_dep_ref("agents/simple.agent.md")
1753+
target_path = tmp_path / "pkg"
1754+
1755+
downloader = GitHubPackageDownloader()
1756+
with patch.dict(os.environ, {}, clear=True), _CRED_FILL_PATCH:
1757+
with patch.object(downloader, "download_raw_file", return_value=agent_content):
1758+
downloader.download_virtual_file_package(dep_ref, target_path)
1759+
1760+
content = (target_path / "apm.yml").read_text(encoding="utf-8")
1761+
parsed = yaml.safe_load(content)
1762+
assert parsed["description"] == "A simple agent without special chars"
1763+
1764+
def test_collection_yaml_with_colon_in_description(self, tmp_path):
1765+
"""apm.yml for collection packages must be valid when description contains a colon."""
1766+
import yaml
1767+
1768+
# A minimal .collection.yml whose description contains ":"
1769+
collection_manifest = (
1770+
b"id: my-collection\n"
1771+
b"name: My Collection\n"
1772+
b"description: 'A collection for tasks: feature development, debugging.'\n"
1773+
b"items:\n"
1774+
b" - path: agents/my-agent.agent.md\n"
1775+
b" kind: agent\n"
1776+
)
1777+
agent_file = b"---\nname: My Agent\n---\n## Body\n"
1778+
1779+
dep_ref = self._make_collection_dep_ref("collections/my-collection")
1780+
target_path = tmp_path / "pkg"
1781+
1782+
downloader = GitHubPackageDownloader()
1783+
1784+
def _fake_download(dep_ref_arg, path, ref):
1785+
if "collection" in path:
1786+
return collection_manifest
1787+
return agent_file
1788+
1789+
with patch.dict(os.environ, {}, clear=True), _CRED_FILL_PATCH:
1790+
with patch.object(downloader, "download_raw_file", side_effect=_fake_download):
1791+
downloader.download_collection_package(dep_ref, target_path)
1792+
1793+
content = (target_path / "apm.yml").read_text(encoding="utf-8")
1794+
parsed = yaml.safe_load(content) # must not raise
1795+
1796+
assert parsed["description"] == "A collection for tasks: feature development, debugging."
1797+
1798+
def test_collection_yaml_with_colon_in_tags(self, tmp_path):
1799+
"""apm.yml for collection packages must be valid when tags contain a colon."""
1800+
import yaml
1801+
1802+
collection_manifest = (
1803+
b"id: tagged-collection\n"
1804+
b"name: Tagged\n"
1805+
b"description: Normal description\n"
1806+
b"tags:\n"
1807+
b" - 'scope: engineering'\n"
1808+
b" - plain-tag\n"
1809+
b"items:\n"
1810+
b" - path: agents/my-agent.agent.md\n"
1811+
b" kind: agent\n"
1812+
)
1813+
agent_file = b"---\nname: My Agent\n---\n## Body\n"
1814+
1815+
dep_ref = self._make_collection_dep_ref("collections/tagged-collection")
1816+
target_path = tmp_path / "pkg"
1817+
1818+
downloader = GitHubPackageDownloader()
1819+
1820+
def _fake_download(dep_ref_arg, path, ref):
1821+
if "collection" in path:
1822+
return collection_manifest
1823+
return agent_file
1824+
1825+
with patch.dict(os.environ, {}, clear=True), _CRED_FILL_PATCH:
1826+
with patch.object(downloader, "download_raw_file", side_effect=_fake_download):
1827+
downloader.download_collection_package(dep_ref, target_path)
1828+
1829+
content = (target_path / "apm.yml").read_text(encoding="utf-8")
1830+
parsed = yaml.safe_load(content)
1831+
1832+
assert parsed["tags"] == ["scope: engineering", "plain-tag"]
1833+
1834+
16591835
if __name__ == '__main__':
16601836
pytest.main([__file__])

0 commit comments

Comments
 (0)