Repository navigation
fix(install): keep files deployed to other Claude/Hermes profiles - #3424
Open
Sergei Malykh (xronos-i-am) wants to merge 2 commits into
Open
Sergei Malykh (xronos-i-am) wants to merge 2 commits into
Sergei Malykh (xronos-i-am) wants to merge 2 commits into
Conversation
Claude (CLAUDE_CONFIG_DIR) and Hermes (HERMES_HOME) profiles share one user lockfile. A global install into one profile treated every unrefreshed row of the active target as stale, regardless of its root, and the lockfile phase deleted the default profile's files. A row of a target whose user root an environment variable selects is now stale only when it lies under the selected root. Rows of other profiles stay in the lockfile and are cleaned up when their own profile is installed into. The fix is deliberately bounded. Unlike microsoft#2884 it does not isolate profiles through APM_HOME: there is still one lockfile and no schema change. Unlike microsoft#3238 it adds no symlink normalization: the root check is a lexical prefix match that neither resolves nor follows symlinks, it only narrows what counts as stale, and every deletion still goes through validate_deploy_path. Uninstall across profiles is out of scope. Refs microsoft#3423
Sergei Malykh (xronos-i-am)
requested review from
Daniel Meppiel (danielmeppiel) and
Sergio Sisternes (sergio-sisternes-epam)
as code owners
October 30, 2026 08:34
Author
|
@microsoft-github-policy-service agree |
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Windows path separator mismatches cause _is_stale() to retain stale Claude/Hermes rows for profiles outside the home directory.
1 open issue
What changed in this PR
This PR addresses #3423 by narrowing install-time stale-file reconciliation so installing into one Claude or Hermes profile preserves deployments under other profile roots.
Changes:
- Centralizes environment-selected profile roots and adds a profile-aware stale-path check.
- Adds reconciliation tests and a Claude CLI lifecycle test covering profile switching and stale-skill cleanup.
- Documents profile preservation, abandoned-profile cleanup, and the fix.
| File | Description |
|---|---|
tests/unit/core/test_deployment_state.py |
Tests preservation of other Claude and Hermes profile rows. |
tests/integration/test_claude_config_profile_lifecycle.py |
Tests profile switching, idempotency, and selected-profile cleanup. |
src/apm_cli/integration/targets.py |
Defines profile environment variables and the lexical root check. |
src/apm_cli/core/deployment_state.py |
Exempts other-profile deployments from active-target stale reconciliation. |
docs/src/content/docs/reference/cli/install.md |
Documents Claude file preservation and abandoned-profile behavior. |
docs/src/content/docs/integrations/hermes.md |
Documents selected-home cleanup for Hermes. |
CHANGELOG.md |
Records the cross-profile install fix. |
Review effort: Balanced
An absolute profile root keeps native separators (D:\profiles\b on Windows) while lockfile values use forward slashes, so rows of the selected profile outside $HOME looked like another profile's and were never treated as stale. Compare both sides in POSIX form; the check stays lexical and does not resolve symlinks. Refs microsoft#3423
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Description
A global install into one Claude profile (
CLAUDE_CONFIG_DIR) deleted files that an earlier install had deployed to another profile. All profiles share one user lockfile (~/.apm/apm.lock.yaml).DeploymentReconciler._is_staletreated every unrefreshed row of an active target as stale, without checking the root it was deployed under. The lockfile phase then removed those rows throughremove_stale_deployed_files(targets=None). The static prefix list there knows.claude/but not a custom profile root, so the deletion only went one way: installing into a second profile removed the default profile's files, and installing into the default profile kept the second profile's files. A profile outside$HOMEdeleted~/.claudefiles the same way. Hermes (HERMES_HOME) had the same defect.The fix: a row of
claudeorhermes, the targets whose user root an environment variable selects, is stale only when its path lies under the currently selected root. Rows under other roots stay in the lockfile unchanged and are cleaned up when their own profile is installed into. Other targets keep their current behaviour, so legacy path migrations are unaffected.The root check is a lexical prefix match against the already resolved target root. It does not resolve or follow symlinks and adds no new removal path. Every deletion still goes through
validate_deploy_pathand its symlink-component checks; the change only narrows what is considered stale. This keeps clear of the symlink-following problem found in #3238.Hermes is included deliberately.
HERMES_HOMEselects its user root through the sameTargetProfile.for_scopebranch, and current main loses~/.hermes/skillsin the same A -> B scenario.Known limitations:
CLAUDE_CONFIG_DIR=~/.claude/profiles/b) is still treated as part of the outer profile, so an install into~/.clauderemoves it, as it does on main. Neither APM nor Claude Code documents nested profiles; Claude Code's multi-account example uses a sibling directory (~/.claude-work).Issue and approved scope
Issue: #3423
Human scope-approval comment: none yet. No
apm-scope:v1approval has been recorded on #3423.Does this PR complete the issue, or what remains?
Fixes #3423: it completes the install-side bug reported there. Out of scope:
apm uninstall -gacross profiles: on current main it removes the other profile's files and refuses the selected profile's files. Reported in [BUG] Global uninstall with CLAUDE_CONFIG_DIR deletes the default profile's files and keeps the selected profile's #3425.APM_HOME([BUG] Document and align APM_HOME behavior for global user state #2884).Type of change
Testing
New tests (all of them fail on current main and pass with this change):
tests/integration/test_claude_config_profile_lifecycle.py::test_claude_profile_install_keeps_files_deployed_to_other_profiles(lifecycle smoke, real CLI, second profile inside and outside$HOME). Covers A -> B -> A -> B with both profiles intact, an idempotent repeat install (lockfile bytes unchanged), and a skill dropped from the package being removed only from the profile being installed into until the other profile is reinstalled.tests/unit/core/test_deployment_state.py::test_other_profile_rows_are_not_stale(Claude and Hermes, relative and absolute roots).Local results (Linux, Python 3.12,
CLAUDE_CONFIG_DIRunset):lifecycle_smoke and not lifecycle_merge_group): 391 passed.git diff --check: clean.Spec conformance (OpenAPM v0.1)
If this PR changes behaviour that an OpenAPM v0.1
req-XXXcovers,confirm the three-step ritual in the
development guide:
docs/src/content/docs/specs/openapm-v0.1.mdupdated(new/changed
<a id="req-XXX"></a>anchor + prose + Appendix Crow).
docs/src/content/docs/specs/manifests/openapm-v0.1.requirements.ymlupdated.
@pytest.mark.req("req-XXX")test undertests/spec_conformance/added or extended.CONFORMANCE.{md,json}regenerated viauv run --extra dev python -m tests.spec_conformance.gen_statementand committed.
The change narrows cleanup to the selected deploy root, which [req-tg-002] already requires; no requirement changes. Mode B counts 16 substantive critical-path lines, below the waiver threshold.