apm-cli 0.8.5__tar.gz → 0.8.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. {apm_cli-0.8.5/src/apm_cli.egg-info → apm_cli-0.8.6}/PKG-INFO +1 -1
  2. {apm_cli-0.8.5 → apm_cli-0.8.6}/pyproject.toml +1 -1
  3. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/install.py +119 -56
  4. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/compilation/claude_formatter.py +4 -1
  5. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/compilation/context_optimizer.py +3 -2
  6. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/compilation/distributed_compiler.py +4 -1
  7. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/compilation/template_builder.py +3 -3
  8. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/core/auth.py +23 -18
  9. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/deps/github_downloader.py +29 -41
  10. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/deps/lockfile.py +20 -0
  11. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/integration/skill_integrator.py +107 -140
  12. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/integration/targets.py +40 -13
  13. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/models/dependency/reference.py +314 -239
  14. apm_cli-0.8.6/src/apm_cli/utils/file_ops.py +294 -0
  15. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/utils/path_security.py +50 -6
  16. {apm_cli-0.8.5 → apm_cli-0.8.6/src/apm_cli.egg-info}/PKG-INFO +1 -1
  17. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli.egg-info/SOURCES.txt +1 -0
  18. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_apm_package_models.py +463 -348
  19. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_github_downloader.py +9 -0
  20. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_lockfile.py +68 -0
  21. {apm_cli-0.8.5 → apm_cli-0.8.6}/AUTHORS +0 -0
  22. {apm_cli-0.8.5 → apm_cli-0.8.6}/LICENSE +0 -0
  23. {apm_cli-0.8.5 → apm_cli-0.8.6}/README.md +0 -0
  24. {apm_cli-0.8.5 → apm_cli-0.8.6}/setup.cfg +0 -0
  25. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/__init__.py +0 -0
  26. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/adapters/__init__.py +0 -0
  27. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/adapters/client/__init__.py +0 -0
  28. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/adapters/client/base.py +0 -0
  29. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/adapters/client/codex.py +0 -0
  30. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/adapters/client/copilot.py +0 -0
  31. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/adapters/client/cursor.py +0 -0
  32. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/adapters/client/opencode.py +0 -0
  33. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/adapters/client/vscode.py +0 -0
  34. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/adapters/package_manager/__init__.py +0 -0
  35. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/adapters/package_manager/base.py +0 -0
  36. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/adapters/package_manager/default_manager.py +0 -0
  37. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/bundle/__init__.py +0 -0
  38. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/bundle/lockfile_enrichment.py +0 -0
  39. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/bundle/packer.py +0 -0
  40. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/bundle/plugin_exporter.py +0 -0
  41. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/bundle/unpacker.py +0 -0
  42. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/cli.py +0 -0
  43. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/__init__.py +0 -0
  44. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/_helpers.py +0 -0
  45. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/audit.py +0 -0
  46. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/compile/__init__.py +0 -0
  47. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/compile/cli.py +0 -0
  48. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/compile/watcher.py +0 -0
  49. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/config.py +0 -0
  50. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/deps/__init__.py +0 -0
  51. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/deps/_utils.py +0 -0
  52. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/deps/cli.py +0 -0
  53. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/init.py +0 -0
  54. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/list_cmd.py +0 -0
  55. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/mcp.py +0 -0
  56. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/pack.py +0 -0
  57. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/prune.py +0 -0
  58. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/run.py +0 -0
  59. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/runtime.py +0 -0
  60. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/uninstall/__init__.py +0 -0
  61. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/uninstall/cli.py +0 -0
  62. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/uninstall/engine.py +0 -0
  63. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/commands/update.py +0 -0
  64. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/compilation/__init__.py +0 -0
  65. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/compilation/agents_compiler.py +0 -0
  66. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/compilation/constants.py +0 -0
  67. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/compilation/constitution.py +0 -0
  68. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/compilation/constitution_block.py +0 -0
  69. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/compilation/injector.py +0 -0
  70. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/compilation/link_resolver.py +0 -0
  71. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/config.py +0 -0
  72. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/constants.py +0 -0
  73. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/core/__init__.py +0 -0
  74. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/core/command_logger.py +0 -0
  75. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/core/conflict_detector.py +0 -0
  76. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/core/docker_args.py +0 -0
  77. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/core/operations.py +0 -0
  78. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/core/safe_installer.py +0 -0
  79. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/core/script_runner.py +0 -0
  80. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/core/target_detection.py +0 -0
  81. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/core/token_manager.py +0 -0
  82. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/deps/__init__.py +0 -0
  83. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/deps/aggregator.py +0 -0
  84. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/deps/apm_resolver.py +0 -0
  85. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/deps/collection_parser.py +0 -0
  86. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/deps/dependency_graph.py +0 -0
  87. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/deps/package_validator.py +0 -0
  88. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/deps/plugin_parser.py +0 -0
  89. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/deps/verifier.py +0 -0
  90. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/drift.py +0 -0
  91. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/factory.py +0 -0
  92. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/integration/__init__.py +0 -0
  93. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/integration/agent_integrator.py +0 -0
  94. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/integration/base_integrator.py +0 -0
  95. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/integration/command_integrator.py +0 -0
  96. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/integration/hook_integrator.py +0 -0
  97. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/integration/instruction_integrator.py +0 -0
  98. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/integration/mcp_integrator.py +0 -0
  99. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/integration/prompt_integrator.py +0 -0
  100. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/integration/skill_transformer.py +0 -0
  101. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/integration/utils.py +0 -0
  102. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/models/__init__.py +0 -0
  103. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/models/apm_package.py +0 -0
  104. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/models/dependency/__init__.py +0 -0
  105. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/models/dependency/mcp.py +0 -0
  106. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/models/dependency/types.py +0 -0
  107. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/models/plugin.py +0 -0
  108. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/models/results.py +0 -0
  109. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/models/validation.py +0 -0
  110. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/output/__init__.py +0 -0
  111. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/output/formatters.py +0 -0
  112. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/output/models.py +0 -0
  113. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/output/script_formatters.py +0 -0
  114. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/policy/__init__.py +0 -0
  115. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/policy/ci_checks.py +0 -0
  116. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/policy/discovery.py +0 -0
  117. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/policy/inheritance.py +0 -0
  118. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/policy/matcher.py +0 -0
  119. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/policy/models.py +0 -0
  120. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/policy/parser.py +0 -0
  121. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/policy/policy_checks.py +0 -0
  122. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/policy/schema.py +0 -0
  123. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/primitives/__init__.py +0 -0
  124. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/primitives/discovery.py +0 -0
  125. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/primitives/models.py +0 -0
  126. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/primitives/parser.py +0 -0
  127. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/registry/__init__.py +0 -0
  128. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/registry/client.py +0 -0
  129. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/registry/integration.py +0 -0
  130. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/registry/operations.py +0 -0
  131. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/runtime/__init__.py +0 -0
  132. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/runtime/base.py +0 -0
  133. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/runtime/codex_runtime.py +0 -0
  134. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/runtime/copilot_runtime.py +0 -0
  135. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/runtime/factory.py +0 -0
  136. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/runtime/llm_runtime.py +0 -0
  137. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/runtime/manager.py +0 -0
  138. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/security/__init__.py +0 -0
  139. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/security/audit_report.py +0 -0
  140. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/security/content_scanner.py +0 -0
  141. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/security/file_scanner.py +0 -0
  142. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/security/gate.py +0 -0
  143. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/utils/__init__.py +0 -0
  144. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/utils/console.py +0 -0
  145. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/utils/content_hash.py +0 -0
  146. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/utils/diagnostics.py +0 -0
  147. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/utils/github_host.py +0 -0
  148. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/utils/helpers.py +0 -0
  149. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/utils/paths.py +0 -0
  150. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/utils/version_checker.py +0 -0
  151. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/utils/yaml_io.py +0 -0
  152. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/version.py +0 -0
  153. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/workflow/__init__.py +0 -0
  154. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/workflow/discovery.py +0 -0
  155. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/workflow/parser.py +0 -0
  156. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli/workflow/runner.py +0 -0
  157. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli.egg-info/dependency_links.txt +0 -0
  158. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli.egg-info/entry_points.txt +0 -0
  159. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli.egg-info/requires.txt +0 -0
  160. {apm_cli-0.8.5 → apm_cli-0.8.6}/src/apm_cli.egg-info/top_level.txt +0 -0
  161. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_apm_resolver.py +0 -0
  162. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_codex_docker_args_fix.py +0 -0
  163. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_codex_empty_string_and_defaults.py +0 -0
  164. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_collision_integration.py +0 -0
  165. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_console.py +0 -0
  166. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_distributed_compilation.py +0 -0
  167. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_empty_string_and_defaults.py +0 -0
  168. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_enhanced_discovery.py +0 -0
  169. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_github_downloader_token_precedence.py +0 -0
  170. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_runnable_prompts.py +0 -0
  171. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_runtime_manager_token_precedence.py +0 -0
  172. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_token_manager.py +0 -0
  173. {apm_cli-0.8.5 → apm_cli-0.8.6}/tests/test_virtual_package_multi_install.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apm-cli
3
- Version: 0.8.5
3
+ Version: 0.8.6
4
4
  Summary: MCP configuration tool
5
5
  Author-email: Daniel Meppiel <user@example.com>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "apm-cli"
7
- version = "0.8.5"
7
+ version = "0.8.6"
8
8
  description = "MCP configuration tool"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -35,6 +35,12 @@ set = builtins.set
35
35
  list = builtins.list
36
36
  dict = builtins.dict
37
37
 
38
+ # AuthResolver has no optional deps (stdlib + internal utils only), so it must
39
+ # be imported unconditionally here -- NOT inside the APM_DEPS_AVAILABLE guard.
40
+ # If it were gated, a missing optional dep (e.g. GitPython) would cause a
41
+ # NameError in install() before the graceful APM_DEPS_AVAILABLE check fires.
42
+ from ..core.auth import AuthResolver
43
+
38
44
  # APM Dependencies (conditional import for graceful degradation)
39
45
  APM_DEPS_AVAILABLE = False
40
46
  _APM_IMPORT_ERROR = None
@@ -56,7 +62,7 @@ except ImportError as e:
56
62
  # ---------------------------------------------------------------------------
57
63
 
58
64
 
59
- def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, logger=None):
65
+ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, logger=None, auth_resolver=None):
60
66
  """Validate packages exist and can be accessed, then add to apm.yml dependencies section.
61
67
 
62
68
  Implements normalize-on-write: any input form (HTTPS URL, SSH URL, FQDN, shorthand)
@@ -68,6 +74,7 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
68
74
  dry_run: If True, only show what would be added.
69
75
  dev: If True, write to devDependencies instead of dependencies.
70
76
  logger: InstallLogger for structured output.
77
+ auth_resolver: Shared auth resolver for caching credentials.
71
78
 
72
79
  Returns:
73
80
  Tuple of (validated_packages list, _ValidationOutcome).
@@ -146,7 +153,7 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
146
153
 
147
154
  # Validate package exists and is accessible
148
155
  verbose = bool(logger and logger.verbose)
149
- if _validate_package_exists(package, verbose=verbose):
156
+ if _validate_package_exists(package, verbose=verbose, auth_resolver=auth_resolver):
150
157
  valid_outcomes.append((canonical, already_in_deps))
151
158
  if logger:
152
159
  logger.validation_pass(canonical, already_present=already_in_deps)
@@ -258,7 +265,7 @@ def _local_path_no_markers_hint(local_dir, verbose_log=None):
258
265
  _rich_echo(f" ... and {len(found) - 5} more", color="dim")
259
266
 
260
267
 
261
- def _validate_package_exists(package, verbose=False):
268
+ def _validate_package_exists(package, verbose=False, auth_resolver=None):
262
269
  """Validate that a package exists and is accessible on GitHub, Azure DevOps, or locally."""
263
270
  import os
264
271
  import subprocess
@@ -266,7 +273,9 @@ def _validate_package_exists(package, verbose=False):
266
273
  from apm_cli.core.auth import AuthResolver
267
274
 
268
275
  verbose_log = (lambda msg: _rich_echo(f" {msg}", color="dim")) if verbose else None
269
- auth_resolver = AuthResolver()
276
+ # Use provided resolver or create new one if not in a CLI session context
277
+ if auth_resolver is None:
278
+ auth_resolver = AuthResolver()
270
279
 
271
280
  try:
272
281
  # Parse the package to check if it's a virtual package or ADO
@@ -300,8 +309,8 @@ def _validate_package_exists(package, verbose=False):
300
309
  org = dep_ref.repo_url.split('/')[0] if dep_ref.repo_url and '/' in dep_ref.repo_url else None
301
310
  if verbose_log:
302
311
  verbose_log(f"Auth resolved: host={host}, org={org}, source={ctx.source}, type={ctx.token_type}")
303
- downloader = GitHubPackageDownloader(auth_resolver=auth_resolver)
304
- result = downloader.validate_virtual_package_exists(dep_ref)
312
+ virtual_downloader = GitHubPackageDownloader(auth_resolver=auth_resolver)
313
+ result = virtual_downloader.validate_virtual_package_exists(dep_ref)
305
314
  if not result and verbose_log:
306
315
  try:
307
316
  err_ctx = auth_resolver.build_error_context(host, f"accessing {package}", org=org)
@@ -316,26 +325,39 @@ def _validate_package_exists(package, verbose=False):
316
325
  if dep_ref.is_azure_devops() or (dep_ref.host and dep_ref.host != "github.com"):
317
326
  from apm_cli.utils.github_host import is_github_hostname, is_azure_devops_hostname
318
327
 
319
- downloader = GitHubPackageDownloader()
328
+ # Determine host type before building the URL so we know whether to
329
+ # embed a token. Generic (non-GitHub, non-ADO) hosts are excluded
330
+ # from APM-managed auth; they rely on git credential helpers via the
331
+ # relaxed validate_env below.
332
+ is_generic = not is_github_hostname(dep_ref.host) and not is_azure_devops_hostname(dep_ref.host)
333
+
334
+ # For GHES / ADO: resolve per-dependency auth up front so the URL
335
+ # carries an embedded token and avoids triggering OS credential
336
+ # helper popups during git ls-remote validation.
337
+ _url_token = None
338
+ if not is_generic:
339
+ _dep_ctx = auth_resolver.resolve_for_dep(dep_ref)
340
+ _url_token = _dep_ctx.token
341
+
342
+ ado_downloader = GitHubPackageDownloader(auth_resolver=auth_resolver)
320
343
  # Set the host
321
344
  if dep_ref.host:
322
- downloader.github_host = dep_ref.host
345
+ ado_downloader.github_host = dep_ref.host
323
346
 
324
- # Build authenticated URL using downloader's auth
325
- package_url = downloader._build_repo_url(
326
- dep_ref.repo_url, use_ssh=False, dep_ref=dep_ref
347
+ # Build authenticated URL using the resolved per-dep token.
348
+ package_url = ado_downloader._build_repo_url(
349
+ dep_ref.repo_url, use_ssh=False, dep_ref=dep_ref, token=_url_token
327
350
  )
328
351
 
329
352
  # For generic hosts (not GitHub, not ADO), relax the env so native
330
353
  # credential helpers (SSH keys, macOS Keychain, etc.) can work.
331
354
  # This mirrors _clone_with_fallback() which does the same relaxation.
332
- is_generic = not is_github_hostname(dep_ref.host) and not is_azure_devops_hostname(dep_ref.host)
333
355
  if is_generic:
334
- validate_env = {k: v for k, v in downloader.git_env.items()
356
+ validate_env = {k: v for k, v in ado_downloader.git_env.items()
335
357
  if k not in ('GIT_ASKPASS', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_NOSYSTEM')}
336
358
  validate_env['GIT_TERMINAL_PROMPT'] = '0'
337
359
  else:
338
- validate_env = {**os.environ, **downloader.git_env}
360
+ validate_env = {**os.environ, **ado_downloader.git_env}
339
361
 
340
362
  if verbose_log:
341
363
  verbose_log(f"Trying git ls-remote for {dep_ref.host}")
@@ -513,8 +535,19 @@ def _validate_package_exists(package, verbose=False):
513
535
  default=False,
514
536
  help="Install as development dependency (devDependencies)",
515
537
  )
538
+ @click.option(
539
+ "--target",
540
+ "-t",
541
+ "target",
542
+ type=click.Choice(
543
+ ["copilot", "claude", "cursor", "opencode", "vscode", "agents", "all"],
544
+ case_sensitive=False,
545
+ ),
546
+ default=None,
547
+ help="Force deployment to a specific target (overrides auto-detection)",
548
+ )
516
549
  @click.pass_context
517
- def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbose, trust_transitive_mcp, parallel_downloads, dev):
550
+ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbose, trust_transitive_mcp, parallel_downloads, dev, target):
518
551
  """Install APM and MCP dependencies from apm.yml (like npm install).
519
552
 
520
553
  This command automatically detects AI runtimes from your apm.yml scripts and installs
@@ -539,6 +572,10 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
539
572
  is_partial = bool(packages)
540
573
  logger = InstallLogger(verbose=verbose, dry_run=dry_run, partial=is_partial)
541
574
 
575
+ # Create shared auth resolver for all downloads in this CLI invocation
576
+ # to ensure credentials are cached and reused (prevents duplicate auth popups)
577
+ auth_resolver = AuthResolver()
578
+
542
579
  # Check if apm.yml exists
543
580
  apm_yml_exists = Path(APM_YML_FILENAME).exists()
544
581
 
@@ -560,7 +597,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
560
597
  # If packages are specified, validate and add them to apm.yml first
561
598
  if packages:
562
599
  validated_packages, outcome = _validate_and_add_packages_to_apm_yml(
563
- packages, dry_run, dev=dev, logger=logger,
600
+ packages, dry_run, dev=dev, logger=logger, auth_resolver=auth_resolver,
564
601
  )
565
602
  # Short-circuit: all packages failed validation — nothing to install
566
603
  if outcome.all_failed:
@@ -661,6 +698,8 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
661
698
  apm_package, update, verbose, only_pkgs, force=force,
662
699
  parallel_downloads=parallel_downloads,
663
700
  logger=logger,
701
+ auth_resolver=auth_resolver,
702
+ target=target,
664
703
  )
665
704
  apm_count = install_result.installed_count
666
705
  prompt_count = install_result.prompts_integrated
@@ -874,12 +913,20 @@ def _integrate_package_primitives(
874
913
  package_info, project_root,
875
914
  diagnostics=diagnostics, managed_files=managed_files, force=force,
876
915
  )
916
+ # Build human-readable list of target dirs from deployed paths
917
+ _skill_target_dirs: set[str] = set()
918
+ for tp in skill_result.target_paths:
919
+ rel = tp.relative_to(project_root)
920
+ if rel.parts:
921
+ _skill_target_dirs.add(rel.parts[0])
922
+ _skill_targets = sorted(_skill_target_dirs)
923
+ _skill_target_str = ", ".join(f"{d}/skills/" for d in _skill_targets) or "skills/"
877
924
  if skill_result.skill_created:
878
925
  result["skills"] += 1
879
- _log_integration(f" └─ Skill integrated -> .github/skills/")
926
+ _log_integration(f" |-- Skill integrated -> {_skill_target_str}")
880
927
  if skill_result.sub_skills_promoted > 0:
881
928
  result["sub_skills"] += skill_result.sub_skills_promoted
882
- _log_integration(f" └─ {skill_result.sub_skills_promoted} skill(s) integrated -> .github/skills/")
929
+ _log_integration(f" |-- {skill_result.sub_skills_promoted} skill(s) integrated -> {_skill_target_str}")
883
930
  for tp in skill_result.target_paths:
884
931
  deployed.append(tp.relative_to(project_root).as_posix())
885
932
 
@@ -951,19 +998,20 @@ def _integrate_package_primitives(
951
998
  deployed.append(tp.relative_to(project_root).as_posix())
952
999
 
953
1000
  # --- commands (.claude) ---
954
- command_result = command_integrator.integrate_package_commands(
955
- package_info, project_root,
956
- force=force, managed_files=managed_files,
957
- diagnostics=diagnostics,
958
- )
959
- if command_result.files_integrated > 0:
960
- result["commands"] += command_result.files_integrated
961
- _log_integration(f" └─ {command_result.files_integrated} commands integrated -> .claude/commands/")
962
- if command_result.files_updated > 0:
963
- _log_integration(f" └─ {command_result.files_updated} commands updated")
964
- result["links_resolved"] += command_result.links_resolved
965
- for tp in command_result.target_paths:
966
- deployed.append(tp.relative_to(project_root).as_posix())
1001
+ if integrate_claude:
1002
+ command_result = command_integrator.integrate_package_commands(
1003
+ package_info, project_root,
1004
+ force=force, managed_files=managed_files,
1005
+ diagnostics=diagnostics,
1006
+ )
1007
+ if command_result.files_integrated > 0:
1008
+ result["commands"] += command_result.files_integrated
1009
+ _log_integration(f" └─ {command_result.files_integrated} commands integrated -> .claude/commands/")
1010
+ if command_result.files_updated > 0:
1011
+ _log_integration(f" └─ {command_result.files_updated} commands updated")
1012
+ result["links_resolved"] += command_result.links_resolved
1013
+ for tp in command_result.target_paths:
1014
+ deployed.append(tp.relative_to(project_root).as_posix())
967
1015
 
968
1016
  # --- OpenCode commands (.opencode) ---
969
1017
  opencode_command_result = command_integrator.integrate_package_commands_opencode(
@@ -1071,6 +1119,8 @@ def _install_apm_dependencies(
1071
1119
  force: bool = False,
1072
1120
  parallel_downloads: int = 4,
1073
1121
  logger: "InstallLogger" = None,
1122
+ auth_resolver: "AuthResolver" = None,
1123
+ target: str = None,
1074
1124
  ):
1075
1125
  """Install APM package dependencies.
1076
1126
 
@@ -1082,6 +1132,8 @@ def _install_apm_dependencies(
1082
1132
  force: Whether to overwrite locally-authored files on collision
1083
1133
  parallel_downloads: Max concurrent downloads (0 disables parallelism)
1084
1134
  logger: InstallLogger for structured output
1135
+ auth_resolver: Shared auth resolver for caching credentials
1136
+ target: Explicit target override from --target CLI flag
1085
1137
  """
1086
1138
  if not APM_DEPS_AVAILABLE:
1087
1139
  raise RuntimeError("APM dependency system not available")
@@ -1114,8 +1166,12 @@ def _install_apm_dependencies(
1114
1166
  apm_modules_dir = project_root / APM_MODULES_DIR
1115
1167
  apm_modules_dir.mkdir(exist_ok=True)
1116
1168
 
1169
+ # Use provided resolver or create new one if not in a CLI session context
1170
+ if auth_resolver is None:
1171
+ auth_resolver = AuthResolver()
1172
+
1117
1173
  # Create downloader early so it can be used for transitive dependency resolution
1118
- downloader = GitHubPackageDownloader()
1174
+ downloader = GitHubPackageDownloader(auth_resolver=auth_resolver)
1119
1175
 
1120
1176
  # Track direct dependency keys so the download callback can distinguish them from transitive
1121
1177
  direct_dep_keys = builtins.set(dep.get_unique_key() for dep in apm_deps)
@@ -1302,23 +1358,27 @@ def _install_apm_dependencies(
1302
1358
  # Get config target from apm.yml if available
1303
1359
  config_target = apm_package.target
1304
1360
 
1305
- # Auto-create .github/ if neither .github/ nor .claude/ exists.
1306
- # Per skill-strategy Decision 1, .github/skills/ is the standard skills location;
1307
- # creating .github/ here ensures a consistent skills root and also enables
1308
- # VSCode/Copilot integration by default (quick path to value), even for
1309
- # projects that don't yet use .claude/.
1310
- github_dir = project_root / GITHUB_DIR
1311
- claude_dir = project_root / CLAUDE_DIR
1312
- if not github_dir.exists() and not claude_dir.exists():
1313
- github_dir.mkdir(parents=True, exist_ok=True)
1314
- if logger:
1315
- logger.verbose_detail(
1316
- "Created .github/ as standard skills root (.github/skills/) and to enable VSCode/Copilot integration"
1317
- )
1361
+ # Resolve effective explicit target: CLI --target wins, then apm.yml
1362
+ _explicit = target or config_target or None
1363
+
1364
+ # Determine active targets. When --target or apm.yml target is set
1365
+ # the user's choice wins. Otherwise auto-detect from existing dirs,
1366
+ # falling back to copilot when nothing is found.
1367
+ from apm_cli.integration.targets import active_targets as _active_targets
1368
+
1369
+ _targets = _active_targets(project_root, explicit_target=_explicit)
1370
+ for _t in _targets:
1371
+ _target_dir = project_root / _t.root_dir
1372
+ if not _target_dir.exists():
1373
+ _target_dir.mkdir(parents=True, exist_ok=True)
1374
+ if logger:
1375
+ logger.verbose_detail(
1376
+ f"Created {_t.root_dir}/ ({_t.name} target)"
1377
+ )
1318
1378
 
1319
1379
  detected_target, detection_reason = detect_target(
1320
1380
  project_root=project_root,
1321
- explicit_target=None, # No explicit flag for install
1381
+ explicit_target=_explicit,
1322
1382
  config_target=config_target,
1323
1383
  )
1324
1384
 
@@ -1472,13 +1532,9 @@ def _install_apm_dependencies(
1472
1532
  _pre_downloaded_keys = builtins.set(_pre_download_results.keys())
1473
1533
 
1474
1534
  # Create progress display for sequential integration
1475
- _auth_resolver = None
1476
- if verbose:
1477
- try:
1478
- from apm_cli.core.auth import AuthResolver
1479
- _auth_resolver = AuthResolver()
1480
- except Exception:
1481
- pass
1535
+ # Reuse the shared auth_resolver (already created in this invocation) so
1536
+ # verbose auth logging does not trigger a duplicate credential-helper popup.
1537
+ _auth_resolver = auth_resolver
1482
1538
 
1483
1539
  with Progress(
1484
1540
  SpinnerColumn(),
@@ -2109,9 +2165,16 @@ def _install_apm_dependencies(
2109
2165
  existing.add_dependency(dep)
2110
2166
  lockfile = existing
2111
2167
 
2112
- lockfile.save(lockfile_path)
2113
- if logger:
2114
- logger.verbose_detail(f"Generated apm.lock.yaml with {len(lockfile.dependencies)} dependencies")
2168
+ # Only write when the semantic content has actually changed
2169
+ # (avoids generated_at churn in version control).
2170
+ existing_lockfile = LockFile.read(lockfile_path) if lockfile_path.exists() else None
2171
+ if existing_lockfile and lockfile.is_semantically_equivalent(existing_lockfile):
2172
+ if logger:
2173
+ logger.verbose_detail("apm.lock.yaml unchanged -- skipping write")
2174
+ else:
2175
+ lockfile.save(lockfile_path)
2176
+ if logger:
2177
+ logger.verbose_detail(f"Generated apm.lock.yaml with {len(lockfile.dependencies)} dependencies")
2115
2178
  except Exception as e:
2116
2179
  _lock_msg = f"Could not generate apm.lock.yaml: {e}"
2117
2180
  diagnostics.error(_lock_msg)
@@ -291,7 +291,10 @@ class ClaudeFormatter:
291
291
  sections.append(f"## Files matching `{pattern}`")
292
292
  sections.append("")
293
293
 
294
- for instruction in pattern_instructions:
294
+ for instruction in sorted(
295
+ pattern_instructions,
296
+ key=lambda i: portable_relpath(i.file_path, self.base_dir),
297
+ ):
295
298
  content = instruction.content.strip()
296
299
  if content:
297
300
  # Add source attribution comment
@@ -172,8 +172,9 @@ class ContextOptimizer:
172
172
  self._file_list_cache = []
173
173
  for root, dirs, files in os.walk(self.base_dir):
174
174
  # Skip hidden and excluded directories for performance
175
- dirs[:] = [d for d in dirs if not d.startswith('.') and d not in DEFAULT_EXCLUDED_DIRNAMES]
176
- for file in files:
175
+ # Sort to guarantee deterministic traversal order across filesystems
176
+ dirs[:] = sorted(d for d in dirs if not d.startswith('.') and d not in DEFAULT_EXCLUDED_DIRNAMES)
177
+ for file in sorted(files):
177
178
  if not file.startswith('.'):
178
179
  self._file_list_cache.append(Path(root) / file)
179
180
  return self._file_list_cache
@@ -536,7 +536,10 @@ class DistributedAgentsCompiler:
536
536
  sections.append(f"## Files matching `{pattern}`")
537
537
  sections.append("")
538
538
 
539
- for instruction in pattern_instructions:
539
+ for instruction in sorted(
540
+ pattern_instructions,
541
+ key=lambda i: portable_relpath(i.file_path, self.base_dir),
542
+ ):
540
543
  content = instruction.content.strip()
541
544
  if content:
542
545
  # Add source attribution for individual instructions
@@ -34,12 +34,12 @@ def build_conditional_sections(instructions: List[Instruction]) -> str:
34
34
 
35
35
  sections = []
36
36
 
37
- for pattern, pattern_instructions in pattern_groups.items():
37
+ for pattern, pattern_instructions in sorted(pattern_groups.items()):
38
38
  sections.append(f"## Files matching `{pattern}`")
39
39
  sections.append("")
40
-
40
+
41
41
  # Combine content from all instructions for this pattern
42
- for instruction in pattern_instructions:
42
+ for instruction in sorted(pattern_instructions, key=lambda i: portable_relpath(i.file_path, Path.cwd())):
43
43
  content = instruction.content.strip()
44
44
  if content:
45
45
  # Add source file comment before the content
@@ -182,25 +182,30 @@ class AuthResolver:
182
182
  """Resolve auth for *(host, org)*. Cached & thread-safe."""
183
183
  key = (host.lower() if host else host, org.lower() if org else org)
184
184
  with self._lock:
185
- if key in self._cache:
186
- return self._cache[key]
187
-
188
- host_info = self.classify_host(host)
189
- token, source = self._resolve_token(host_info, org)
190
- token_type = self.detect_token_type(token) if token else "unknown"
191
- git_env = self._build_git_env(token)
192
-
193
- ctx = AuthContext(
194
- token=token,
195
- source=source,
196
- token_type=token_type,
197
- host_info=host_info,
198
- git_env=git_env,
199
- )
200
-
201
- with self._lock:
185
+ cached = self._cache.get(key)
186
+ if cached is not None:
187
+ return cached
188
+
189
+ # Hold lock during entire credential resolution to prevent duplicate
190
+ # credential-helper popups when parallel downloads resolve the same
191
+ # (host, org) concurrently. The first caller fills the cache; all
192
+ # subsequent callers for the same key become O(1) cache hits.
193
+ # Bounded by APM_GIT_CREDENTIAL_TIMEOUT (default 60s). No deadlock
194
+ # risk: single lock, never nested.
195
+ host_info = self.classify_host(host)
196
+ token, source = self._resolve_token(host_info, org)
197
+ token_type = self.detect_token_type(token) if token else "unknown"
198
+ git_env = self._build_git_env(token)
199
+
200
+ ctx = AuthContext(
201
+ token=token,
202
+ source=source,
203
+ token_type=token_type,
204
+ host_info=host_info,
205
+ git_env=git_env,
206
+ )
202
207
  self._cache[key] = ctx
203
- return ctx
208
+ return ctx
204
209
 
205
210
  def resolve_for_dep(self, dep_ref: "DependencyReference") -> AuthContext:
206
211
  """Resolve auth from a ``DependencyReference``."""
@@ -85,27 +85,11 @@ def _close_repo(repo) -> None:
85
85
  def _rmtree(path) -> None:
86
86
  """Remove a directory tree, handling read-only files and brief Windows locks.
87
87
 
88
- Git pack/index files are often read-only, and on Windows git processes may
89
- hold brief locks even after the Repo object is closed. This wrapper uses
90
- an onerror callback for read-only files and a single retry for lock races.
88
+ Delegates to :func:`robust_rmtree` which retries with exponential backoff
89
+ on transient lock errors (e.g. antivirus scanning on Windows).
91
90
  """
92
- def _on_readonly(func, fpath, _exc_info):
93
- """onerror callback: make read-only files writable and retry."""
94
- try:
95
- os.chmod(fpath, stat.S_IWRITE)
96
- func(fpath)
97
- except OSError:
98
- pass
99
-
100
- try:
101
- shutil.rmtree(path, onerror=_on_readonly)
102
- except PermissionError:
103
- if sys.platform == 'win32':
104
- # Single retry after a brief wait for lingering git handles
105
- time.sleep(0.5)
106
- shutil.rmtree(path, ignore_errors=True)
107
- # On all platforms: don't raise from cleanup — just leave the
108
- # temp dir behind (the OS will clean it up eventually).
91
+ from ..utils.file_ops import robust_rmtree
92
+ robust_rmtree(path, ignore_errors=True)
109
93
 
110
94
 
111
95
  class GitProgressReporter(RemoteProgress):
@@ -202,20 +186,18 @@ class GitHubPackageDownloader:
202
186
  else:
203
187
  env['GIT_CONFIG_GLOBAL'] = '/dev/null'
204
188
 
205
- # Resolve default host tokens via AuthResolver (backward compat properties)
206
- default_ctx = self.auth_resolver.resolve(default_host())
207
- self._default_github_ctx = default_ctx
208
- self.github_token = default_ctx.token
209
- self.has_github_token = default_ctx.token is not None
210
- self._github_token_from_credential_fill = (
211
- self.has_github_token
212
- and self.token_manager.get_token_for_purpose('modules', env) is None
213
- )
189
+ # IMPORTANT: Do not resolve credentials via helpers at construction time.
190
+ # AuthResolver.resolve(...) can trigger OS credential helper UI. If we do
191
+ # this eagerly (host-only key) and later resolve per-dependency (host+org),
192
+ # users can see duplicate auth prompts. Keep constructor token state env-only
193
+ # and resolve lazily per dependency during clone/validate flows.
194
+ self.github_token = self.token_manager.get_token_for_purpose('modules', env)
195
+ self.has_github_token = self.github_token is not None
196
+ self._github_token_from_credential_fill = False
214
197
 
215
- # Azure DevOps
216
- ado_ctx = self.auth_resolver.resolve("dev.azure.com")
217
- self.ado_token = ado_ctx.token
218
- self.has_ado_token = ado_ctx.token is not None
198
+ # Azure DevOps (env-only at init; lazy auth resolution happens per dep)
199
+ self.ado_token = self.token_manager.get_token_for_purpose('ado_modules', env)
200
+ self.has_ado_token = self.ado_token is not None
219
201
 
220
202
  # JFrog Artifactory (not host-based, uses dedicated env var)
221
203
  self.artifactory_token = self.token_manager.get_token_for_purpose('artifactory_modules', env)
@@ -664,7 +646,9 @@ class GitHubPackageDownloader:
664
646
  f"If this package lives on a different server (e.g., github.com), "
665
647
  f"use the full hostname in apm.yml: {suggested}"
666
648
  )
667
- elif not self.has_github_token:
649
+ elif not has_token:
650
+ # No auth was resolved (neither env var nor credential helper).
651
+ # Guide the user through setting up authentication.
668
652
  host = dep_host or default_host()
669
653
  org = dep_ref.repo_url.split('/')[0] if dep_ref and dep_ref.repo_url else None
670
654
  error_msg += self.auth_resolver.build_error_context(host, "clone", org=org)
@@ -1626,14 +1610,16 @@ author: {dep_ref.repo_url.split('/')[0]}
1626
1610
  _rmtree(target_path)
1627
1611
  target_path.mkdir(parents=True, exist_ok=True)
1628
1612
 
1629
- # Copy subdirectory contents to target
1613
+ # Copy subdirectory contents to target (retry on transient
1614
+ # file-lock errors caused by antivirus scanning on Windows).
1615
+ from ..utils.file_ops import robust_copytree, robust_copy2
1630
1616
  for item in source_subdir.iterdir():
1631
1617
  src = source_subdir / item.name
1632
1618
  dst = target_path / item.name
1633
1619
  if src.is_dir():
1634
- shutil.copytree(src, dst)
1620
+ robust_copytree(src, dst)
1635
1621
  else:
1636
- shutil.copy2(src, dst)
1622
+ robust_copy2(src, dst)
1637
1623
 
1638
1624
  # Capture commit SHA; close the Repo object immediately so its file
1639
1625
  # handles are released before _rmtree() runs in the finally block.
@@ -1723,16 +1709,17 @@ author: {dep_ref.repo_url.split('/')[0]}
1723
1709
  f"Artifactory ({host}/{prefix}/{owner}/{repo}#{ref})"
1724
1710
  )
1725
1711
  target_path.mkdir(parents=True, exist_ok=True)
1712
+ from ..utils.file_ops import robust_rmtree, robust_copytree, robust_copy2
1726
1713
  if target_path.exists() and any(target_path.iterdir()):
1727
- shutil.rmtree(target_path)
1714
+ robust_rmtree(target_path)
1728
1715
  target_path.mkdir(parents=True, exist_ok=True)
1729
1716
  for item in source_subdir.iterdir():
1730
1717
  src = source_subdir / item.name
1731
1718
  dst = target_path / item.name
1732
1719
  if src.is_dir():
1733
- shutil.copytree(src, dst)
1720
+ robust_copytree(src, dst)
1734
1721
  else:
1735
- shutil.copy2(src, dst)
1722
+ robust_copy2(src, dst)
1736
1723
 
1737
1724
  if progress_obj and progress_task_id is not None:
1738
1725
  progress_obj.update(progress_task_id, completed=80, total=100)
@@ -1771,7 +1758,8 @@ author: {dep_ref.repo_url.split('/')[0]}
1771
1758
 
1772
1759
  _debug(f"Downloading from Artifactory: {host}/{prefix}/{owner}/{repo}#{ref}")
1773
1760
  if target_path.exists() and any(target_path.iterdir()):
1774
- shutil.rmtree(target_path)
1761
+ from ..utils.file_ops import robust_rmtree
1762
+ robust_rmtree(target_path)
1775
1763
  target_path.mkdir(parents=True, exist_ok=True)
1776
1764
  if progress_obj and progress_task_id is not None:
1777
1765
  progress_obj.update(progress_task_id, total=100, completed=10)
@@ -302,6 +302,26 @@ class LockFile:
302
302
  """Save lock file to disk (alias for write)."""
303
303
  self.write(path)
304
304
 
305
+ def is_semantically_equivalent(self, other: "LockFile") -> bool:
306
+ """Return True if *other* has the same deps, MCP servers, and configs.
307
+
308
+ Ignores ``generated_at`` and ``apm_version`` so that a no-change
309
+ install does not dirty the lockfile.
310
+ """
311
+ if self.lockfile_version != other.lockfile_version:
312
+ return False
313
+ if set(self.dependencies.keys()) != set(other.dependencies.keys()):
314
+ return False
315
+ for key, dep in self.dependencies.items():
316
+ other_dep = other.dependencies[key]
317
+ if dep.to_dict() != other_dep.to_dict():
318
+ return False
319
+ if sorted(self.mcp_servers) != sorted(other.mcp_servers):
320
+ return False
321
+ if self.mcp_configs != other.mcp_configs:
322
+ return False
323
+ return True
324
+
305
325
  @classmethod
306
326
  def installed_paths_for_project(cls, project_root: Path) -> List[str]:
307
327
  """Load apm.lock.yaml from project_root and return installed paths.