From 6392436820def652ff1d3eccf2b19594952a13ca Mon Sep 17 00:00:00 2001 From: Anas Lakhani Date: Sun, 25 Oct 2026 08:42:50 +0500 Subject: [PATCH 1/2] =?UTF-8?q?Add=20Install=20Command=20Line=20Tool?= =?UTF-8?q?=E2=80=A6=20to=20the=20app=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app from the zip had no removemacai command, unlike the installer and Homebrew. The new item asks first, then links /usr/local/bin/removemacai to the app's binary after one password prompt. A link runs as the command line, not the app, so removemacai with no arguments still shows the overview. It never replaces a file there that isn't a link. Fixes #60 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + Localization/ru.lproj/Localizable.strings | 13 ++++++ README.md | 2 +- Sources/removemacai/App/RemoveMacAIApp.swift | 42 ++++++++++++++++++++ Sources/removemacai/CommandLineTool.swift | 17 ++++++++ Sources/removemacai/SelfTest.swift | 8 ++++ 6 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 Sources/removemacai/CommandLineTool.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f00db8c..fd0c455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- The app has an Install Command Line Tool… item in the RemoveMacAI menu. It asks first, then links `/usr/local/bin/removemacai` to the app, so the app from the zip gets the `removemacai` command too. It never replaces a file there that isn't a link (#60). - Block Music launches is now called Stop Music from opening, even by hand, and its warning says Music won't open at all until you undo it. The old name read as if only AirPods were blocked (#67). ## 1.2.0-beta.1 diff --git a/Localization/ru.lproj/Localizable.strings b/Localization/ru.lproj/Localizable.strings index c4b0e49..e441c3f 100644 --- a/Localization/ru.lproj/Localizable.strings +++ b/Localization/ru.lproj/Localizable.strings @@ -3,6 +3,7 @@ "RemoveMacAI" = "RemoveMacAI"; "About RemoveMacAI" = "О программе RemoveMacAI"; +"Install Command Line Tool…" = "Установить инструмент командной строки…"; "Reload" = "Обновить"; "How RemoveMacAI Works" = "Как работает RemoveMacAI"; "RemoveMacAI on GitHub" = "RemoveMacAI на GitHub"; @@ -411,3 +412,15 @@ "RemoveMacAI needs your password to change background items for all users." = "RemoveMacAI требуется ваш пароль, чтобы изменить фоновые объекты для всех пользователей."; "RemoveMacAI needs your password to move items only an administrator can change." = "RemoveMacAI требуется ваш пароль, чтобы переместить объекты, которые может изменять только администратор."; "Turn off everything except 1 kept feature" = "Выключить всё, кроме 1 оставленной функции"; + +"The removemacai command is already installed." = "Команда removemacai уже установлена."; +"Open Terminal and run removemacai status." = "Откройте Терминал и выполните removemacai status."; +"Something else is already at %@." = "По пути %@ уже что-то есть."; +"RemoveMacAI left it as it is. Remove it first, then try again." = "RemoveMacAI ничего не изменил. Сначала удалите это, затем попробуйте снова."; +"Install the removemacai command?" = "Установить команду removemacai?"; +"This links %@ to this app, so you can run removemacai in Terminal. If you move or delete the app, the command stops working. macOS asks for your password." = "По пути %@ будет создана ссылка на это приложение, чтобы removemacai можно было запускать в Терминале. Если переместить или удалить приложение, команда перестанет работать. macOS запросит пароль."; +"Install" = "Установить"; +"RemoveMacAI needs your password to install the removemacai command." = "RemoveMacAI требуется ваш пароль, чтобы установить команду removemacai."; +"The removemacai command is installed." = "Команда removemacai установлена."; +"Open a new Terminal window and run removemacai status." = "Откройте новое окно Терминала и выполните removemacai status."; +"The removemacai command wasn't installed." = "Команда removemacai не установлена."; diff --git a/README.md b/README.md index fac0049..4ede9df 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ This runs the command-line version from a temporary folder and installs nothing. brew install omlahore/tap/removemacai ``` -The app and the command line are the same program and share the same undo history. +The app and the command line are the same program and share the same undo history. If you installed the app from the zip, choose RemoveMacAI > Install Command Line Tool… to get the `removemacai` command too. ## What it does diff --git a/Sources/removemacai/App/RemoveMacAIApp.swift b/Sources/removemacai/App/RemoveMacAIApp.swift index 9722bf6..c96f956 100644 --- a/Sources/removemacai/App/RemoveMacAIApp.swift +++ b/Sources/removemacai/App/RemoveMacAIApp.swift @@ -17,6 +17,9 @@ struct RemoveMacAIApp: App { CommandGroup(replacing: .appInfo) { Button("About RemoveMacAI") { AppDelegate.showAbout() } } + CommandGroup(after: .appSettings) { + Button("Install Command Line Tool…") { AppDelegate.installCommandLineTool() } + } CommandGroup(after: .toolbar) { Button("Reload") { model.reload(resetChoices: true) }.keyboardShortcut("r") } @@ -54,6 +57,45 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + /// Links the app's binary into /usr/local/bin, so the app from the zip gets + /// the removemacai command the installer and Homebrew give. Asks first, and + /// never replaces a file there that isn't a link. + static func installCommandLineTool() { + guard let binary = Bundle.main.executablePath else { return } + let path = CommandLineTool.path + let fm = FileManager.default + if let existing = try? fm.destinationOfSymbolicLink(atPath: path) { + if existing == binary { + return message(String(localized: "The removemacai command is already installed."), + String(localized: "Open Terminal and run removemacai status.")) + } + } else if fm.fileExists(atPath: path) { + return message(String(localized: "Something else is already at \(path)."), + String(localized: "RemoveMacAI left it as it is. Remove it first, then try again.")) + } + let confirm = NSAlert() + confirm.messageText = String(localized: "Install the removemacai command?") + confirm.informativeText = String(localized: "This links \(path) to this app, so you can run removemacai in Terminal. If you move or delete the app, the command stops working. macOS asks for your password.") + confirm.addButton(withTitle: String(localized: "Install")) + confirm.addButton(withTitle: String(localized: "Cancel")) + guard confirm.runModal() == .alertFirstButtonReturn else { return } + let result = CommandLineTool.link( + to: binary, prompt: String(localized: "RemoveMacAI needs your password to install the removemacai command.")) + if result.ok { + message(String(localized: "The removemacai command is installed."), + String(localized: "Open a new Terminal window and run removemacai status.")) + } else { + message(String(localized: "The removemacai command wasn't installed."), localizedMessage(Shell.adminError(result))) + } + } + + private static func message(_ title: String, _ text: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = text + alert.runModal() + } + static func showAbout() { let credits = NSMutableAttributedString( string: String(localized: "Debloats macOS and can undo every change.\n\nThe Apple Intelligence part builds on pared by 4evy, which first mapped Apple's asset service and model sets."), diff --git a/Sources/removemacai/CommandLineTool.swift b/Sources/removemacai/CommandLineTool.swift new file mode 100644 index 0000000..cb38f1d --- /dev/null +++ b/Sources/removemacai/CommandLineTool.swift @@ -0,0 +1,17 @@ +import Foundation + +/// The removemacai command for the app from the zip, which the installer and +/// Homebrew put on the PATH but the app doesn't. +enum CommandLineTool { + static let path = "/usr/local/bin/removemacai" + + /// Links path to the binary after one password prompt. + static func link(to binary: String, prompt: String) -> Shell.Result { + Shell.admin(commands(linking: binary, at: path), prompt: prompt) + } + + static func commands(linking binary: String, at path: String) -> [String] { + let folder = (path as NSString).deletingLastPathComponent + return ["mkdir -p \(Shell.quote(folder))", "ln -sfn \(Shell.quote(binary)) \(Shell.quote(path))"] + } +} diff --git a/Sources/removemacai/SelfTest.swift b/Sources/removemacai/SelfTest.swift index 3d3073d..63f4729 100644 --- a/Sources/removemacai/SelfTest.swift +++ b/Sources/removemacai/SelfTest.swift @@ -285,6 +285,14 @@ func selfTest() -> Bool { check(!ranAll.ok && ranAll.output.contains("second"), "one failed administrator step doesn't skip the rest") let exited = Shell.run("/bin/sh", ["-c", Shell.adminScript(["exit 3", "echo after-exit"])]) check(!exited.ok && exited.output.contains("after-exit"), "a step that exits doesn't stop the rest") + let toolFolder = FileManager.default.temporaryDirectory.appendingPathComponent("removemacai-cli-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: toolFolder) } + let toolLink = toolFolder.appendingPathComponent("bin/removemacai").path + let binary = "/Applications/Remove Mac AI's.app/Contents/MacOS/RemoveMacAI" + let linked = Shell.run("/bin/sh", ["-c", Shell.adminScript(CommandLineTool.commands(linking: binary, at: toolLink))]) + let relinked = Shell.run("/bin/sh", ["-c", Shell.adminScript(CommandLineTool.commands(linking: binary, at: toolLink))]) + check(linked.ok && relinked.ok && (try? FileManager.default.destinationOfSymbolicLink(atPath: toolLink)) == binary, + "the command line tool links to the app, even from a path with spaces and quotes") check(Shell.run("/bin/sh", ["-c", Shell.adminScript(["true", "true"])]).ok, "administrator steps succeed together") check(Term.pad("abc", 5) == "abc " && Term.pad("abcdef", 5) == "abcdef ", "columns never run together") check(Term.colorEnabled(env: [:], tty: true) && !Term.colorEnabled(env: [:], tty: false), From 59508efa88f54695753d78ea45528701bb440388 Mon Sep 17 00:00:00 2001 From: Anas Lakhani Date: Sun, 25 Oct 2026 09:08:02 +0500 Subject: [PATCH 2/2] Check again as root before linking the command line tool ln -sfn would delete a regular file that appeared at /usr/local/bin/removemacai after the app looked. The privileged step now refuses anything there that isn't a link, and says so. Co-Authored-By: Claude Opus 5.5 --- Sources/removemacai/CommandLineTool.swift | 8 +++++++- Sources/removemacai/SelfTest.swift | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/removemacai/CommandLineTool.swift b/Sources/removemacai/CommandLineTool.swift index cb38f1d..ca3f6a5 100644 --- a/Sources/removemacai/CommandLineTool.swift +++ b/Sources/removemacai/CommandLineTool.swift @@ -10,8 +10,14 @@ enum CommandLineTool { Shell.admin(commands(linking: binary, at: path), prompt: prompt) } + /// Checks again as root, since ln -f would delete a file that appeared after the app looked. static func commands(linking binary: String, at path: String) -> [String] { let folder = (path as NSString).deletingLastPathComponent - return ["mkdir -p \(Shell.quote(folder))", "ln -sfn \(Shell.quote(binary)) \(Shell.quote(path))"] + let p = Shell.quote(path) + return [ + "mkdir -p \(Shell.quote(folder))", + "if [ -e \(p) ] && [ ! -L \(p) ]; then echo \(Shell.quote("\(path) isn't a link, so it was left alone")) >&2; exit 1; fi; " + + "ln -sfn \(Shell.quote(binary)) \(p)", + ] } } diff --git a/Sources/removemacai/SelfTest.swift b/Sources/removemacai/SelfTest.swift index 63f4729..c62c7d8 100644 --- a/Sources/removemacai/SelfTest.swift +++ b/Sources/removemacai/SelfTest.swift @@ -293,6 +293,11 @@ func selfTest() -> Bool { let relinked = Shell.run("/bin/sh", ["-c", Shell.adminScript(CommandLineTool.commands(linking: binary, at: toolLink))]) check(linked.ok && relinked.ok && (try? FileManager.default.destinationOfSymbolicLink(atPath: toolLink)) == binary, "the command line tool links to the app, even from a path with spaces and quotes") + let toolFile = toolFolder.appendingPathComponent("bin/other").path + FileManager.default.createFile(atPath: toolFile, contents: Data("keep".utf8)) + let refused = Shell.run("/bin/sh", ["-c", Shell.adminScript(CommandLineTool.commands(linking: binary, at: toolFile))]) + check(!refused.ok && refused.output.contains("isn't a link") && (try? String(contentsOfFile: toolFile, encoding: .utf8)) == "keep", + "the command line tool never replaces a file that isn't a link") check(Shell.run("/bin/sh", ["-c", Shell.adminScript(["true", "true"])]).ok, "administrator steps succeed together") check(Term.pad("abc", 5) == "abc " && Term.pad("abcdef", 5) == "abcdef ", "columns never run together") check(Term.colorEnabled(env: [:], tty: true) && !Term.colorEnabled(env: [:], tty: false),