inject --uninstall Design
Date: 2026-04-17
Project: sap-devs-cli
Status: Approved
Problem
There is no clean way to reverse inject. Users who want to stop using sap-devs, switch tools, or debug a clean state must manually find and delete fenced sections from files like ~/.claude/CLAUDE.md. This is error-prone and creates a bad off-boarding experience.
Solution
Add --uninstall as a boolean flag to the existing inject command. When set, the engine iterates all file-inject adapters and removes previously injected content instead of writing it.
Scope
replace-sectionadapters: remove the fenced<!-- sap-devs:start:… -->/<!-- sap-devs:end:… -->block from the target file.replace-fileadapters: delete the entire file (sap-devs owns these files entirely).append-mode targets: skip with a warning toos.Stderr—appendmode does not use fenced markers and cannot be auto-removed. This is defensive future-proofing;appendmode is not currently implemented inrunFileInjecteither.clipboard-export,file-export,mcp-wireadapters: silently skipped.--statsflag: silently ignored when--uninstallis set (no pack rendering occurs). It must not be added to the validation block — it is silently ignored by simply not passing it toOptionswheninjectUninstallis true.- Supports
--toolto limit removal to a single adapter ID. - Supports
--projectto only remove project-scope injections. - Supports
--dry-runto preview what would be removed without modifying files. - Prints a per-file summary; no-ops cleanly if nothing is found.
Architecture
Approach
Option A: uninstall logic lives inside the existing engine. engine.Run() checks opts.Uninstall at the top of the per-adapter loop — before any pack trimming, rendering, or budget work — and dispatches to a new runFileUninstall() method instead of runFileInject(). All existing --tool and scope filtering applies unchanged.
New primitives in internal/adapter/file_inject.go
findSection(content, start, end string) (startIdx, endIdx int, status sectionStatus)
Extracts the marker-search logic currently inlined in ReplaceSection. sectionStatus is a new unexported type with three values:
sectionFound— both markers present andstartIdx < endIdx.sectionNotFound— neither marker is present.sectionOrphaned— exactly one marker is present.
startIdx is the byte offset of the first character of the start marker string (matching strings.Index convention). endIdx is the byte offset of the first character of the end marker string (same convention). Both are only meaningful when status == sectionFound.
ReplaceSection is refactored to call findSection and return an error when it gets sectionOrphaned (preserving existing behaviour). When findSection returns sectionNotFound, ReplaceSection continues to the append-on-create path unchanged. No existing ReplaceSection behaviour changes — the refactor is purely internal.
findSection never returns an error itself. The caller is responsible for converting sectionOrphaned into an error with an appropriate message.
RemoveSection(path, section string, dryRun bool, w io.Writer) (found, removed bool, err error)
Returns three values:
found=true, removed=true: live mode, section was present and has been removed.found=true, removed=false: dry-run mode, section was present but not removed (dry-run only previews).found=false, removed=false: section not present in file (either mode);err=nil.- Any of the above with
err!=nil: an error occurred.
Behaviour:
- Reads the file; returns
found=false, removed=false, err=nilif file doesn't exist. - Constructs
startandendmarker strings, then callsfindSection. sectionNotFound→ returnsfound=false, removed=false, err=nil(clean no-op).sectionOrphaned→ returnsfound=false, removed=false, err=<orphaned marker error>.sectionFoundin dry-run mode → writesfmt.Sprintf("[dry-run] would remove section %q from %s\n", section, path)tow; returnsfound=true, removed=false, err=nil. No file write.sectionFoundin live mode → removes the block as follows:- The removed region is bytes
[startIdx, endIdx+len(end))— inclusive of both markers. Formula:result = content[:startIdx] + content[endIdx+len(end):]. - Consume the
\nimmediately following the end marker (advanceendIdx+len(end)by 1 if that byte is\n) — same asReplaceSectiondoes today withafterEnd++. - Always apply a global collapse of three or more consecutive newlines to exactly two — unconditional, applied to all occurrences (not just the first). Required to produce correct output when content surrounded the section with blank lines. Use a loop or regexp:
for strings.Contains(result, "\n\n\n") { result = strings.ReplaceAll(result, "\n\n\n", "\n\n") }. - Example:
"before\n\n<!-- sap-devs:start:X -->\nbody\n<!-- sap-devs:end:X -->\n\nafter\n"→ step 2 consumes trailing\n→ step 3 collapses nothing (no triple-newline) → output"before\n\nafter\n". - Write modified content back to the file.
- Returns
found=true, removed=true, err=nil. Writes nothing tow(result lines are printed byrunFileUninstall).
- The removed region is bytes
DeleteFile(path string, dryRun bool, w io.Writer) (found, deleted bool, err error)
Returns analogous three values: found=true, deleted=true (live, file existed and was removed), found=true, deleted=false (dry-run, file exists but not deleted), found=false, deleted=false (file absent).
- File absent → returns
found=false, deleted=false, err=nil. - File present, dry-run → writes
fmt.Sprintf("[dry-run] would delete %s\n", path)tow; returnsfound=true, deleted=false, err=nil. - File present, live → removes file via
os.Remove; returnsfound=true, deleted=true, err=nilon success. Writes nothing tow.
Refactoring note: The existing ReplaceSection and ReplaceFile use fmt.Printf for dry-run output (writing to raw stdout). To keep the change set minimal, those functions are not migrated to io.Writer in this feature. Only the new RemoveSection and DeleteFile use the w io.Writer pattern.
Engine changes in internal/adapter/engine.go
Add to the Options struct:
Uninstall bool
// Lang is the active language for i18n in runFileUninstall.
// Populated from i18n.ActiveLang in the cmd layer.
// Always use e.opts.Lang inside engine code — do not reference i18n.ActiveLang directly
// from the engine package, as it is a cmd-layer global.
Lang stringengine.Run() returns a RunResult struct instead of bare error:
type RunResult struct {
Found int // sections/files actually removed (live mode only)
DryFound int // sections/files that would be removed (dry-run mode only; 0 in live mode)
Err error
}
func (e *Engine) Run() RunResult { ... }All existing callers of engine.Run() must be updated. Use grep -n '\.Run()' ./internal/adapter/ ./cmd/ to find all call sites before editing — do not rely on a hard-coded count. The pattern to apply is: change require.NoError(t, eng.Run()) to res := eng.Run(); require.NoError(t, res.Err), and if err := eng.Run(); err != nil to res := eng.Run(); if res.Err != nil. Call sites are in internal/adapter/adapter_test.go, cmd/inject.go, and cmd/inject_test.go.
In engine.Run(), the uninstall check is placed at the top of the per-adapter loop body, before any pack-trim, render, or budget operations:
for _, a := range e.adapters {
if e.opts.ToolFilter != "" && a.ID != e.opts.ToolFilter {
continue
}
if e.opts.Uninstall {
if a.Type == "file-inject" {
n, dn, err := e.runFileUninstall(a)
result.Found += n
result.DryFound += dn
if err != nil {
result.Err = errors.Join(result.Err, err)
}
}
continue // skip all other adapter types and all budget/render work
}
// existing budget / trim / render path below
...
}This avoids calling TrimPacks or RenderContext (which require loaded packs) during uninstall, where packs are not loaded.
runFileUninstall(a Adapter) (found, dryFound int, err error):
- Iterates
a.Targets. - Applies scope filtering (skips targets whose scope doesn't match
opts.Scope). An emptyopts.Scopeskips all targets (consistent withrunFileInject). Tests must setopts.Scopeexplicitly. - Expands
~/paths viaExpandHome(). IfExpandHomereturns an error for a target, collect it viaerrors.Joinand continue to remaining targets (consistent with collect-all-errors, unlike fail-fast inrunFileInject). - Dispatches by mode:
replace-section→RemoveSection(path, target.Section, opts.DryRun, e.opts.Out)→(found, removed bool, err)replace-file→DeleteFile(path, opts.DryRun, e.opts.Out)→(found, deleted bool, err)append→ writesi18n.Tf(e.opts.Lang, "inject.uninstall.append_warning", map[string]any{"Path": path}) + "\n"toos.Stderr; skips target (not an error, not counted).- Unknown mode → returns error immediately (same as
runFileInject).
- After each
replace-sectionorreplace-filecall, usesfound(notremoved/deleted) to determine output:- If
found && removed(live mode, section/file was present and removed): increment the localfoundcounter; writefmt.Sprintf(" %s — %s\n", path, i18n.T(e.opts.Lang, key))toe.opts.Outwhere key isinject.uninstall.section_removedorinject.uninstall.file_deleted. - If
found && !removed(dry-run mode, section/file is present): increment localdryFound; the[dry-run]line was already written toe.opts.OutbyRemoveSection/DeleteFile. Write no additional line. - If
!found(section/file not present, either mode): writefmt.Sprintf(" %s — %s\n", path, i18n.T(e.opts.Lang, "inject.uninstall.not_found"))toe.opts.Out. Do not incrementfoundordryFound.
- If
- Error handling: collects all errors via
errors.Join, returns after processing all targets (does not stop on first error).
Command changes in cmd/inject.go
Add --uninstall boolean flag alongside existing inject flags.
Mutual exclusion: validated at the top of RunE — --uninstall is incompatible with --sync and --no-sync. --stats must not be added to the validation block; it is silently ignored by not passing it to Options when injectUninstall is true.
When --uninstall is set, skip:
- Staleness check
- Dynamic context gathering
- Pack loading
Load adapters via loadAdapters() (same as the normal inject path — check the error, return it if non-nil). Pass nil packs and nil profile to adapter.NewEngine directly. Do not use newAdapterEngine — it requires loaded packs and performs layer merging that is unnecessary for uninstall.
Use a bytes.Buffer as the Out writer so the command can inspect what was written:
var buf bytes.Buffer
opts := adapter.Options{
Uninstall: true,
Scope: scope,
ToolFilter: injectTool,
DryRun: injectDryRun,
Lang: i18n.ActiveLang,
Out: &buf,
}
gatheredAdapters, err := loadAdapters()
if err != nil {
return err
}
eng := adapter.NewEngine(gatheredAdapters, nil, nil, opts)
res := eng.Run()
if res.Err != nil {
return res.Err
}Summary output logic:
- In normal mode: if
res.Found > 0, printi18n.T(lang, "inject.uninstall.header")thenbuf.String()tocmd.OutOrStdout(). Otherwise printi18n.T(lang, "inject.uninstall.nothing_found"). - In dry-run mode:
res.Foundis always 0. Useres.DryFound > 0: if non-zero, printi18n.T(lang, "inject.uninstall.dry_run_header")thenbuf.String(); otherwise printinject.uninstall.nothing_found. - Note: if all matched targets are
append-mode (found == 0,DryFound == 0),nothing_foundis printed on stdout alongside the stderr warnings. This combination — stderr append warnings + stdout nothing-found — is intentional: the stderr warnings convey the manual action required, andnothing_foundaccurately reflects that no automatic removal occurred.
Summary output format (normal mode):
Uninstalled SAP developer context:
~/.claude/CLAUDE.md — section removed
~/.cursor/rules/sap-developer-context.mdc — file deletedSummary output format (dry-run mode, sections present):
Would uninstall SAP developer context:
[dry-run] would remove section "SAP Developer Context" from ~/.claude/CLAUDE.md
[dry-run] would delete ~/.cursor/rules/sap-developer-context.mdcIf nothing was found or would be found:
No injected sections found.i18n keys added to both en and de catalogs:
| Key | English | German |
|---|---|---|
inject.uninstall.header | Uninstalled SAP developer context: | SAP-Entwicklerkontext deinstalliert: |
inject.uninstall.dry_run_header | Would uninstall SAP developer context: | Würde SAP-Entwicklerkontext deinstallieren: |
inject.uninstall.section_removed | section removed | Abschnitt entfernt |
inject.uninstall.file_deleted | file deleted | Datei gelöscht |
inject.uninstall.not_found | not found | nicht gefunden |
inject.uninstall.nothing_found | No injected sections found. | Keine injizierten Abschnitte gefunden. |
inject.uninstall.append_warning | warning: cannot auto-remove append-mode target {{.Path}} — remove manually | Warnung: Append-Ziel {{.Path}} kann nicht automatisch entfernt werden — bitte manuell löschen |
Call site for append warning inside runFileUninstall: i18n.Tf(e.opts.Lang, "inject.uninstall.append_warning", map[string]any{"Path": path}). Always use e.opts.Lang inside engine code — do not reference i18n.ActiveLang from the engine package.
Data Flow
inject --uninstall [--tool X] [--project] [--dry-run]
└─ cmd/inject.go: load adapters, build Options{Uninstall:true, Out:&buf, Lang:i18n.ActiveLang}
└─ engine.Run() → RunResult{Found, DryFound, Err}
└─ for each adapter (--tool filter applied):
├─ non-file-inject → skip (continue)
└─ file-inject → runFileUninstall(a) → (found, dryFound int, err)
├─ scope filter applied per target
├─ replace-section → RemoveSection(…, &buf)
│ found+removed → result line to buf (section removed)
│ found+!removed → [dry-run] line to buf (by primitive)
│ !found → not-found line to buf
├─ replace-file → DeleteFile(…, &buf) [same pattern]
└─ append → warning to os.Stderr, skips
└─ cmd: normal: Found>0 → header + buf
dry-run: DryFound>0 → dry_run_header + buf
else → nothing_foundError Handling
- File not found: no-op, not an error.
- Section markers not found: no-op, not an error (printed in summary as "not found").
- Orphaned/mismatched markers (start without end or vice versa):
RemoveSectionreturns an error;runFileUninstallcollects it viaerrors.Joinand continues to remaining targets. append-mode target: warning printed toos.Stderr, target skipped, not an error.ExpandHomeerror for a target: collected viaerrors.Join, continues to remaining targets.- File permission errors: collected by
runFileUninstallviaerrors.Join, returned after all targets processed. loadAdapters()error: returned immediately fromRunE.--uninstallwith--syncor--no-sync: validation error at the top ofRunE.
Testing
- Unit test
findSection: givencontent = "before\n<!-- sap-devs:start:X -->\nbody\n<!-- sap-devs:end:X -->\nafter\n", verifystartIdxandendIdxusingstrings.Indexdirectly in the test rather than hard-coding computed values (manual byte counting is error-prone); status =sectionFound. Also: start marker only →sectionOrphaned; end marker only →sectionOrphaned; neither →sectionNotFound. - Unit test
RemoveSection(live mode): section present →found=true, removed=true, content is"before\n\nafter\n"for input"before\n\n<!-- sap-devs:start:X -->\nbody\n<!-- sap-devs:end:X -->\n\nafter\n", no triple-newline; section absent →found=false, removed=false, err=nil; file absent →found=false, removed=false, err=nil; orphaned start →err!=nil; orphaned end →err!=nil. - Unit test
RemoveSection(dry-run): section present →found=true, removed=false,[dry-run]message written tow, file unchanged; section absent →found=false, removed=false, nothing written tow. - Unit test
DeleteFile: file present, live →found=true, deleted=true; file absent →found=false, deleted=false, err=nil; dry-run, file present →found=true, deleted=false,[dry-run]message written tow, file not deleted. - Engine integration test:
--uninstallremoves a previously injectedreplace-sectiontarget andRunResult.Found == 1;--uninstalldeletes areplace-filetarget andFound == 1; skips non-file-injectadapters; respects--toolfilter; respects--projectscope;--dry-runmakes no disk changes,DryFound == 1, buf contains[dry-run]line;append-mode target emits warning to stderr,Found == 0,DryFound == 0. - Command-level test:
--uninstallwith--syncreturns an error;--uninstallwith--no-syncreturns an error;--uninstallwith--statssucceeds; nothing-found path printsinject.uninstall.nothing_found; dry-run with content prints dry-run header +[dry-run]lines. - Regression: verify all existing
ReplaceSectiontests pass after extractingfindSection; no newReplaceSectiontests needed beyond confirming the refactor does not change observable behaviour. - Caller update: update all
engine.Run()call sites ininternal/adapter/adapter_test.go,cmd/inject.go, andcmd/inject_test.goto theRunResultpattern (res := eng.Run(); require.NoError(t, res.Err)). Usegrep -n '\.Run()' ./internal/adapter/ ./cmd/to locate all sites before editing.
Out of Scope
- MCP server registration cleanup (handled by the
mcpcommand separately). clipboard-exportandfile-exportcleanup.- Hook configuration cleanup.
- Migrating existing
ReplaceSection/ReplaceFiledry-run output toio.Writer(deferred to a future cleanup).