Skip to content

fix(install): remove the user-global CLAUDE.md registration on uninstall - #3573

Open
L4XB wants to merge 1 commit into
Graphify-Labs:v8from
L4XB:fix/3572-uninstall-global-claude-md
Open

L4XB wants to merge 1 commit into
Graphify-Labs:v8from
L4XB:fix/3572-uninstall-global-claude-md

Conversation

@L4XB

@L4XB L4XB commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Found via #3572, but not the defect that issue names. Install is fine on v8, and the reverse direction is broken in two ways that stack.

What I measured

Install honours CLAUDE_CONFIG_DIR completely. Sandbox HOME, variable set:

gfcfg/CLAUDE.md
gfcfg/skills/graphify/SKILL.md
gfcfg/skills/graphify/.graphify_version
gfcfg/skills/graphify/references/*.md

Nothing in HOME. _platform_skill_destination reads the variable for the skill tree (#527) and the registration reads it too (#2694). So the reporter is very likely on a version before those, and I said so on the issue.

Uninstall does not.

$ python -c "from graphify.install import claude_uninstall; claude_uninstall()"
  skill removed    ->  $TMP/gfcfg/skills/graphify/SKILL.md
No CLAUDE.md found in current directory - nothing to do

$ grep -c graphify $TMP/gfcfg/CLAUDE.md
3

After graphify uninstall, Claude Code still loads a graphify block pointing at a SKILL.md that was just deleted. The same happens with no CLAUDE_CONFIG_DIR at all, against ~/.claude/CLAUDE.md, so the variable is not what makes it fail.

Two defects, stacked

One. claude_uninstall's targets were all project_dir-relative:

md_targets = [
    project_dir / "CLAUDE.md",
    project_dir / "CLAUDE.local.md",
    project_dir / ".claude" / "CLAUDE.local.md",
]

A user-global uninstall deletes the global skill tree, then looks for the registration in whatever directory it was run from.

Two, and this is the half that made the first fix look wrong. Adding the global file to that list still did not remove the block. The project section comes from always_on/claude-md.md and opens ## graphify; the global one comes from _skill_registration and opens # graphify. _strip_graphify_md_section matched only the H2, so the global block was unremovable wherever it lived. The intermediate state printed graphify section not found in CLAUDE.md, which is how I found it.

The fix

  • _global_claude_md() resolves the file once, and install and uninstall both read it, so they cannot drift on the variable again. Install's inline branch is replaced by the call.
  • The global file joins the uninstall targets only when the global skill is being removed, so project=True still leaves it alone, as claude_uninstall still ignores project_dir with project=False (root cause of #2168, unfixed by v0.9.27) #2215 documents.
  • The strip falls through to the H1 form, ended at the next heading of any level rather than the next H2.
  • The "nothing to do" line names the paths it searched, since it was printed even when the file it had not looked at held the block it had just orphaned.

Tests

Six cells in tests/test_install.py, alongside the existing test_install_claude_md_honors_claude_config_dir. They install and then uninstall from an unrelated working directory, which is what a user does and what made the gap invisible.

cell what it stops
the global registration is removed defect one, default profile
... under CLAUDE_CONFIG_DIR defect one, relocated profile
the user's own notes survive a delete where a section strip belongs
a project uninstall leaves the global block alone the widened search widening the delete
a section written after the block survives the H1 strip running to the next H2

Mutation results, 5 of 5 caught:

mutation result
uninstall does not look at the global file (defect one) caught
the H1 form is not stripped (defect two) caught
the global file ignores CLAUDE_CONFIG_DIR caught
a project uninstall deletes the global registration too caught
the H1 strip runs to the next H2, swallowing a neighbour caught

The last one survived the first run of the battery, and the cell for it is why it does not now. It is reachable rather than theoretical: install appends the block to the end of the file, so anything the user writes afterwards sits below it, and a next-H2 boundary would take an # My own rules section with it.

tests/test_install.py and tests/test_claude_md.py: 126 passed. Ruff clean.

tests/test_install_references.py::test_built_wheel_ships_the_full_skill_payload fails on my machine. It fails identically with this branch stashed, so it is not this change.

Reported as an install problem (Graphify-Labs#3572). Install is fine on `v8`: with
`CLAUDE_CONFIG_DIR` set and a sandbox HOME, the skill tree, its references and
the registration all land under the config dir and nothing touches HOME. What
does not work is the other direction, in two independent ways that stack.

First, `claude_uninstall`'s `md_targets` were all `project_dir`-relative, so a
user-global uninstall deleted the global skill tree and then looked for the
registration in whatever directory it was run from. Measured: the skill goes,
`$CLAUDE_CONFIG_DIR/CLAUDE.md` keeps its three graphify lines, and the command
prints "No CLAUDE.md found in current directory - nothing to do".

Second, adding the global file to that list was not enough. The project block
comes from `always_on/claude-md.md` and opens with `## graphify`; the global
block comes from `_skill_registration` and opens with `# graphify`. The strip
matched only the H2, so the global block was unremovable wherever it lived.

So: one `_global_claude_md()` resolver that install and uninstall both read, so
they cannot drift on `CLAUDE_CONFIG_DIR` again; the global file appended to the
uninstall targets only when the global skill is being removed, so `project=True`
still leaves it alone (Graphify-Labs#2215); and the strip falls through to the H1 form,
ending at the next heading of any level rather than the next H2.

Six cells. Both locations, the user's own notes surviving, a project uninstall
leaving the global block alone, and a section the user wrote BELOW the block
surviving, which is reachable because install appends to the end of the file.
Five mutations, all killed; the last of those cells exists because the
next-H2 boundary survived the first run of the battery.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Fixes a user-global graphify uninstall orphaning its CLAUDE.md registration: install and uninstall now resolve the same file through _global_claude_md, which honours CLAUDE_CONFIG_DIR on both sides instead of only at install time. When remove_user_skill is set, uninstall appends the global CLAUDE.md to its targets and strips the block by its H1 marker (ending at the next heading of any level) as a fallback when the H2 project marker doesn't match, and the "nothing to do" message now names every path searched. Project uninstalls are unchanged, and a section-strip preserves any user notes above or below the block.

Worth a look

  • Project uninstall now strips top-level # graphify sections from local CLAUDE.mdgraphify/install.py:1991 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Global CLAUDE.md strip uses H1 boundary that stops at the block's own H1, removing nothinggraphify/install.py:1994 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 544 functions depend on the 314 functions this change touches.

Health — this change adds coupling hotspots:

  • new: dispatch_command() — 2 callers, 124 callees
  • new: claude_uninstall() — 21 callers, 5 callees
  • new: codebuddy_install() — 20 callers, 5 callees
  • new: claude_install() — 19 callers, 4 callees
  • new: _copy_skill_file() — 12 callers, 6 callees
  • new: gemini_install() — 10 callers, 7 callees
  • new: _project_uninstall() — 5 callers, 13 callees
  • new: dispatch_install_cli() — 2 callers, 31 callees
  • …and 14 more — each is listed as a finding

Verification — 544 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 463 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

31 of 275 test file(s) selected (11%) via static blast radius.

  • tests/test_affected_cli.py — impact
  • tests/test_agents_platform.py — impact
  • tests/test_atomic_version_stamp.py — impact
  • tests/test_claude_md.py — impact
  • tests/test_codebuddy.py — impact
  • tests/test_devin.py — impact
  • tests/test_explain_cli.py — impact
  • tests/test_extract_cli.py — impact
  • tests/test_gemini_hook.py — impact
  • tests/test_global_add_tag_inference.py — impact
  • tests/test_god_nodes_cli.py — impact
  • tests/test_hollow_chunks_arm_shrink_guard.py — impact
  • tests/test_home_sandbox.py — impact
  • tests/test_hook_strict.py — impact
  • tests/test_incomplete_build_guard.py — impact
  • tests/test_install.py — impact, changed-test
  • tests/test_install_references.py — impact
  • tests/test_install_strings.py — impact
  • tests/test_install_version_warning.py — impact
  • tests/test_merge_chunks_validation.py — impact
  • tests/test_multigraph_diagnostics.py — impact
  • tests/test_no_dedup_flag.py — impact
  • tests/test_path_cli.py — impact
  • tests/test_query_cli.py — impact
  • tests/test_query_induced_edges.py — impact
  • tests/test_read_hook.py — impact
  • tests/test_replace_or_append_section.py — impact
  • tests/test_search_hook.py — impact
  • tests/test_skill_version_warning.py — impact
  • tests/test_uninstall_scope.py — impact
  • tests/test_unverified_semantic_shrink.py — impact

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

Could not verify: Could not verify claude\_uninstall.

The verifier did not have enough to check claude\_uninstall, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `project_dir` is annotated `Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify install.

The verifier did not have enough to check install, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_strip\_graphify\_md\_section.

The verifier did not have enough to check \_strip\_graphify\_md\_section, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `target` is annotated `Path` — outside the synthesizable primitive/collection set

· 22 more finding(s) on lines outside this diff (see the check run).

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.

1 participant