Skip to content

Commit 2aeb193

Browse files
fix: improve local path validation error messages (#431)
* fix: improve local path validation error messages When a local path points to a directory that exists but lacks package markers (apm.yml, SKILL.md, or plugin.json), the error message now says 'no apm.yml, SKILL.md, or plugin.json found' instead of the misleading 'not accessible or doesn't exist'. Additionally, when the directory contains discoverable sub-packages (up to 2 levels deep), a hint is printed suggesting the correct apm install commands. Closes #427 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: Windows CI failures and PR review comments - Fix 3 path assertion failures in test_agents_compiler_coverage.py: use Path.resolve().as_posix() to match portable_relpath output instead of str(Path()) which returns 8.3 short names on Windows - Fix 3 file-locking failures in test_discovery.py: replace NamedTemporaryFile(delete=False)+os.unlink with TemporaryDirectory pattern to avoid WinError 32 - Fix hardcoded /tmp path in test_install_command.py (use tmp_path) - Fix tautological assertion (reason is not None or reason is None) - Fix docstring 'one level' -> 'two levels' in _local_path_no_markers_hint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: handle Rich line-wrapping in hint tests Rich wraps long paths at 80 columns in CI, splitting substrings across lines. Collapse newlines before asserting substring presence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7f446e8 commit 2aeb193

4 files changed

Lines changed: 198 additions & 47 deletions

File tree

src/apm_cli/commands/install.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,11 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
155155
validated_packages.append(canonical)
156156
existing_identities.add(identity) # prevent duplicates within batch
157157
else:
158-
reason = "not accessible or doesn't exist"
159-
if not verbose:
160-
reason += " -- run with --verbose for auth details"
158+
reason = _local_path_failure_reason(dep_ref)
159+
if not reason:
160+
reason = "not accessible or doesn't exist"
161+
if not verbose:
162+
reason += " -- run with --verbose for auth details"
161163
invalid_outcomes.append((package, reason))
162164
if logger:
163165
logger.validation_fail(package, reason)
@@ -212,6 +214,50 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
212214
return validated_packages, outcome
213215

214216

217+
def _local_path_failure_reason(dep_ref):
218+
"""Return a specific failure reason for local path deps, or None for remote."""
219+
if not (dep_ref.is_local and dep_ref.local_path):
220+
return None
221+
local = Path(dep_ref.local_path).expanduser()
222+
if not local.is_absolute():
223+
local = Path.cwd() / local
224+
local = local.resolve()
225+
if not local.exists():
226+
return "path does not exist"
227+
if not local.is_dir():
228+
return "path is not a directory"
229+
# Directory exists but has no package markers
230+
return "no apm.yml, SKILL.md, or plugin.json found"
231+
232+
233+
def _local_path_no_markers_hint(local_dir, verbose_log=None):
234+
"""Scan two levels for sub-packages and print a hint if any are found."""
235+
from apm_cli.utils.helpers import find_plugin_json
236+
237+
markers = ("apm.yml", "SKILL.md")
238+
found = []
239+
for child in sorted(local_dir.iterdir()):
240+
if not child.is_dir():
241+
continue
242+
if any((child / m).exists() for m in markers) or find_plugin_json(child) is not None:
243+
found.append(child)
244+
# Also check one more level (e.g. skills/<name>/)
245+
for grandchild in sorted(child.iterdir()) if child.is_dir() else []:
246+
if not grandchild.is_dir():
247+
continue
248+
if any((grandchild / m).exists() for m in markers) or find_plugin_json(grandchild) is not None:
249+
found.append(grandchild)
250+
251+
if not found:
252+
return
253+
254+
_rich_info(" [i] Found installable package(s) inside this directory:")
255+
for p in found[:5]:
256+
_rich_echo(f" apm install {p}", color="dim")
257+
if len(found) > 5:
258+
_rich_echo(f" ... and {len(found) - 5} more", color="dim")
259+
260+
215261
def _validate_package_exists(package, verbose=False):
216262
"""Validate that a package exists and is accessible on GitHub, Azure DevOps, or locally."""
217263
import os
@@ -241,7 +287,11 @@ def _validate_package_exists(package, verbose=False):
241287
if (local / "apm.yml").exists() or (local / "SKILL.md").exists():
242288
return True
243289
from apm_cli.utils.helpers import find_plugin_json
244-
return find_plugin_json(local) is not None
290+
if find_plugin_json(local) is not None:
291+
return True
292+
# Directory exists but lacks package markers -- surface a hint
293+
_local_path_no_markers_hint(local, verbose_log)
294+
return False
245295

246296
# For virtual packages, use the downloader's validation method
247297
if dep_ref.is_virtual:

tests/unit/compilation/test_agents_compiler_coverage.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -256,8 +256,9 @@ def test_validate_primitives_outside_base_dir_uses_absolute_path(self):
256256
compiler.validate_primitives(primitives)
257257

258258
self.assertEqual(len(compiler.warnings), 1)
259-
# Should use absolute path (contains tmp2 path, not relative).
260-
self.assertIn(str(tmp2), compiler.warnings[0])
259+
# portable_relpath resolves and returns POSIX paths
260+
resolved_tmp2 = Path(tmp2).resolve().as_posix()
261+
self.assertIn(resolved_tmp2, compiler.warnings[0])
261262
finally:
262263
import shutil
263264

@@ -448,7 +449,9 @@ def test_generate_placement_summary_outside_base_uses_absolute(self):
448449
result = self._make_distributed_result([(outside_path, 2)])
449450

450451
summary = compiler._generate_placement_summary(result)
451-
self.assertIn(outside_path, summary)
452+
# portable_relpath resolves and returns POSIX paths
453+
resolved_path = (Path(other_tmp) / "AGENTS.md").resolve().as_posix()
454+
self.assertIn(resolved_path, summary)
452455
finally:
453456
import shutil
454457

@@ -477,7 +480,9 @@ def test_generate_distributed_summary_outside_base(self):
477480
summary = compiler._generate_distributed_summary(
478481
result, CompilationConfig()
479482
)
480-
self.assertIn(outside_path, summary)
483+
# portable_relpath resolves and returns POSIX paths
484+
resolved_path = (Path(other_tmp) / "AGENTS.md").resolve().as_posix()
485+
self.assertIn(resolved_path, summary)
481486
finally:
482487
import shutil
483488

tests/unit/policy/test_discovery.py

Lines changed: 24 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -133,34 +133,24 @@ class TestLoadFromFile(unittest.TestCase):
133133
"""Test _load_from_file with real filesystem."""
134134

135135
def test_valid_policy_file(self):
136-
with tempfile.NamedTemporaryFile(
137-
mode="w", suffix=".yml", delete=False
138-
) as f:
139-
f.write(VALID_POLICY_YAML)
140-
f.flush()
141-
try:
142-
result = _load_from_file(Path(f.name))
143-
self.assertTrue(result.found)
144-
self.assertIsInstance(result.policy, ApmPolicy)
145-
self.assertEqual(result.policy.name, "test-policy")
146-
self.assertIn("file:", result.source)
147-
self.assertIsNone(result.error)
148-
finally:
149-
os.unlink(f.name)
136+
with tempfile.TemporaryDirectory() as tmpdir:
137+
p = Path(tmpdir) / "policy.yml"
138+
p.write_text(VALID_POLICY_YAML, encoding="utf-8")
139+
result = _load_from_file(p)
140+
self.assertTrue(result.found)
141+
self.assertIsInstance(result.policy, ApmPolicy)
142+
self.assertEqual(result.policy.name, "test-policy")
143+
self.assertIn("file:", result.source)
144+
self.assertIsNone(result.error)
150145

151146
def test_invalid_yaml(self):
152-
with tempfile.NamedTemporaryFile(
153-
mode="w", suffix=".yml", delete=False
154-
) as f:
155-
f.write("enforcement: invalid-value\n")
156-
f.flush()
157-
try:
158-
result = _load_from_file(Path(f.name))
159-
self.assertFalse(result.found)
160-
self.assertIsNotNone(result.error)
161-
self.assertIn("Invalid policy file", result.error)
162-
finally:
163-
os.unlink(f.name)
147+
with tempfile.TemporaryDirectory() as tmpdir:
148+
p = Path(tmpdir) / "bad-policy.yml"
149+
p.write_text("enforcement: invalid-value\n", encoding="utf-8")
150+
result = _load_from_file(p)
151+
self.assertFalse(result.found)
152+
self.assertIsNotNone(result.error)
153+
self.assertIn("Invalid policy file", result.error)
164154

165155
def test_unreadable_file(self):
166156
result = _load_from_file(Path("/nonexistent/file.yml"))
@@ -494,19 +484,14 @@ class TestDiscoverPolicy(unittest.TestCase):
494484
"""Integration-level tests for discover_policy."""
495485

496486
def test_override_local_file(self):
497-
with tempfile.NamedTemporaryFile(
498-
mode="w", suffix=".yml", delete=False
499-
) as f:
500-
f.write(VALID_POLICY_YAML)
501-
f.flush()
502-
try:
503-
result = discover_policy(
504-
Path("/fake"), policy_override=f.name
505-
)
506-
self.assertTrue(result.found)
507-
self.assertIn("file:", result.source)
508-
finally:
509-
os.unlink(f.name)
487+
with tempfile.TemporaryDirectory() as tmpdir:
488+
p = Path(tmpdir) / "override-policy.yml"
489+
p.write_text(VALID_POLICY_YAML, encoding="utf-8")
490+
result = discover_policy(
491+
Path("/fake"), policy_override=str(p)
492+
)
493+
self.assertTrue(result.found)
494+
self.assertIn("file:", result.source)
510495

511496
@patch("apm_cli.policy.discovery.requests")
512497
def test_override_url(self, mock_requests):

tests/unit/test_install_command.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,3 +489,114 @@ def tracking_callback(dep_ref, mods_dir, parent_chain=""):
489489
assert ">" in chain, (
490490
f"Expected '>' separator in chain, got: '{chain}'"
491491
)
492+
493+
494+
class TestLocalPathValidationMessages:
495+
"""Tests for improved local path validation error messages."""
496+
497+
def test_local_path_failure_reason_nonexistent(self, tmp_path):
498+
"""Non-existent path returns 'path does not exist'."""
499+
from apm_cli.commands.install import _local_path_failure_reason
500+
from apm_cli.models.apm_package import DependencyReference
501+
502+
dep_ref = DependencyReference.parse(str(tmp_path / "does-not-exist-xyz-9999"))
503+
reason = _local_path_failure_reason(dep_ref)
504+
assert reason == "path does not exist"
505+
506+
def test_local_path_failure_reason_file_not_dir(self, tmp_path):
507+
"""A file (not directory) returns 'path is not a directory'."""
508+
from apm_cli.commands.install import _local_path_failure_reason
509+
from apm_cli.models.apm_package import DependencyReference
510+
511+
f = tmp_path / "somefile.txt"
512+
f.write_text("hello")
513+
dep_ref = DependencyReference.parse(str(f))
514+
reason = _local_path_failure_reason(dep_ref)
515+
assert reason == "path is not a directory"
516+
517+
def test_local_path_failure_reason_no_markers(self, tmp_path):
518+
"""Directory without markers returns specific message."""
519+
from apm_cli.commands.install import _local_path_failure_reason
520+
from apm_cli.models.apm_package import DependencyReference
521+
522+
empty_dir = tmp_path / "empty-pkg"
523+
empty_dir.mkdir()
524+
dep_ref = DependencyReference.parse(str(empty_dir))
525+
reason = _local_path_failure_reason(dep_ref)
526+
assert reason == "no apm.yml, SKILL.md, or plugin.json found"
527+
528+
def test_local_path_failure_reason_valid_apm_yml(self, tmp_path):
529+
"""Directory with apm.yml still returns 'no markers' message.
530+
531+
_local_path_failure_reason is only called when _validate_package_exists
532+
already returned False, so it doesn't re-check markers. We verify it
533+
returns a string (not None) and doesn't crash.
534+
"""
535+
from apm_cli.commands.install import _local_path_failure_reason
536+
from apm_cli.models.apm_package import DependencyReference
537+
538+
pkg = tmp_path / "valid-pkg"
539+
pkg.mkdir()
540+
(pkg / "apm.yml").write_text("name: test\nversion: 1.0.0\n")
541+
dep_ref = DependencyReference.parse(str(pkg))
542+
reason = _local_path_failure_reason(dep_ref)
543+
assert reason == "no apm.yml, SKILL.md, or plugin.json found"
544+
545+
def test_local_path_failure_reason_remote_ref(self):
546+
"""Remote refs return None (not a local path)."""
547+
from apm_cli.commands.install import _local_path_failure_reason
548+
from apm_cli.models.apm_package import DependencyReference
549+
550+
dep_ref = DependencyReference.parse("owner/repo")
551+
reason = _local_path_failure_reason(dep_ref)
552+
assert reason is None
553+
554+
def test_hint_finds_skill_in_subdirectory(self, tmp_path, capsys):
555+
"""Hint discovers SKILL.md in a child directory."""
556+
from apm_cli.commands.install import _local_path_no_markers_hint
557+
558+
skill_dir = tmp_path / "my-skill"
559+
skill_dir.mkdir()
560+
(skill_dir / "SKILL.md").write_text("---\nname: my-skill\n---\n")
561+
562+
_local_path_no_markers_hint(tmp_path)
563+
captured = capsys.readouterr()
564+
# Rich may wrap long paths across lines; collapse before asserting
565+
flat = captured.out.replace("\n", "")
566+
assert "my-skill" in flat
567+
568+
def test_hint_finds_nested_skill(self, tmp_path, capsys):
569+
"""Hint discovers SKILL.md two levels deep (skills/<name>/)."""
570+
from apm_cli.commands.install import _local_path_no_markers_hint
571+
572+
nested = tmp_path / "skills" / "deep-skill"
573+
nested.mkdir(parents=True)
574+
(nested / "SKILL.md").write_text("---\nname: deep-skill\n---\n")
575+
576+
_local_path_no_markers_hint(tmp_path)
577+
captured = capsys.readouterr()
578+
flat = captured.out.replace("\n", "")
579+
assert "deep-skill" in flat
580+
581+
def test_hint_silent_when_no_packages(self, tmp_path, capsys):
582+
"""Hint produces no output when no sub-packages found."""
583+
from apm_cli.commands.install import _local_path_no_markers_hint
584+
585+
(tmp_path / "random-file.txt").write_text("nothing here")
586+
_local_path_no_markers_hint(tmp_path)
587+
captured = capsys.readouterr()
588+
assert captured.out == ""
589+
590+
def test_hint_caps_at_five(self, tmp_path, capsys):
591+
"""Hint shows at most 5 packages then a '... and N more' line."""
592+
from apm_cli.commands.install import _local_path_no_markers_hint
593+
594+
for i in range(8):
595+
d = tmp_path / f"skill-{i:02d}"
596+
d.mkdir()
597+
(d / "SKILL.md").write_text(f"---\nname: skill-{i:02d}\n---\n")
598+
599+
_local_path_no_markers_hint(tmp_path)
600+
captured = capsys.readouterr()
601+
assert "apm install" in captured.out
602+
assert "... and 3 more" in captured.out

0 commit comments

Comments
 (0)