Skip to content

Design: tip install / tip uninstall and internal/shellhook

Date: 2026-04-14 Status: Approved


Overview

sap-devs init already offers to append sap-devs tip to the user's shell profile. This feature extracts that logic into a reusable internal/shellhook package and exposes it as sap-devs tip install / sap-devs tip uninstall so users who skipped the step during init can add or remove the hook at any time.


Goals

  • Allow users to add sap-devs tip to their shell startup outside of init.
  • Allow users to remove the hook cleanly.
  • Handle all common shell profiles across Linux, macOS, and Windows.
  • Make the hook logic reusable for future commands (e.g., doctor).

Non-Goals

  • Writing to profiles that do not already exist (no profile creation).
  • Supporting shells beyond zsh, bash, and PowerShell.
  • WSL profiles (treated as a separate Linux environment).

Package: internal/shellhook

Responsibility

Append or remove a single-line shell hook (plus an optional comment) across all shell profile files found on the current platform.

Profile Matrix

Profile paths are resolved using os.UserHomeDir() on all platforms.

PlatformProfiles checked (in order)
Linux~/.zshrc, ~/.bashrc, ~/.bash_profile, ~/.zprofile
macOS~/.zshrc, ~/.bashrc, ~/.bash_profile, ~/.zprofile
Windows~/Documents/PowerShell/Microsoft.PowerShell_profile.ps1, ~/.bashrc, ~/.bash_profile

~ denotes the path returned by os.UserHomeDir(). Only profiles that already exist are modified. If no profiles are found, Add returns an error; Remove returns with no results (not an error).

API

go
// Result describes what happened to a single profile file.
type Result struct {
    Path    string
    Updated bool // false = already present (Add) or line not found (Remove)
}

// Add appends comment and line to every existing profile that does not
// already contain line as a complete line. Returns one Result per
// candidate profile found on disk.
func Add(line, comment string) ([]Result, error)

// Remove strips every occurrence of line (full-line match) and any
// immediately preceding line equal to comment from all existing profiles.
// Returns one Result per candidate profile found on disk (Updated=true
// if the file was changed, false if the line was not present).
func Remove(line, comment string) ([]Result, error)

Behaviour Details

Add:

  1. Resolve candidate profile paths for the current platform using os.UserHomeDir().
  2. Skip paths that do not exist on disk.
  3. For each existing path: scan lines; if any line is exactly equal to line (full-line equality, trimming trailing newline), mark Updated: false and skip.
  4. Otherwise append \n<comment>\n<line>\n and mark Updated: true.
  5. If no existing profiles were found, return an error with manual steps. line is assumed to be a single printable line; format it with %q in the error string: fmt.Sprintf("no shell profile found; add %q to your shell profile manually", line).
  6. If any file write fails, collect the error. After processing all profiles, return all results accumulated so far plus a combined error using errors.Join.

Remove:

  1. Resolve candidate profile paths for the current platform using os.UserHomeDir().
  2. For each existing path, whether or not it contains the target: include it in results (Updated=false by default).
  3. Scan lines in a single pass; drop any line exactly equal to line and any immediately preceding line exactly equal to comment (i.e., only the comment that directly precedes the hook line is removed — a solitary comment line with no following line is left in place).
  4. If the file changed, rewrite it and mark Updated: true.
  5. If any file write fails, collect the error. After processing all profiles, return all results plus a combined error using errors.Join.

Orphaned comment lines: If a profile contains comment without an immediately following line (e.g., from a prior partial removal or double-append followed by a partial uninstall), that comment line is left in place. This is accepted behaviour; the implementation does not need to hunt for orphaned comments.

Duplicate detection and removal use the same strategy — full-line equality (after trimming the trailing newline). This ensures that a comment line such as # runs sap-devs tip does not trigger a false-positive match and that Add / Remove are symmetric.


CLI: sap-devs tip install / sap-devs tip uninstall

Both are subcommands registered under the existing tip command in cmd/tip.go.

tip install

Calls shellhook.Add("sap-devs tip", "# SAP developer tips").

Prints a summary line per result:

text
✓ Updated ~/.zshrc
✓ Updated ~/.bash_profile
  ~/.bashrc — already configured

If no profiles were found (error returned, results empty), prints:

text
No shell profile found. Add this line to your shell profile manually:
  sap-devs tip

tip uninstall

Calls shellhook.Remove("sap-devs tip", "# SAP developer tips").

Prints a summary line per result:

text
✓ Removed from ~/.zshrc
  ~/.bash_profile — not configured

If no results have Updated: true, also prints:

text
'sap-devs tip' was not found in any shell profile.

Changes to cmd/init.go

Remove the addShellHook() function and replace its call site with shellhook.Add("sap-devs tip", "# SAP developer tips").

Behaviour change: The existing addShellHook() stops at the first existing profile. After this change, init will write to all existing profiles, consistent with the behaviour of tip install. This is intentional — users with multiple shells (e.g., zsh and Git Bash on the same machine) get the tip in all of them.


Error Handling

  • File read/write errors are collected across all profiles.
  • After processing all profiles, any errors are combined with errors.Join and returned alongside the results accumulated so far.
  • Callers receive partial results and can report what succeeded before surfacing the error.

Testing

Unit tests in internal/shellhook/shellhook_test.go using a temporary directory substituted for home:

CaseAddRemove
Single profile exists, line absentappends, Updated=trueUpdated=false (not found)
Single profile exists, line presentskips, Updated=falseremoves, Updated=true
Line present as substring of another lineappends (no false positive), Updated=trueleaves file unchanged, Updated=false
Multiple profiles existupdates all absent onesremoves from all that contain line
No profiles existreturns error, empty resultsreturns empty results, no error
Comment+hook present; orphaned comment presentskips, Updated=falseremoves all comment+hook pairs, leaves orphaned comment, Updated=true
Multiple comment+hook blocks in one fileskips, Updated=falseremoves all pairs, Updated=true
Windows PowerShell profile pathappends, Updated=trueremoves, Updated=true

File Checklist

ActionPath
Createinternal/shellhook/shellhook.go
Createinternal/shellhook/shellhook_test.go
Modifycmd/tip.go — add install and uninstall subcommands
Modifycmd/init.go — replace addShellHook() with shellhook.Add(...)
Modifydocs/user/user-guide.md — document tip install / tip uninstall