Skip to content

Commit fddc56b

Browse files
Merge branch 'main' into fix/198-update-stale-lockfile
2 parents 720337e + 6f499d8 commit fddc56b

3 files changed

Lines changed: 250 additions & 12 deletions

File tree

docs/cli-reference.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -264,11 +264,11 @@ This makes all package prompts available in VSCode, Claude Code, and compatible
264264
Remove installed APM packages and their integrated files.
265265

266266
```bash
267-
apm uninstall PACKAGE [OPTIONS]
267+
apm uninstall [OPTIONS] PACKAGES...
268268
```
269269

270270
**Arguments:**
271-
- `PACKAGE` - Package to uninstall. Accepts any format — shorthand (`owner/repo`), HTTPS URL, SSH URL, or FQDN. APM resolves it to the canonical identity stored in `apm.yml`.
271+
- `PACKAGES...` - One or more packages to uninstall. Accepts any format — shorthand (`owner/repo`), HTTPS URL, SSH URL, or FQDN. APM resolves each to the canonical identity stored in `apm.yml`.
272272

273273
**Options:**
274274
- `--dry-run` - Show what would be removed without removing
@@ -672,23 +672,23 @@ Available scripts:
672672
debug: RUST_LOG=debug codex hello-world.prompt.md
673673
```
674674

675-
### `apm compile` - 📝 Compile APM context files into AGENTS.md
675+
### `apm compile` - 📝 Compile APM context into distributed AGENTS.md files
676676

677-
Compile APM context files (chatmodes, instructions, contexts) into a single intelligent AGENTS.md file with conditional sections, markdown link resolution, and project setup auto-detection.
677+
Compile APM context files (chatmodes, instructions, contexts) into distributed AGENTS.md files with conditional sections, markdown link resolution, and project setup auto-detection.
678678

679679
```bash
680680
apm compile [OPTIONS]
681681
```
682682

683683
**Options:**
684-
- `-o, --output TEXT` - Output file path (default: AGENTS.md)
684+
- `-o, --output TEXT` - Output file path (for single-file mode)
685685
- `-t, --target [vscode|agents|claude|all]` - Target agent format. `agents` is an alias for `vscode`. Auto-detects if not specified.
686686
- `--chatmode TEXT` - Chatmode to prepend to the AGENTS.md file
687-
- `--dry-run` - Generate content without writing file
687+
- `--dry-run` - Preview compilation without writing files (shows placement decisions)
688688
- `--no-links` - Skip markdown link resolution
689689
- `--with-constitution/--no-constitution` - Include Spec Kit `memory/constitution.md` verbatim at top inside a delimited block (default: `--with-constitution`). When disabled, any existing block is preserved but not regenerated.
690690
- `--watch` - Auto-regenerate on changes (file system monitoring)
691-
- `--validate` - Validate context without compiling
691+
- `--validate` - Validate primitives without compiling
692692
- `--single-agents` - Force single-file compilation (legacy mode)
693693
- `-v, --verbose` - Show detailed source attribution and optimizer analysis
694694
- `--local-only` - Ignore dependencies, compile only local primitives

src/apm_cli/cli.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ def cli(ctx):
313313
@cli.command(help="🚀 Initialize a new APM project")
314314
@click.argument("project_name", required=False)
315315
@click.option(
316-
"--yes", "-y", is_flag=True, help="Skip prompts and use auto-detected defaults"
316+
"--yes", "-y", is_flag=True, help="Skip interactive prompts and use auto-detected defaults"
317317
)
318318
@click.pass_context
319319
def init(ctx, project_name, yes):
@@ -4241,7 +4241,7 @@ def config(ctx):
42414241
click.echo(f" APM CLI Version: {get_version()}")
42424242

42434243

4244-
@config.command(help="Set configuration value")
4244+
@config.command(help="Set a configuration value")
42454245
@click.argument("key")
42464246
@click.argument("value")
42474247
def set(key, value):
@@ -4272,7 +4272,7 @@ def set(key, value):
42724272
sys.exit(1)
42734273

42744274

4275-
@config.command(help="Get configuration value")
4275+
@config.command(help="Get a configuration value")
42764276
@click.argument("key", required=False)
42774277
def get(key):
42784278
"""Get a configuration value or show all configuration.
@@ -4306,7 +4306,7 @@ def get(key):
43064306
click.echo(f" {k}: {v}")
43074307

43084308

4309-
@cli.group(help="Manage Coding Agent CLI runtimes")
4309+
@cli.group(help="Manage AI runtimes")
43104310
def runtime():
43114311
"""Manage Coding Agent CLI runtime installations and configurations."""
43124312
pass
@@ -4330,7 +4330,7 @@ def _atomic_write(path: Path, data: str) -> None:
43304330
raise
43314331

43324332

4333-
@cli.group(help="Manage MCP servers")
4333+
@cli.group(help="Browse MCP server registry")
43344334
def mcp():
43354335
"""Manage MCP server discovery and information."""
43364336
pass

tests/unit/test_install_update.py

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
"""Tests for --update flag behavior in install command (Bug #190).
2+
3+
Verifies that `apm install --update` bypasses lockfile-pinned SHAs
4+
and re-fetches the latest content, especially for subdirectory packages.
5+
"""
6+
7+
from unittest.mock import Mock
8+
9+
from apm_cli.models.apm_package import DependencyReference
10+
11+
12+
class TestSkipDownloadWithUpdateFlag:
13+
"""Test that skip_download respects --update (update_refs=True).
14+
15+
The skip_download condition must NOT skip when update_refs is True,
16+
even if the package was already resolved by the BFS callback.
17+
"""
18+
19+
def _build_skip_download(self, *, install_path_exists, is_cacheable, update_refs,
20+
already_resolved, lockfile_match):
21+
"""Reproduce the skip_download condition from cli.py.
22+
23+
Note: ``already_resolved`` is intentionally NOT gated by ``update_refs``.
24+
When the BFS resolver callback downloads a package during this run it is
25+
always a fresh fetch (the callback itself skips lockfile overrides when
26+
``update_refs=True``), so re-downloading would be redundant.
27+
"""
28+
return install_path_exists and (
29+
(is_cacheable and not update_refs) or already_resolved or lockfile_match
30+
)
31+
32+
def test_already_resolved_skips_without_update(self):
33+
"""Without --update, already_resolved packages should be skipped."""
34+
assert self._build_skip_download(
35+
install_path_exists=True,
36+
is_cacheable=False,
37+
update_refs=False,
38+
already_resolved=True,
39+
lockfile_match=False,
40+
) is True
41+
42+
def test_already_resolved_still_skips_with_update(self):
43+
"""With --update, already_resolved packages are still skipped because
44+
the BFS callback already fetched them fresh in this run."""
45+
assert self._build_skip_download(
46+
install_path_exists=True,
47+
is_cacheable=False,
48+
update_refs=True,
49+
already_resolved=True,
50+
lockfile_match=False,
51+
) is True
52+
53+
def test_cacheable_skips_without_update(self):
54+
"""Without --update, cacheable (tag/commit) packages should be skipped."""
55+
assert self._build_skip_download(
56+
install_path_exists=True,
57+
is_cacheable=True,
58+
update_refs=False,
59+
already_resolved=False,
60+
lockfile_match=False,
61+
) is True
62+
63+
def test_cacheable_does_not_skip_with_update(self):
64+
"""With --update, cacheable packages must NOT be skipped."""
65+
assert self._build_skip_download(
66+
install_path_exists=True,
67+
is_cacheable=True,
68+
update_refs=True,
69+
already_resolved=False,
70+
lockfile_match=False,
71+
) is False
72+
73+
def test_lockfile_match_always_skips(self):
74+
"""lockfile_match should always skip (not gated by update_refs because
75+
the lockfile_match check itself is already gated by `not update_refs`)."""
76+
assert self._build_skip_download(
77+
install_path_exists=True,
78+
is_cacheable=False,
79+
update_refs=True,
80+
already_resolved=False,
81+
lockfile_match=True,
82+
) is True
83+
84+
def test_no_install_path_never_skips(self):
85+
"""If install path doesn't exist, never skip regardless of other flags."""
86+
assert self._build_skip_download(
87+
install_path_exists=False,
88+
is_cacheable=True,
89+
update_refs=False,
90+
already_resolved=True,
91+
lockfile_match=True,
92+
) is False
93+
94+
95+
class TestDownloadRefLockfileOverride:
96+
"""Test that lockfile SHA override is gated behind `not update_refs`.
97+
98+
When --update is used, the download ref should NOT be overridden with
99+
the lockfile's pinned SHA. The package should be fetched at its
100+
original reference (or default branch).
101+
"""
102+
103+
@staticmethod
104+
def _build_download_ref(dep_ref, existing_lockfile, update_refs):
105+
"""Reproduce the download_ref construction logic from cli.py.
106+
107+
This mirrors the sequential download path. The same logic applies
108+
to the parallel pre-download path.
109+
"""
110+
download_ref = str(dep_ref)
111+
if existing_lockfile and not update_refs:
112+
locked_dep = existing_lockfile.get_dependency(dep_ref.get_unique_key())
113+
if locked_dep and locked_dep.resolved_commit and locked_dep.resolved_commit != "cached":
114+
base_ref = dep_ref.repo_url
115+
if dep_ref.virtual_path:
116+
base_ref = f"{base_ref}/{dep_ref.virtual_path}"
117+
download_ref = f"{base_ref}#{locked_dep.resolved_commit}"
118+
return download_ref
119+
120+
def _make_subdirectory_dep(self):
121+
return DependencyReference(
122+
repo_url="owner/monorepo",
123+
host="github.com",
124+
reference=None,
125+
virtual_path="packages/my-skill",
126+
is_virtual=True,
127+
)
128+
129+
def _make_regular_dep(self):
130+
return DependencyReference(
131+
repo_url="owner/repo",
132+
host="github.com",
133+
reference="main",
134+
)
135+
136+
def _mock_lockfile(self, dep_ref, resolved_commit="abc123def456"):
137+
lockfile = Mock()
138+
locked_dep = Mock()
139+
locked_dep.resolved_commit = resolved_commit
140+
lockfile.get_dependency = Mock(return_value=locked_dep)
141+
return lockfile
142+
143+
def test_subdirectory_lockfile_override_without_update(self):
144+
"""Without --update, subdirectory download ref uses locked SHA."""
145+
dep = self._make_subdirectory_dep()
146+
lockfile = self._mock_lockfile(dep)
147+
148+
ref = self._build_download_ref(dep, lockfile, update_refs=False)
149+
assert "#abc123def456" in ref
150+
assert ref == "owner/monorepo/packages/my-skill#abc123def456"
151+
152+
def test_subdirectory_no_lockfile_override_with_update(self):
153+
"""With --update, subdirectory download ref must NOT use locked SHA."""
154+
dep = self._make_subdirectory_dep()
155+
lockfile = self._mock_lockfile(dep)
156+
157+
ref = self._build_download_ref(dep, lockfile, update_refs=True)
158+
assert "#abc123def456" not in ref
159+
assert ref == str(dep)
160+
161+
def test_regular_lockfile_override_without_update(self):
162+
"""Without --update, regular package download ref uses locked SHA."""
163+
dep = self._make_regular_dep()
164+
lockfile = self._mock_lockfile(dep)
165+
166+
ref = self._build_download_ref(dep, lockfile, update_refs=False)
167+
assert "#abc123def456" in ref
168+
169+
def test_regular_no_lockfile_override_with_update(self):
170+
"""With --update, regular package download ref must NOT use locked SHA."""
171+
dep = self._make_regular_dep()
172+
lockfile = self._mock_lockfile(dep)
173+
174+
ref = self._build_download_ref(dep, lockfile, update_refs=True)
175+
assert "#abc123def456" not in ref
176+
177+
def test_no_lockfile_returns_original_ref(self):
178+
"""Without a lockfile, download ref is the original dependency string."""
179+
dep = self._make_subdirectory_dep()
180+
ref = self._build_download_ref(dep, existing_lockfile=None, update_refs=False)
181+
assert ref == str(dep)
182+
183+
def test_cached_lockfile_entry_not_overridden(self):
184+
"""Lockfile entries with resolved_commit='cached' should not override."""
185+
dep = self._make_subdirectory_dep()
186+
lockfile = self._mock_lockfile(dep, resolved_commit="cached")
187+
188+
ref = self._build_download_ref(dep, lockfile, update_refs=False)
189+
assert ref == str(dep)
190+
191+
192+
class TestPreDownloadRefLockfileOverride:
193+
"""Same as TestDownloadRefLockfileOverride but for the parallel pre-download path."""
194+
195+
@staticmethod
196+
def _build_pre_download_ref(dep_ref, existing_lockfile, update_refs):
197+
"""Reproduce the _pd_dlref construction logic from cli.py's pre-download loop."""
198+
_pd_dlref = str(dep_ref)
199+
if existing_lockfile and not update_refs:
200+
_pd_locked = existing_lockfile.get_dependency(dep_ref.get_unique_key())
201+
if _pd_locked and _pd_locked.resolved_commit and _pd_locked.resolved_commit != "cached":
202+
_pd_base = dep_ref.repo_url
203+
if dep_ref.virtual_path:
204+
_pd_base = f"{_pd_base}/{dep_ref.virtual_path}"
205+
_pd_dlref = f"{_pd_base}#{_pd_locked.resolved_commit}"
206+
return _pd_dlref
207+
208+
def _make_subdirectory_dep(self):
209+
return DependencyReference(
210+
repo_url="owner/monorepo",
211+
host="github.com",
212+
reference=None,
213+
virtual_path="packages/my-skill",
214+
is_virtual=True,
215+
)
216+
217+
def _mock_lockfile(self, dep_ref, resolved_commit="abc123def456"):
218+
lockfile = Mock()
219+
locked_dep = Mock()
220+
locked_dep.resolved_commit = resolved_commit
221+
lockfile.get_dependency = Mock(return_value=locked_dep)
222+
return lockfile
223+
224+
def test_pre_download_no_lockfile_override_with_update(self):
225+
"""With --update, pre-download ref must NOT use locked SHA."""
226+
dep = self._make_subdirectory_dep()
227+
lockfile = self._mock_lockfile(dep)
228+
229+
ref = self._build_pre_download_ref(dep, lockfile, update_refs=True)
230+
assert "#abc123def456" not in ref
231+
232+
def test_pre_download_lockfile_override_without_update(self):
233+
"""Without --update, pre-download ref uses locked SHA."""
234+
dep = self._make_subdirectory_dep()
235+
lockfile = self._mock_lockfile(dep)
236+
237+
ref = self._build_pre_download_ref(dep, lockfile, update_refs=False)
238+
assert "#abc123def456" in ref

0 commit comments

Comments
 (0)