Skip to content

feat(uninstall): add granular uninstall flow on current main#241

Merged
Alan-TheGentleman merged 14 commits intoGentleman-Programming:mainfrom
IrrealV:feat/issue-102-clean
Apr 13, 2026
Merged

feat(uninstall): add granular uninstall flow on current main#241
Alan-TheGentleman merged 14 commits intoGentleman-Programming:mainfrom
IrrealV:feat/issue-102-clean

Conversation

@IrrealV
Copy link
Copy Markdown
Contributor

@IrrealV IrrealV commented Apr 5, 2026

🔗 Linked Issue

Closes #102


🏷️ PR Type

What kind of change does this PR introduce?

  • type:bug — Bug fix (non-breaking change that fixes an issue)
  • type:feature — New feature (non-breaking change that adds functionality)
  • type:docs — Documentation only
  • type:refactor — Code refactoring (no functional changes)
  • type:chore — Build, CI, or tooling changes
  • type:breaking-change — Breaking change (fix or feature that changes existing behavior)

📝 Summary

  • Adds a granular uninstall flow to the current TUI, including partial uninstall, full uninstall, full uninstall with cleanup, and full uninstall followed by clean install.
  • Ports the uninstall feature cleanly onto current main without the unrelated branch drift that affected the previous PR.
  • Extends uninstall cleanup to cover current OpenCode SDD profiles while preserving non-managed user configuration.

📂 Changes

File / Area What Changed
internal/tui/model.go Integrated uninstall screens and state into the current TUI flow on top of today’s main branch
internal/tui/router.go Added navigation routes for uninstall mode, selection, confirmation, and result screens
internal/tui/screens/uninstall.go Added the granular uninstall UI, warnings, and result rendering
internal/app/app.go Wired TUI uninstall execution through the app layer
internal/cli/uninstall.go Added CLI uninstall execution and reporting
internal/components/uninstall/* Added uninstall planning, cleanup, snapshotting, manual-action reporting, and regression coverage
internal/components/filemerge/* Preserved the uninstall-related hardening for managed rewrites and atomic writes
internal/backup/manifest.go Added uninstall backup metadata so uninstall snapshots are labeled correctly
docs/usage.md Documented the granular uninstall flow

🧪 Test Plan

Unit Tests

go test ./internal/components/uninstall ./internal/backup ./internal/tui ./internal/app ./internal/cli

Additional validation

go test ./internal/components/sdd ./internal/model
  • Unit tests pass for affected packages
  • Manually verified the code paths and uninstall flow wiring
  • Added coverage for clean-install/full-remove TUI paths and SDD profile cleanup

🤖 Automated Checks

The following checks run automatically on this PR:

Check Status Description
Check Issue Reference PR body must contain Closes/Fixes/Resolves #N
Check Issue Has status:approved Linked issue must have been approved before work began
Check PR Has type:* Label Exactly one type:* label must be applied
Unit / targeted Go tests Affected package tests must pass

✅ Contributor Checklist

  • PR is linked to an issue with status:approved
  • I will add exactly one type:* label to this PR
  • Tests for affected packages pass
  • I updated docs for the new uninstall flow
  • My commits follow Conventional Commits format
  • My commits do not include Co-Authored-By trailers

💬 Notes for Reviewers

This PR replaces the previous uninstall PR with a clean branch based on current main.

The prior PR included unrelated drift because the original work had been started from an old base. This branch was rebuilt on top of today’s main, preserving the uninstall logic and adapting it to current repository state.

One important adaptation versus the original implementation: uninstall now also removes current OpenCode SDD profile agent entries (sdd-*-<profile>) when uninstalling the SDD component, while leaving unrelated user settings intact.

Copilot AI review requested due to automatic review settings April 5, 2026 19:18
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a first-class managed uninstall feature across the TUI and CLI, backed by a new uninstall service that plans removals, snapshots affected files, and reports manual cleanup steps. This closes the lifecycle gap between install/sync and safe reversal, including SDD profile-agent cleanup for OpenCode.

Changes:

  • Introduces multi-mode uninstall flow in the TUI (partial/full/full-remove/clean-install) with confirmation + results rendering.
  • Adds CLI uninstall command, output reporting, and app-layer wiring.
  • Implements uninstall planning/execution with pre-uninstall snapshots + cleanup utilities; extends backup manifest metadata and hardens atomic writes.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
internal/tui/screens/uninstall.go New uninstall mode/selection/confirm/result screen renderers.
internal/tui/screens/uninstall_result_test.go Verifies uninstall result rendering includes manual cleanup section.
internal/tui/router.go Adds linear routes for uninstall screens.
internal/tui/model.go Adds uninstall state, navigation, execution (incl. full-remove + clean-install behavior).
internal/tui/model_test.go Adds coverage for uninstall navigation and execution paths.
internal/model/types.go Introduces UninstallMode enum.
internal/components/uninstall/service.go New uninstall service: plan building, snapshotting, cleanup operations, and state updates.
internal/components/uninstall/service_test.go Tests for manual-action reporting and SDD profile agent cleanup.
internal/components/uninstall/cleaners.go Cleanup helpers for markdown/JSON/TOML and managed-file safety checks.
internal/components/uninstall/cleaners_test.go Broad unit coverage for cleaners and managed-file constraints.
internal/components/filemerge/writer.go Hardens atomic writes (symlink/size checks, fsync temp + directory).
internal/components/filemerge/writer_test.go Updates expectations and adds tests for new hardening behaviors.
internal/cli/uninstall.go Adds CLI uninstall flag parsing, confirmation prompt, execution, and reporting.
internal/cli/command_output_test.go Verifies uninstall report output includes manual cleanup section.
internal/backup/manifest.go Adds BackupSourceUninstall metadata.
internal/backup/manifest_test.go Tests uninstall source label/display behavior.
internal/app/app.go Wires gentle-ai uninstall command through app entrypoint.
docs/usage.md Documents TUI uninstall flow and CLI uninstall usage/flags.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/tui/model.go Outdated
case ScreenModelConfig:
return len(screens.ModelConfigOptions())
case ScreenUninstallMode:
return len(screens.UninstallModeOptions()) + 2
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

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

optionCount() for ScreenUninstallMode returns len(UninstallModeOptions()) + 2, but the mode screen renders only one extra option ("Back") and confirmSelection() only handles cursor == len(options) for back. This allows the cursor to land on a non-existent option (and enter will no-op). Adjust the count to match the rendered options (likely + 1).

Suggested change
return len(screens.UninstallModeOptions()) + 2
return len(screens.UninstallModeOptions()) + 1

Copilot uses AI. Check for mistakes.
Comment thread internal/tui/model.go
Comment on lines +1866 to +1874
// If FullRemove mode, attempt to delete the binary itself
if mode == model.UninstallModeFullRemove {
execPath, execErr := os.Executable()
if execErr != nil {
return UninstallDoneMsg{Result: result, Err: fmt.Errorf("uninstall succeeded but failed to locate binary: %w", execErr)}
}
if removeErr := os.Remove(execPath); removeErr != nil {
return UninstallDoneMsg{Result: result, Err: fmt.Errorf("uninstall succeeded but failed to remove binary at %q: %w", execPath, removeErr)}
}
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

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

In FullRemove mode, attempting to os.Remove(os.Executable()) will reliably fail on Windows (running executables are locked) and can also fail on some Unix setups depending on install location/permissions. Treating this as a hard error makes the UI report an overall uninstall failure even though the managed uninstall already succeeded. Consider reporting this as a manual action (or a separate non-fatal field) so the uninstall result stays successful while still telling the user how to remove the binary.

Suggested change
// If FullRemove mode, attempt to delete the binary itself
if mode == model.UninstallModeFullRemove {
execPath, execErr := os.Executable()
if execErr != nil {
return UninstallDoneMsg{Result: result, Err: fmt.Errorf("uninstall succeeded but failed to locate binary: %w", execErr)}
}
if removeErr := os.Remove(execPath); removeErr != nil {
return UninstallDoneMsg{Result: result, Err: fmt.Errorf("uninstall succeeded but failed to remove binary at %q: %w", execPath, removeErr)}
}
// If FullRemove mode, attempt to delete the binary itself.
// This is best-effort because removing the currently running executable
// reliably fails on Windows and may fail on some Unix setups depending
// on permissions or install location. The managed uninstall has already
// succeeded, so do not turn self-removal failures into an overall error.
if mode == model.UninstallModeFullRemove {
if execPath, execErr := os.Executable(); execErr == nil {
_ = os.Remove(execPath)
}

Copilot uses AI. Check for mistakes.
Comment on lines +715 to +726
func removeFile(path string) operation {
return operation{
typeID: opRemoveFile,
path: path,
apply: func(path string) (bool, bool, error) {
_, statErr := os.Stat(path)
if statErr != nil {
if os.IsNotExist(statErr) {
return false, false, nil
}
return false, false, statErr
}
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

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

removeFile uses os.Stat (follows symlinks) to decide existence. If a managed path has been replaced with a broken symlink, os.Stat returns not-exist and the uninstall will skip removing the symlink, leaving the managed path behind. Using os.Lstat here would allow removing broken symlinks as well and avoids following links during the existence check.

Copilot uses AI. Check for mistakes.
Comment on lines +735 to +745
func removeTree(path string) operation {
return operation{
typeID: opRemoveTree,
path: path,
apply: func(path string) (bool, bool, error) {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return false, false, nil
}
return false, false, err
}
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

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

removeTree checks existence with os.Stat, so a broken symlink at path will be treated as "does not exist" and skipped, leaving the symlink behind. Switching the pre-check to os.Lstat would allow os.RemoveAll to remove the symlink itself even when it’s dangling, making uninstall more complete and avoiding following symlinks during the check.

Copilot uses AI. Check for mistakes.
@IrrealV
Copy link
Copy Markdown
Contributor Author

IrrealV commented Apr 5, 2026

Update from latest push (521454e):\n\n- Fixed a real runtime gap in the TUI uninstall flow by wiring in app startup and exposing a reachable Managed uninstall option from Welcome -> Uninstall Mode.\n- Hardened SDD cleanup coverage by including in managed uninstall enumerations, so SDD removals do not leave onboard artifacts behind.\n- Added regression tests for welcome navigation (with/without profiles) and updated uninstall cleanup assertions for onboard variants.\n\nValidation run locally:\n- ok github.com/gentleman-programming/gentle-ai/internal/tui (cached)\n- ok github.com/gentleman-programming/gentle-ai/internal/tui (cached)\n- ok github.com/gentleman-programming/gentle-ai/internal/tui/screens (cached)\n- ok github.com/gentleman-programming/gentle-ai/internal/app 10.399s
ok github.com/gentleman-programming/gentle-ai/internal/components/uninstall (cached)\n\nThis push is focused on closing the Judgment Day findings and the follow-up suspect navigation path.

@IrrealV
Copy link
Copy Markdown
Contributor Author

IrrealV commented Apr 5, 2026

Update from latest push (521454e):

  • Fixed a real runtime gap in the TUI uninstall flow by wiring UninstallFn in app startup and exposing a reachable Managed uninstall option from Welcome -> Uninstall Mode.
  • Hardened SDD cleanup coverage by including sdd-onboard in managed uninstall enumerations, so SDD removals do not leave onboard artifacts behind.
  • Added regression tests for welcome navigation (with/without profiles) and updated uninstall cleanup assertions for onboard variants.

Validation run locally:

  • go test ./internal/tui -run Uninstall
  • go test ./internal/tui
  • go test ./internal/tui/screens -run WelcomeOptions
  • go test ./internal/app ./internal/components/uninstall

This push is focused on closing the Judgment Day findings and the follow-up suspect navigation path.

Copy link
Copy Markdown
Contributor

@Alan-TheGentleman Alan-TheGentleman left a comment

Choose a reason for hiding this comment

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

Review: feat(uninstall): add granular uninstall flow

Solid feature PR. Clean architecture that follows the existing codebase patterns well — operation planning separated from execution, proper backup-before-mutate flow, and meaningful test coverage across all layers.

Must address

  1. Breaking behavior change in WriteFileAtomic: The removal of os.Chmod(dir, 0o755) means directories created with 0o555 by previous installer versions will now cause failures. This is a security improvement but should be called out in the PR description / release notes since it affects sync and install too, not just uninstall.

  2. FullRemove + Homebrew: When the binary was installed via brew install, os.Remove(execPath) leaves a dangling symlink in the bin path. Consider detecting Homebrew-managed installs and warning the user to use brew uninstall instead, or at least adding it to ManualActions.

Minor suggestions

  1. ScreenUninstallMode option count uses +2 but only renders 1 extra item ("Back"). The cursor can move past the visible list.

  2. appendPathSection calls os.Getwd() per-path inside the loop — easy to hoist.

  3. sddSkillIDs() and sddPhaseAgents are near-duplicates — consider sharing.

  4. The JSON normalizer (stripJSONComments/stripTrailingCommas) would benefit from fuzz testing given its complexity.

What I liked

  • Backup-before-mutate is exactly right for destructive operations
  • The mergeRewriteOps pattern for composing multiple mutations on the same file is elegant
  • Good use of readManagedFile with size limits and symlink rejection as defense-in-depth
  • Test coverage is meaningful — not just "it doesn't crash" but verifying preservation of user content
  • Clean Install mode (uninstall + sync) is a great UX addition for fixing broken configs

Overall this is well-crafted work. The items above are refinements, not blockers. Leaning approve once #1 and #2 are addressed or acknowledged.

@IrrealV
Copy link
Copy Markdown
Contributor Author

IrrealV commented Apr 13, 2026

Gracias por el review. Ya resolví los puntos de Alan Buscaglia y dejé el trabajo dividido en commits separados para que sea más fácil de revisar:

  • fix(uninstall): resolve review regressions in full-remove and atomic writes
  • feat(uninstall): add profile sub-selection inside uninstall flow
  • feat(uninstall): add Engram project-vs-global cleanup scope

Además actualicé la PR para dejar claro el issue linkeado y el tipo de cambio. Si querés, puedo seguir con cualquier ajuste adicional que veas.

Copilot AI review requested due to automatic review settings April 13, 2026 13:51
Copy link
Copy Markdown
Contributor

@Alan-TheGentleman Alan-TheGentleman left a comment

Choose a reason for hiding this comment

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

Re-review — both items addressed

Item 1: WriteFileAtomic breaking behavior on 0o555 dirs

Fixed. The old unconditional os.Chmod(dir, 0o755) is replaced by ensureAtomicParentDir which:

  • Only relaxes permissions when the owner-write bit is actually missing (perm & 0o200 == 0)
  • Creates new directories with 0o700 (tighter default)
  • Rejects symlink parent directories via os.Lstat check
  • Test renamed to TestWriteFileAtomicReadOnlyDirRelaxesOwnerWritePermission — accurately describes the behavior

Bonus: symlink rejection for both target files (readComparableFile) and parent dirs, with tests. Good hardening.

Item 2: FullRemove + Homebrew dangling symlink

Fixed. isHomebrewManagedBinary detects Cellar, /opt/homebrew/, /usr/local/Homebrew/, and linuxbrew paths. When matched, os.Remove is skipped and a manual action is surfaced: "Run 'brew uninstall gentle-ai' to remove the executable cleanly." Two tests cover both the brew and non-brew paths.

Security audit (quick pass)

  • WriteFileAtomic: tmp.Sync() + dir Sync() added — crash-safe atomicity. Symlink checks on file and parent. Max file size guard on reads. All good.
  • readManagedFile in cleaners: same Lstat + size guard pattern. Consistent.
  • removeTree: uses os.Stat (follows symlinks) before os.RemoveAll. If a managed path were ever a symlink, this would follow through and delete the real target. Non-blocking since paths are constructed from constants, but switching to os.Lstat would be more defensive. Consider for a follow-up.
  • osRemoveFn / osExecutableFn testability hooks: clean pattern, no production risk.

Verdict

LGTM — both review items resolved, security posture improved. Ship it.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 47 to 49
opts = append(opts, "Manage backups")
opts = append(opts, "Managed uninstall")
opts = append(opts, "Quit")
Comment thread internal/tui/model.go
Comment on lines +1944 to +1959
profileSelectionUsed := m.UninstallProfileSelection || len(profileNamesToRemove) > 0
mode := m.UninstallMode
return func() tea.Msg {
if uninstallFn == nil && uninstallWithProfilesFn == nil {
return UninstallDoneMsg{Err: fmt.Errorf("uninstall function not configured")}
}

var (
result componentuninstall.Result
err error
)
if uninstallWithProfilesFn != nil && profileSelectionUsed {
result, err = uninstallWithProfilesFn(agentIDs, componentIDs, profileNamesToRemove, engramScope)
} else {
result, err = uninstallFn(agentIDs, componentIDs)
}
Comment thread internal/tui/model.go
}
return m
}

Comment on lines +340 to +348
if (mode == model.UninstallModeFull || mode == model.UninstallModeFullRemove) || hasWorkspaceAssets {
b.WriteString(styles.WarningStyle.Render("⚠ Workspace Assets Warning:"))
b.WriteString("\n")
b.WriteString(styles.SubtextStyle.Render(" Removing SDD or Skills will delete workspace-scoped files like:"))
b.WriteString("\n")
b.WriteString(styles.SubtextStyle.Render(" • .windsurf/workflows/ (SDD workflows)"))
b.WriteString("\n")
b.WriteString(styles.SubtextStyle.Render(" • .engram/ (persistent memory context)"))
b.WriteString("\n")
@Alan-TheGentleman Alan-TheGentleman added the type:feature New feature label Apr 13, 2026
IrrealV and others added 14 commits April 13, 2026 16:07
- Add ScreenUninstallMode as entry point for uninstall flow
- Three modes: Partial (granular selection), Full (all agents/components), Full & Remove (includes binary deletion)
- Workspace asset warnings for .engram/, workflows/, and skills/
- Full & Remove mode uses os.Remove() to delete gentle-ai binary after successful uninstall
- Updated tests to reflect new navigation flow through mode selector
- Closes part of Gentleman-Programming#102
- Extend uninstall result with ManualActions
- Report directories left non-empty after managed cleanup
- Show manual cleanup section in CLI and TUI uninstall results
- Add tests for service, CLI report, and TUI result rendering
Adds a 4th uninstall mode that removes all managed configuration and
immediately re-syncs all managed assets from scratch. This provides
a factory-reset workflow to fix broken configurations without manually
re-running install/sync commands.

The sync runs after uninstall completes. Sync errors are non-fatal and
displayed separately in the result screen so the user always sees the
uninstall status.
Ensure managed uninstall is reachable from the Welcome menu and correctly wired to the backend callback, and include sdd-onboard in SDD cleanup lists to avoid leftover managed artifacts.
…writes

Restore WriteFileAtomic permission-relaxation compatibility, prevent Homebrew full-remove from deleting managed binaries, and tighten uninstall cleanup safety checks so reviewer-reported edge cases are handled explicitly.
Add a conditional profile-selection step so users can choose which OpenCode SDD profiles are removed, while preserving the old flow when no profiles apply.
…g#265 state.Write API change

- Adapt uninstall service to use state.InstallState instead of []string
- Update app.go dispatch to match RunUninstall return signature
- Replace incompatible uninstall tests with PR-compatible version
@Alan-TheGentleman Alan-TheGentleman merged commit 4d03aca into Gentleman-Programming:main Apr 13, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(tui): add partial and complete uninstall flow

3 participants