Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion docs/src/content/docs/guides/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,10 @@ curl -H "Authorization: token $GITHUB_CLI_PAT" https://api.github.com/user
- Remove circular references
- Consider merging closely related packages

#### "File conflicts during installation"
**Problem**: Local files collide with package files during `apm install`
**Resolution**: APM skips files that exist locally and aren't managed by APM. The diagnostic summary at the end of install shows how many files were skipped. Use `--verbose` to see which files, or `--force` to overwrite.

#### "File conflicts during compilation"
**Problem**: Multiple packages or local files have same names
**Resolution**: Local files automatically override dependency files with same names
Expand All @@ -683,7 +687,10 @@ apm deps tree
# Preview installation without changes
apm install --dry-run

# Enable verbose logging
# See detailed diagnostics (skipped files, errors)
apm install --verbose

# Enable verbose logging for compilation
apm compile --verbose
```

Expand Down
16 changes: 14 additions & 2 deletions docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ apm install [PACKAGES...] [OPTIONS]
- `--force` - Overwrite locally-authored files on collision
- `--dry-run` - Show what would be installed without installing
- `--parallel-downloads INTEGER` - Max concurrent package downloads (default: 4, 0 to disable)
- `--verbose` - Show detailed installation information
- `--verbose` - Show individual file paths and full error details in the diagnostic summary
- `--trust-transitive-mcp` - Trust self-defined MCP servers from transitive packages (skip re-declaration requirement)

**Behavior:**
Expand Down Expand Up @@ -193,7 +193,19 @@ When you run `apm install`, APM automatically integrates primitives from install
- **Control**: Disable with `apm config set auto-integrate false`
- **Smart updates**: Only updates when package version/commit changes
- **Hooks**: Hook `.json` files → `.github/hooks/*.json` with scripts bundled
- **Collision detection**: Skips local files with a warning; use `--force` to overwrite
- **Collision detection**: Skips local files that aren't managed by APM; use `--force` to overwrite

**Diagnostic Summary:**

After installation completes, APM prints a grouped diagnostic summary instead of inline warnings. Categories include collisions (skipped files), sub-skill overwrites, warnings, and errors.

- **Normal mode**: Shows counts and actionable tips (e.g., "9 files skipped -- use `apm install --force` to overwrite")
- **Verbose mode** (`--verbose`): Additionally lists individual file paths grouped by package, and full error details

```bash
# See exactly which files were skipped or had issues
apm install --verbose
```

**Claude Integration (`.claude/` present):**

Expand Down
38 changes: 32 additions & 6 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import click

from ..utils.console import _rich_error, _rich_info, _rich_success, _rich_warning
from ..utils.diagnostics import DiagnosticCollector
from ..utils.github_host import default_host, is_valid_fqdn
from ._helpers import (
_create_minimal_apm_yml,
Expand Down Expand Up @@ -400,6 +401,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
if _existing_lock:
old_mcp_servers = builtins.set(_existing_lock.mcp_servers)

apm_diagnostics = None
if should_install_apm and apm_deps:
if not APM_DEPS_AVAILABLE:
_rich_error("APM dependency system not available")
Expand All @@ -410,7 +412,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
# If specific packages were requested, only install those
# Otherwise install all from apm.yml
only_pkgs = builtins.list(packages) if packages else None
apm_count, prompt_count, agent_count = _install_apm_dependencies(
apm_count, prompt_count, agent_count, apm_diagnostics = _install_apm_dependencies(
apm_package, update, verbose, only_pkgs, force=force,
parallel_downloads=parallel_downloads,
)
Expand Down Expand Up @@ -462,6 +464,8 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo

# Show beautiful post-install summary
_rich_blank_line()
if apm_diagnostics and apm_diagnostics.has_diagnostics:
apm_diagnostics.render_summary()
if not only:
# Load apm.yml config for summary
apm_config = _load_apm_config()
Expand Down Expand Up @@ -689,6 +693,7 @@ def _collect_descendants(node, visited=None):
command_integrator = CommandIntegrator()
hook_integrator = HookIntegrator()
instruction_integrator = InstructionIntegrator()
diagnostics = DiagnosticCollector(verbose=verbose)
total_prompts_integrated = 0
total_agents_integrated = 0
total_skills_integrated = 0
Expand Down Expand Up @@ -946,6 +951,7 @@ def _collect_descendants(node, visited=None):
prompt_integrator.integrate_package_prompts(
cached_package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
)
if prompt_result.files_integrated > 0:
Comment on lines 951 to 957
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diagnostics are being threaded through to the prompt/agent/etc. integrators here, but the nearby skill_integrator.integrate_package_skill(...) call in this same integration block is still invoked without diagnostics=diagnostics. That leaves skill name-normalization warnings and sub-skill overwrite notices outside the end-of-install diagnostic summary and may reintroduce inline noise.

This issue also appears on line 1208 of the same file.

Copilot uses AI. Check for mistakes.
Expand All @@ -969,6 +975,7 @@ def _collect_descendants(node, visited=None):
agent_integrator.integrate_package_agents(
cached_package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
)
if agent_result.files_integrated > 0:
Expand Down Expand Up @@ -1012,6 +1019,7 @@ def _collect_descendants(node, visited=None):
instruction_integrator.integrate_package_instructions(
cached_package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
)
if instruction_result.files_integrated > 0:
Expand All @@ -1032,6 +1040,7 @@ def _collect_descendants(node, visited=None):
agent_integrator.integrate_package_agents_claude(
cached_package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
)
if claude_agent_result.files_integrated > 0:
Expand All @@ -1050,6 +1059,7 @@ def _collect_descendants(node, visited=None):
command_integrator.integrate_package_commands(
cached_package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
)
if command_result.files_integrated > 0:
Expand All @@ -1072,6 +1082,7 @@ def _collect_descendants(node, visited=None):
hook_result = hook_integrator.integrate_package_hooks(
cached_package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
if hook_result.hooks_integrated > 0:
total_hooks_integrated += hook_result.hooks_integrated
Expand All @@ -1084,6 +1095,7 @@ def _collect_descendants(node, visited=None):
hook_result_claude = hook_integrator.integrate_package_hooks_claude(
cached_package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
if hook_result_claude.hooks_integrated > 0:
total_hooks_integrated += hook_result_claude.hooks_integrated
Expand All @@ -1097,8 +1109,9 @@ def _collect_descendants(node, visited=None):
package_deployed_files[dep_key] = dep_deployed
except Exception as e:
# Don't fail installation if integration fails
_rich_warning(
f" ⚠ Failed to integrate primitives from cached package: {e}"
diagnostics.error(
f"Failed to integrate primitives from cached package: {e}",
package=dep_key,
)

continue
Expand Down Expand Up @@ -1195,6 +1208,7 @@ def _collect_descendants(node, visited=None):
prompt_integrator.integrate_package_prompts(
package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
)
if prompt_result.files_integrated > 0:
Expand All @@ -1218,6 +1232,7 @@ def _collect_descendants(node, visited=None):
agent_integrator.integrate_package_agents(
package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
)
if agent_result.files_integrated > 0:
Expand Down Expand Up @@ -1261,6 +1276,7 @@ def _collect_descendants(node, visited=None):
instruction_integrator.integrate_package_instructions(
package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
)
if instruction_result.files_integrated > 0:
Expand All @@ -1281,6 +1297,7 @@ def _collect_descendants(node, visited=None):
agent_integrator.integrate_package_agents_claude(
package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
)
if claude_agent_result.files_integrated > 0:
Expand All @@ -1299,6 +1316,7 @@ def _collect_descendants(node, visited=None):
command_integrator.integrate_package_commands(
package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
)
if command_result.files_integrated > 0:
Expand All @@ -1321,6 +1339,7 @@ def _collect_descendants(node, visited=None):
hook_result = hook_integrator.integrate_package_hooks(
package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
if hook_result.hooks_integrated > 0:
total_hooks_integrated += hook_result.hooks_integrated
Expand All @@ -1333,6 +1352,7 @@ def _collect_descendants(node, visited=None):
hook_result_claude = hook_integrator.integrate_package_hooks_claude(
package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
if hook_result_claude.hooks_integrated > 0:
total_hooks_integrated += hook_result_claude.hooks_integrated
Expand All @@ -1346,7 +1366,10 @@ def _collect_descendants(node, visited=None):
package_deployed_files[dep_ref.get_unique_key()] = dep_deployed_fresh
except Exception as e:
# Don't fail installation if integration fails
_rich_warning(f" ⚠ Failed to integrate primitives: {e}")
diagnostics.error(
f"Failed to integrate primitives: {e}",
package=dep_ref.get_unique_key(),
)

except Exception as e:
display_name = (
Expand All @@ -1355,7 +1378,10 @@ def _collect_descendants(node, visited=None):
# Remove the progress task on error
if "task_id" in locals():
progress.remove_task(task_id)
_rich_error(f"❌ Failed to install {display_name}: {e}")
diagnostics.error(
f"Failed to install {display_name}: {e}",
package=dep_ref.get_unique_key(),
)
# Continue with other packages instead of failing completely
continue

Expand Down Expand Up @@ -1415,7 +1441,7 @@ def _collect_descendants(node, visited=None):

_rich_success(f"Installed {installed_count} APM dependencies")

return installed_count, total_prompts_integrated, total_agents_integrated
return installed_count, total_prompts_integrated, total_agents_integrated, diagnostics

except Exception as e:
raise RuntimeError(f"Failed to resolve APM dependencies: {e}")
Expand Down
12 changes: 7 additions & 5 deletions src/apm_cli/integration/agent_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ def copy_agent(self, source: Path, target: Path) -> int:

def integrate_package_agents(self, package_info, project_root: Path,
force: bool = False,
managed_files: set = None) -> IntegrationResult:
managed_files: set = None,
diagnostics=None) -> IntegrationResult:
"""Integrate all agents from a package into .github/agents/.

Deploys with clean filenames. Skips user-authored files unless force=True.
Expand Down Expand Up @@ -154,7 +155,7 @@ def integrate_package_agents(self, package_info, project_root: Path,
target_path = agents_dir / target_filename
rel_path = str(target_path.relative_to(project_root))

if self.check_collision(target_path, rel_path, managed_files, force):
if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics):
files_skipped += 1
continue

Expand All @@ -168,7 +169,7 @@ def integrate_package_agents(self, package_info, project_root: Path,
claude_filename = self.get_target_filename_claude(source_file, package_info.package.name)
claude_target = claude_agents_dir / claude_filename
claude_rel = str(claude_target.relative_to(project_root))
if not self.check_collision(claude_target, claude_rel, managed_files, force):
if not self.check_collision(claude_target, claude_rel, managed_files, force, diagnostics=diagnostics):
self.copy_agent(source_file, claude_target)
target_paths.append(claude_target)

Expand Down Expand Up @@ -204,7 +205,8 @@ def get_target_filename_claude(self, source_file: Path, package_name: str) -> st

def integrate_package_agents_claude(self, package_info, project_root: Path,
force: bool = False,
managed_files: set = None) -> IntegrationResult:
managed_files: set = None,
diagnostics=None) -> IntegrationResult:
"""Integrate all agents from a package into .claude/agents/.

Deploys with clean filenames. Skips user-authored files unless force=True.
Expand Down Expand Up @@ -246,7 +248,7 @@ def integrate_package_agents_claude(self, package_info, project_root: Path,
target_path = agents_dir / target_filename
rel_path = str(target_path.relative_to(project_root))

if self.check_collision(target_path, rel_path, managed_files, force):
if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics):
files_skipped += 1
continue

Expand Down
18 changes: 11 additions & 7 deletions src/apm_cli/integration/base_integrator.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"""Base integrator with shared collision detection and sync logic."""

import re
import sys
from pathlib import Path
from typing import Dict, List, Optional, Set

from dataclasses import dataclass, field

from apm_cli.compilation.link_resolver import UnifiedLinkResolver
from apm_cli.primitives.discovery import discover_primitives
from apm_cli.utils.console import _rich_warning


@dataclass
Expand Down Expand Up @@ -51,6 +51,7 @@ def check_collision(
rel_path: str,
managed_files: Optional[Set[str]],
force: bool,
diagnostics=None,
) -> bool:
"""Return True if *target_path* is a user-authored collision.

Expand All @@ -60,7 +61,8 @@ def check_collision(
3. ``rel_path`` is **not** in the managed set (→ user-authored)
4. ``force`` is ``False``

When a collision is detected a warning is emitted to *stderr*.
When *diagnostics* is provided the skip is recorded there;
otherwise a warning is emitted via ``_rich_warning``.

.. note:: Callers must pre-normalize *managed_files* with
forward-slash separators (see ``normalize_managed_files``).
Expand All @@ -75,11 +77,13 @@ def check_collision(
if force:
return False

print(
f"\u26a0\ufe0f Skipping {rel_path} \u2014 local file exists (not managed by APM). "
f"Use 'apm install --force' to overwrite.",
file=sys.stderr,
)
if diagnostics is not None:
diagnostics.skip(rel_path)
else:
_rich_warning(
f"Skipping {rel_path} — local file exists (not managed by APM). "
f"Use 'apm install --force' to overwrite."
)
Comment on lines 64 to +86
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When routing collisions into diagnostics, the recorded entry does not include the package name. In verbose mode, _render_collision_group() groups by package, so collisions collected via check_collision() will all fall under the empty-package bucket and won’t match the "grouped by package" behavior described in the PR. Consider extending check_collision() to accept an optional package (or package_name) and pass it from integrators (e.g., package_info.package.name).

Copilot uses AI. Check for mistakes.
return True

@staticmethod
Expand Down
5 changes: 3 additions & 2 deletions src/apm_cli/integration/command_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ def integrate_command(self, source: Path, target: Path, package_info, original_p

def integrate_package_commands(self, package_info, project_root: Path,
force: bool = False,
managed_files: set = None) -> IntegrationResult:
managed_files: set = None,
diagnostics=None) -> IntegrationResult:
"""Integrate all prompt files from a package as Claude commands.

Deploys with clean filenames. Skips user-authored files unless force=True.
Expand Down Expand Up @@ -137,7 +138,7 @@ def integrate_package_commands(self, package_info, project_root: Path,
target_path = commands_dir / f"{base_name}.md"
rel_path = str(target_path.relative_to(project_root))

if self.check_collision(target_path, rel_path, managed_files, force):
if self.check_collision(target_path, rel_path, managed_files, force, diagnostics=diagnostics):
files_skipped += 1
continue

Expand Down
Loading
Loading