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
4 changes: 3 additions & 1 deletion docs/src/content/docs/guides/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,14 +335,16 @@ Coverage varies by target and primitive type:

| Target | Status | User-level dir | Primitives | Not supported |
|--------|--------|---------------|------------|---------------|
| Claude Code | Supported | `~/.claude/` | Skills, agents, commands, hooks, instructions | -- |
| Claude Code | Supported | `~/.claude/` (or `$CLAUDE_CONFIG_DIR`) | Skills, agents, commands, hooks, instructions | -- |
| Copilot CLI | Partial | `~/.copilot/` | Skills, agents, hooks | Prompts, instructions |
| Cursor | Partial | `~/.cursor/` | Skills, agents, hooks | Rules |
| OpenCode | Partial | `~/.config/opencode/` | Skills, agents, commands | Hooks |

Target detection mirrors project scope: APM auto-detects by `~/.<target>/` directory presence,
falling back to Copilot. Security scanning runs for global installs.

For Claude Code, if `CLAUDE_CONFIG_DIR` is set (and points inside `$HOME`), `apm install -g --target claude` deploys there instead of `~/.claude/` so primitives land where Claude Code reads them.

### When to use each scope

| Use case | Scope |
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/integrations/ide-tool-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ APM provides first-class support for Claude Code and Claude Desktop through nati

> **Auto-Detection**: Claude integration is automatically enabled when a `.claude/` folder exists in your project. If neither `.github/` nor `.claude/` exists, `apm install` skips folder integration (packages are still installed to `apm_modules/`). To force integration regardless of folder presence, pass an explicit target (e.g. `apm install --target claude`) or set `target: claude` in `apm.yml` -- `.claude/` will be created automatically.

> **User-scope `CLAUDE_CONFIG_DIR`**: At user scope (`apm install -g --target claude`), APM honors the `CLAUDE_CONFIG_DIR` environment variable that Claude Code itself reads. If set (and inside `$HOME`), primitives deploy to that directory instead of `~/.claude/`. Values outside `$HOME` are not normalized.

### Optional: Compiled Output for Claude

Running `apm compile` is optional for Claude Code, which reads deployed primitives natively via `apm install`. If you want a single `CLAUDE.md` instruction file (for example, for Claude Desktop), you can generate one:
Expand Down
25 changes: 23 additions & 2 deletions src/apm_cli/integration/targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,25 @@ def for_scope(self, user_scope: bool = False) -> "TargetProfile | None":
return None

new_root = self.user_root_dir or self.root_dir

# Claude Code honors CLAUDE_CONFIG_DIR (default ~/.claude); mirror
# that at user scope so `apm install -g` lands where Claude reads.
if self.name == "claude":
import os
from pathlib import Path

env = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
if env:
# ``resolve`` collapses ``..`` so traversal segments cannot
# leak into ``root_dir`` and escape ``project_root / root_dir``.
abs_path = Path(env).expanduser().resolve(strict=False)
home = Path.home().resolve(strict=False)
try:
# Keep ``root_dir`` home-relative so cleanup prefix matching holds.
new_root = abs_path.relative_to(home).as_posix()
except ValueError:
new_root = str(abs_path)
Comment thread
shuntaka9576 marked this conversation as resolved.

if self.unsupported_user_primitives:
filtered = {
k: v for k, v in self.primitives.items()
Expand Down Expand Up @@ -259,10 +278,12 @@ def for_scope(self, user_scope: bool = False) -> "TargetProfile | None":
user_root_dir=".copilot",
unsupported_user_primitives=("prompts", "instructions"),
),
# Claude Code -- ~/.claude/ is the documented user-level config directory.
# Claude Code -- the user-level config directory is whatever
# ``CLAUDE_CONFIG_DIR`` points to (default ``~/.claude``). The env
# var override is honored by ``for_scope(user_scope=True)``.
# All primitives are supported at user scope.
Comment thread
shuntaka9576 marked this conversation as resolved.
# Ref: https://docs.anthropic.com/en/docs/claude-code/settings
Comment on lines +281 to 285
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

User-scope Claude deploy root is no longer always ~/.claude/ (it can be overridden by CLAUDE_CONFIG_DIR). Several Starlight docs under docs/src/content/docs/ still hardcode ~/.claude/ for global installs (e.g. guides/dependencies.md and integrations/ide-tool-integration.md). Please update the relevant docs to mention the env-var override (keeping it concise) so guidance matches the new behavior.

Copilot uses AI. Check for mistakes.
# Instructions deploy to .claude/rules/*.md with paths: frontmatter.
# Instructions deploy to <root>/rules/*.md with paths: frontmatter.
# Ref: https://code.claude.com/docs/en/memory#organize-rules-with-claude%2Frules%2F
"claude": TargetProfile(
name="claude",
Expand Down
6 changes: 4 additions & 2 deletions tests/unit/integration/test_data_driven_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,8 +688,10 @@ def test_resolves_root_dir_to_user_root(self):
assert resolved.root_dir == ".copilot"
assert resolved.name == "copilot"

def test_user_root_dir_none_keeps_root_dir(self):
"""When user_root_dir is None, root_dir stays unchanged."""
def test_user_root_dir_none_keeps_root_dir(self, monkeypatch):
"""When user_root_dir is None and CLAUDE_CONFIG_DIR is unset,
the user-scope root_dir falls back to the project-scope value."""
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
from apm_cli.integration.targets import KNOWN_TARGETS
claude = KNOWN_TARGETS["claude"]
assert claude.user_root_dir is None
Expand Down
31 changes: 29 additions & 2 deletions tests/unit/integration/test_scope_install_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,11 +331,11 @@ def test_project_scope(self):
for p in deployed:
assert not (self.project_root / p).exists()

def test_user_scope(self):
def test_user_scope(self, monkeypatch):
"""Claude user scope: same root (.claude/), all primitives available."""
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
target = KNOWN_TARGETS["claude"].for_scope(user_scope=True)
Comment thread
shuntaka9576 marked this conversation as resolved.
assert target is not None
# Claude has no user_root_dir so root stays .claude
assert target.root_dir == ".claude"
# All primitives available at user scope
assert "instructions" in target.primitives
Expand Down Expand Up @@ -401,6 +401,33 @@ def test_user_scope(self):
for p in deployed:
assert not (self.project_root / p).exists()

def test_user_scope_with_claude_config_dir(self, monkeypatch):
"""CLAUDE_CONFIG_DIR override: deploy lands at custom root and uninstall cleans it."""
monkeypatch.setenv("HOME", str(self.project_root))
custom = self.project_root / ".config" / "test-claude"
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(custom))
custom.mkdir(parents=True)
Comment on lines +406 to +409
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

This test patches only HOME to make Path.home() point at self.project_root, but on Windows Path.home() typically prefers USERPROFILE over HOME. In that case for_scope(user_scope=True) will treat CLAUDE_CONFIG_DIR as outside home and return an absolute root_dir, making this assertion fail on the Windows unit-test job. Consider also patching USERPROFILE (and/or HOMEDRIVE/HOMEPATH) when sys.platform == "win32", consistent with the repo's other cross-platform home-override helpers.

Copilot uses AI. Check for mistakes.

target = KNOWN_TARGETS["claude"].for_scope(user_scope=True)
assert target is not None
assert target.root_dir == ".config/test-claude"

pkg_info = _make_pkg(self.project_root, instructions=False, agents=True)
integrator = AgentIntegrator()

result = integrator.integrate_agents_for_target(target, pkg_info, self.project_root)
deployed = _posix_relpaths(self.project_root, result.target_paths)
assert deployed
for p in deployed:
assert p.startswith(".config/test-claude/agents/"), f"unexpected path: {p}"

sync = integrator.sync_for_target(
target, pkg_info.package, self.project_root, managed_files=deployed
)
assert sync["files_removed"] == len(deployed)
for p in deployed:
assert not (self.project_root / p).exists()


# ---------------------------------------------------------------------------
# Cursor
Expand Down
50 changes: 47 additions & 3 deletions tests/unit/integration/test_scope_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,11 @@ def test_resolve_targets_includes_codex_at_user_scope(self):


class TestClaudeScopeResolution:
"""Verify Claude uses .claude at both scopes (user_root_dir is None)."""
"""Verify Claude's scope resolution, including the CLAUDE_CONFIG_DIR
override at user scope."""

def test_project_and_user_scope_same_root(self):
def test_project_and_user_scope_same_root(self, monkeypatch):
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
claude = KNOWN_TARGETS["claude"]
project = claude.for_scope(user_scope=False)
user = claude.for_scope(user_scope=True)
Expand All @@ -240,14 +242,56 @@ def test_all_primitives_available_at_user_scope(self):
assert "instructions" in resolved.primitives
assert "agents" in resolved.primitives

def test_user_scope_expands_tilde(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("CLAUDE_CONFIG_DIR", "~/.config/claude")
scoped = KNOWN_TARGETS["claude"].for_scope(user_scope=True)
assert scoped is not None
assert scoped.root_dir == ".config/claude"

def test_user_scope_blank_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv("CLAUDE_CONFIG_DIR", " ")
scoped = KNOWN_TARGETS["claude"].for_scope(user_scope=True)
assert scoped is not None
assert scoped.root_dir == ".claude"

def test_user_scope_outside_home_keeps_absolute(self, tmp_path, monkeypatch):
home = tmp_path / "home"
outside = tmp_path / "elsewhere"
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(outside))
scoped = KNOWN_TARGETS["claude"].for_scope(user_scope=True)
assert scoped is not None
# Paths outside $HOME are not normalized; preserve the absolute string.
assert scoped.root_dir == str(outside)

def test_user_scope_collapses_dotdot_segments(self, tmp_path, monkeypatch):
# ``..`` must be resolved before relative_to(home) so traversal
# cannot leak into root_dir and later escape project_root / root_dir.
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(home / ".." / "outside"))
scoped = KNOWN_TARGETS["claude"].for_scope(user_scope=True)
assert scoped is not None
assert ".." not in scoped.root_dir
assert scoped.root_dir == str((tmp_path / "outside").resolve())

def test_project_scope_ignores_env_var(self, monkeypatch):
monkeypatch.setenv("CLAUDE_CONFIG_DIR", "/should/not/be/used")
scoped = KNOWN_TARGETS["claude"].for_scope(user_scope=False)
assert scoped is KNOWN_TARGETS["claude"]
assert scoped.root_dir == ".claude"


# -- resolve_targets consistency ----------------------------------------------


class TestResolveTargetsConsistency:
"""Verify resolve_targets produces correct profiles for all targets."""

def test_all_targets_at_user_scope_have_correct_roots(self):
def test_all_targets_at_user_scope_have_correct_roots(self, monkeypatch):
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
with tempfile.TemporaryDirectory() as tmp:
targets = resolve_targets(
Path(tmp), user_scope=True, explicit_target="all"
Expand Down
Loading