Skip to content

fix(install): keep files deployed to other Claude/Hermes profiles - #3424

Open
Sergei Malykh (xronos-i-am) wants to merge 2 commits into
microsoft:mainfrom
xronos-i-am:fix/3423-profile-stale-cleanup
Open

Sergei Malykh (xronos-i-am) wants to merge 2 commits into
microsoft:mainfrom
xronos-i-am:fix/3423-profile-stale-cleanup

Conversation

@xronos-i-am

@xronos-i-am Sergei Malykh (xronos-i-am) commented Oct 30, 2026 •

Copy link
Copy Markdown

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_stale treated every unrefreshed row of an active target as stale, without checking the root it was deployed under. The lockfile phase then removed those rows through remove_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 $HOME deleted ~/.claude files the same way. Hermes (HERMES_HOME) had the same defect.

The fix: a row of claude or hermes, 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_path and 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_HOME selects its user root through the same TargetProfile.for_scope branch, and current main loses ~/.hermes/skills in the same A -> B scenario.

Known limitations:

  • If a profile is abandoned, its rows stay in the lockfile and its files are not removed automatically. The install reference now says so.
  • A profile nested inside another profile's root (for example CLAUDE_CONFIG_DIR=~/.claude/profiles/b) is still treated as part of the outer profile, so an install into ~/.claude removes 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:v1 approval 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:

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Maintenance / refactor

Testing

  • Tested locally
  • All existing tests pass
  • Added tests for new functionality (if applicable)

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_DIR unset):

  • Unit suite: 25467 passed.
  • Lifecycle smoke (lifecycle_smoke and not lifecycle_merge_group): 391 passed.
  • Claude, Hermes and global-scope integration files with the binary: all passed.
  • Lint job mirror (ruff check/format, R0801, auth signals, architecture boundaries) and git diff --check: clean.

Spec conformance (OpenAPM v0.1)

If this PR changes behaviour that an OpenAPM v0.1 req-XXX covers,
confirm the three-step ritual in the
development guide:

  • Spec edit: docs/src/content/docs/specs/openapm-v0.1.md updated
    (new/changed <a id="req-XXX"></a> anchor + prose + Appendix C
    row).
  • Manifest edit: docs/src/content/docs/specs/manifests/openapm-v0.1.requirements.yml
    updated.
  • Test edit: a @pytest.mark.req("req-XXX") test under
    tests/spec_conformance/ added or extended.
  • CONFORMANCE.{md,json} regenerated via
    uv run --extra dev python -m tests.spec_conformance.gen_statement
    and committed.
  • N/A -- this PR does not change OpenAPM-observable behaviour.

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.

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
@xronos-i-am

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

Comment thread src/apm_cli/integration/targets.py Resolved
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Global install with CLAUDE_CONFIG_DIR deletes skills deployed to the default Claude profile

2 participants