diff --git a/.gitignore b/.gitignore
index b22bad8..c19eff6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
xcuserdata/
Clamshell.xcodeproj/
DerivedData/
+docs/plans/
diff --git a/App/ClamshellApp/ClamshellApp.swift b/App/ClamshellApp/ClamshellApp.swift
new file mode 100644
index 0000000..b6d58d1
--- /dev/null
+++ b/App/ClamshellApp/ClamshellApp.swift
@@ -0,0 +1,13 @@
+import SwiftUI
+
+@main
+struct ClamshellApp: App {
+ @NSApplicationDelegateAdaptor(ControlActionApplicationDelegate.self)
+ private var applicationDelegate
+
+ var body: some Scene {
+ Settings {
+ EmptyView()
+ }
+ }
+}
diff --git a/App/ClamshellApp/Control/ApplicationInstancePolicy.swift b/App/ClamshellApp/Control/ApplicationInstancePolicy.swift
new file mode 100644
index 0000000..42cfc2b
--- /dev/null
+++ b/App/ClamshellApp/Control/ApplicationInstancePolicy.swift
@@ -0,0 +1,62 @@
+import Foundation
+
+struct ApplicationInstancePolicy: Sendable {
+ struct Instance: Equatable, Sendable {
+ let processIdentifier: Int32
+ let bundleURL: URL?
+ let launchDate: Date?
+ }
+
+ enum Resolution: Equatable, Sendable {
+ case continueAndTerminate([Int32])
+ case terminateCurrent
+ }
+
+ private static let installedApplicationURL =
+ URL(fileURLWithPath: "/Applications/Clamshell.app").standardizedFileURL
+
+ func resolve(current: Instance, running: [Instance]) -> Resolution {
+ var instancesByProcessIdentifier: [Int32: Instance] = [:]
+ for instance in running {
+ instancesByProcessIdentifier[instance.processIdentifier] = instance
+ }
+ instancesByProcessIdentifier[current.processIdentifier] = current
+ let instances = Array(instancesByProcessIdentifier.values)
+
+ guard var selected = instances.first else {
+ return .continueAndTerminate([])
+ }
+ for candidate in instances.dropFirst() where isPreferred(candidate, to: selected) {
+ selected = candidate
+ }
+ guard selected.processIdentifier == current.processIdentifier else {
+ return .terminateCurrent
+ }
+
+ let otherProcessIdentifiers = instances
+ .lazy
+ .map(\.processIdentifier)
+ .filter { $0 != current.processIdentifier }
+ .sorted()
+ return .continueAndTerminate(otherProcessIdentifiers)
+ }
+
+ private func isPreferred(_ candidate: Instance, to selected: Instance) -> Bool {
+ let candidateIsInstalled = isInstalled(candidate)
+ let selectedIsInstalled = isInstalled(selected)
+ if candidateIsInstalled != selectedIsInstalled {
+ return candidateIsInstalled
+ }
+
+ let candidateLaunchDate = candidate.launchDate ?? .distantPast
+ let selectedLaunchDate = selected.launchDate ?? .distantPast
+ if candidateLaunchDate != selectedLaunchDate {
+ return candidateLaunchDate > selectedLaunchDate
+ }
+ return candidate.processIdentifier > selected.processIdentifier
+ }
+
+ private func isInstalled(_ instance: Instance) -> Bool {
+ instance.bundleURL?.standardizedFileURL == Self.installedApplicationURL
+ }
+}
diff --git a/App/ClamshellApp/Control/ApplicationLaunchPolicy.swift b/App/ClamshellApp/Control/ApplicationLaunchPolicy.swift
new file mode 100644
index 0000000..e06e904
--- /dev/null
+++ b/App/ClamshellApp/Control/ApplicationLaunchPolicy.swift
@@ -0,0 +1,8 @@
+import AppKit
+
+@MainActor
+enum ApplicationLaunchPolicy {
+ static func shouldShowSetup(userInfo: [AnyHashable: Any]?) -> Bool {
+ userInfo?[NSApplication.launchIsDefaultUserInfoKey] as? Bool ?? true
+ }
+}
diff --git a/App/ClamshellApp/Control/ApplicationRuntimePolicy.swift b/App/ClamshellApp/Control/ApplicationRuntimePolicy.swift
new file mode 100644
index 0000000..28bb9d0
--- /dev/null
+++ b/App/ClamshellApp/Control/ApplicationRuntimePolicy.swift
@@ -0,0 +1,5 @@
+enum ApplicationRuntimePolicy {
+ static func shouldStartApplication(environment: [String: String]) -> Bool {
+ environment["XCTestConfigurationFilePath"] == nil
+ }
+}
diff --git a/App/ClamshellApp/Control/ControlActionApplicationDelegate.swift b/App/ClamshellApp/Control/ControlActionApplicationDelegate.swift
new file mode 100644
index 0000000..73240e8
--- /dev/null
+++ b/App/ClamshellApp/Control/ControlActionApplicationDelegate.swift
@@ -0,0 +1,77 @@
+import AppKit
+import Foundation
+
+@MainActor
+final class ControlActionApplicationDelegate: NSObject, NSApplicationDelegate {
+ private let handler = ControlActionHandler.live
+ private let instanceCoordinator = RunningApplicationInstanceCoordinator()
+ private var ownsApplicationInstance = false
+ private lazy var setupWindowController = SetupWindowController(
+ model: SetupComposition.makeModel()
+ )
+
+ func applicationWillFinishLaunching(_ notification: Notification) {
+ guard
+ ApplicationRuntimePolicy.shouldStartApplication(
+ environment: ProcessInfo.processInfo.environment
+ ),
+ instanceCoordinator.claimCurrentInstance()
+ else {
+ return
+ }
+ ownsApplicationInstance = true
+
+ NSAppleEventManager.shared().setEventHandler(
+ self,
+ andSelector: #selector(handleGetURL(_:withReplyEvent:)),
+ forEventClass: AEEventClass(kInternetEventClass),
+ andEventID: AEEventID(kAEGetURL)
+ )
+ }
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ if ownsApplicationInstance,
+ ApplicationLaunchPolicy.shouldShowSetup(userInfo: notification.userInfo)
+ {
+ setupWindowController.show()
+ }
+ }
+
+ func applicationShouldHandleReopen(
+ _ sender: NSApplication,
+ hasVisibleWindows flag: Bool
+ ) -> Bool {
+ if ownsApplicationInstance, !flag {
+ setupWindowController.show()
+ }
+ return false
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ guard ownsApplicationInstance else {
+ return
+ }
+ NSAppleEventManager.shared().removeEventHandler(
+ forEventClass: AEEventClass(kInternetEventClass),
+ andEventID: AEEventID(kAEGetURL)
+ )
+ }
+
+ @objc
+ private func handleGetURL(
+ _ event: NSAppleEventDescriptor,
+ withReplyEvent replyEvent: NSAppleEventDescriptor
+ ) {
+ guard
+ let value = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue,
+ let url = URL(string: value)
+ else {
+ return
+ }
+
+ let handler = handler
+ Task.detached {
+ _ = handler.handle(url)
+ }
+ }
+}
diff --git a/App/ClamshellApp/Control/ControlActionHandler.swift b/App/ClamshellApp/Control/ControlActionHandler.swift
new file mode 100644
index 0000000..9f38c79
--- /dev/null
+++ b/App/ClamshellApp/Control/ControlActionHandler.swift
@@ -0,0 +1,51 @@
+import ClamshellControlModel
+import ClamshellControlProtocol
+import Foundation
+import OSLog
+import WidgetKit
+
+struct ControlActionHandler: Sendable {
+ enum Outcome: Equatable, Sendable {
+ case applied
+ case failed
+ case ignored
+ }
+
+ static let live = Self(
+ model: .live,
+ reloadControls: { ControlCenter.shared.reloadAllControls() }
+ )
+
+ private static let logger = Logger(
+ subsystem: "uk.co.lmliam.clamshell",
+ category: "ControlAction"
+ )
+
+ private let model: ControlModel
+ private let reloadControls: @Sendable () -> Void
+
+ init(
+ model: ControlModel,
+ reloadControls: @escaping @Sendable () -> Void
+ ) {
+ self.model = model
+ self.reloadControls = reloadControls
+ }
+
+ func handle(_ url: URL) -> Outcome {
+ guard let request = ControlActionRequest(url: url) else {
+ Self.logger.error("Ignored an invalid control action URL")
+ return .ignored
+ }
+
+ defer { reloadControls() }
+
+ do {
+ try model.setValue(request.isEnabled)
+ return .applied
+ } catch {
+ Self.logger.error("Could not apply the control action: \(error.localizedDescription)")
+ return .failed
+ }
+ }
+}
diff --git a/App/ClamshellApp/Control/RunningApplicationInstanceCoordinator.swift b/App/ClamshellApp/Control/RunningApplicationInstanceCoordinator.swift
new file mode 100644
index 0000000..012eaf4
--- /dev/null
+++ b/App/ClamshellApp/Control/RunningApplicationInstanceCoordinator.swift
@@ -0,0 +1,49 @@
+import AppKit
+
+@MainActor
+struct RunningApplicationInstanceCoordinator {
+ private let policy = ApplicationInstancePolicy()
+
+ func claimCurrentInstance() -> Bool {
+ let current = NSRunningApplication.current
+ guard let bundleIdentifier = current.bundleIdentifier else {
+ return true
+ }
+
+ let runningApplications = NSRunningApplication.runningApplications(
+ withBundleIdentifier: bundleIdentifier
+ )
+ let resolution = policy.resolve(
+ current: instance(for: current),
+ running: runningApplications.map(instance)
+ )
+
+ switch resolution {
+ case .terminateCurrent:
+ terminate(current)
+ return false
+ case let .continueAndTerminate(processIdentifiers):
+ let processIdentifierSet = Set(processIdentifiers)
+ for application in runningApplications
+ where processIdentifierSet.contains(application.processIdentifier) {
+ terminate(application)
+ }
+ return true
+ }
+ }
+
+ private func instance(for application: NSRunningApplication) -> ApplicationInstancePolicy.Instance
+ {
+ ApplicationInstancePolicy.Instance(
+ processIdentifier: application.processIdentifier,
+ bundleURL: application.bundleURL,
+ launchDate: application.launchDate
+ )
+ }
+
+ private func terminate(_ application: NSRunningApplication) {
+ if !application.terminate() {
+ application.forceTerminate()
+ }
+ }
+}
diff --git a/App/ClamshellApp/Info.plist b/App/ClamshellApp/Info.plist
new file mode 100644
index 0000000..ddc2e81
--- /dev/null
+++ b/App/ClamshellApp/Info.plist
@@ -0,0 +1,41 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Clamshell
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ Viewer
+ CFBundleURLName
+ uk.co.lmliam.clamshell.control-action
+ CFBundleURLSchemes
+
+ clamshellctl
+
+
+
+ CFBundleVersion
+ 1
+ LSApplicationCategoryType
+ public.app-category.utilities
+ LSUIElement
+
+
+
diff --git a/App/ClamshellApp/Setup/Diagnostics/CompanionDiagnosing.swift b/App/ClamshellApp/Setup/Diagnostics/CompanionDiagnosing.swift
new file mode 100644
index 0000000..21e3532
--- /dev/null
+++ b/App/ClamshellApp/Setup/Diagnostics/CompanionDiagnosing.swift
@@ -0,0 +1,3 @@
+protocol CompanionDiagnosing: Sendable {
+ func currentState() throws -> SetupState
+}
diff --git a/App/ClamshellApp/Setup/Diagnostics/CompanionDiagnostics.swift b/App/ClamshellApp/Setup/Diagnostics/CompanionDiagnostics.swift
new file mode 100644
index 0000000..8c28ffd
--- /dev/null
+++ b/App/ClamshellApp/Setup/Diagnostics/CompanionDiagnostics.swift
@@ -0,0 +1,45 @@
+import ClamshellCore
+import Foundation
+
+struct CompanionDiagnostics: CompanionDiagnosing {
+ private let bundleURL: URL
+ private let payloadInspector: any CompanionPayloadInspecting
+ private let installation: any InstallationStatusReading
+
+ init(
+ bundleURL: URL,
+ payloadInspector: any CompanionPayloadInspecting = FoundationCompanionPayloadInspector(),
+ installation: any InstallationStatusReading
+ ) {
+ self.bundleURL = bundleURL
+ self.payloadInspector = payloadInspector
+ self.installation = installation
+ }
+
+ func currentState() throws -> SetupState {
+ let commandAvailable = payloadInspector.isRegularFile(at: commandURL)
+ guard commandAvailable else {
+ return .missingBundlePayload(commandAvailable: false)
+ }
+ guard payloadInspector.isRegularFile(at: helperPayloadURL) else {
+ return .missingBundlePayload(commandAvailable: true)
+ }
+
+ return switch try installation.currentStatus() {
+ case .notInstalled:
+ .needsSetup
+ case .ready:
+ .ready
+ case .invalid:
+ .invalidHelper
+ }
+ }
+
+ private var commandURL: URL {
+ bundleURL.appendingPathComponent("Contents/MacOS/clamshellctl")
+ }
+
+ private var helperPayloadURL: URL {
+ bundleURL.appendingPathComponent("Contents/Resources/clamshellctl-helper")
+ }
+}
diff --git a/App/ClamshellApp/Setup/Diagnostics/CompanionPayloadInspecting.swift b/App/ClamshellApp/Setup/Diagnostics/CompanionPayloadInspecting.swift
new file mode 100644
index 0000000..d6b5789
--- /dev/null
+++ b/App/ClamshellApp/Setup/Diagnostics/CompanionPayloadInspecting.swift
@@ -0,0 +1,5 @@
+import Foundation
+
+protocol CompanionPayloadInspecting: Sendable {
+ func isRegularFile(at url: URL) -> Bool
+}
diff --git a/App/ClamshellApp/Setup/Diagnostics/FoundationCompanionPayloadInspector.swift b/App/ClamshellApp/Setup/Diagnostics/FoundationCompanionPayloadInspector.swift
new file mode 100644
index 0000000..7e77d64
--- /dev/null
+++ b/App/ClamshellApp/Setup/Diagnostics/FoundationCompanionPayloadInspector.swift
@@ -0,0 +1,9 @@
+import Foundation
+
+struct FoundationCompanionPayloadInspector: CompanionPayloadInspecting {
+ func isRegularFile(at url: URL) -> Bool {
+ var isDirectory = ObjCBool(false)
+ return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
+ && !isDirectory.boolValue
+ }
+}
diff --git a/App/ClamshellApp/Setup/Diagnostics/InstallationStatusReading.swift b/App/ClamshellApp/Setup/Diagnostics/InstallationStatusReading.swift
new file mode 100644
index 0000000..93ad0a0
--- /dev/null
+++ b/App/ClamshellApp/Setup/Diagnostics/InstallationStatusReading.swift
@@ -0,0 +1,11 @@
+import ClamshellCore
+
+protocol InstallationStatusReading: Sendable {
+ func currentStatus() throws -> InstallationStatus
+}
+
+extension PrivilegedInstallation: InstallationStatusReading {
+ func currentStatus() throws -> InstallationStatus {
+ try status()
+ }
+}
diff --git a/App/ClamshellApp/Setup/Privilege/AdministratorAuthorizationError.swift b/App/ClamshellApp/Setup/Privilege/AdministratorAuthorizationError.swift
new file mode 100644
index 0000000..d1ebc77
--- /dev/null
+++ b/App/ClamshellApp/Setup/Privilege/AdministratorAuthorizationError.swift
@@ -0,0 +1,12 @@
+import Foundation
+
+enum AdministratorAuthorizationError: Error, Equatable, LocalizedError {
+ case invalidApplicationLocation
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidApplicationLocation:
+ "Move Clamshell to the Applications folder before you continue."
+ }
+ }
+}
diff --git a/App/ClamshellApp/Setup/Privilege/AdministratorAuthorizing.swift b/App/ClamshellApp/Setup/Privilege/AdministratorAuthorizing.swift
new file mode 100644
index 0000000..6a5c250
--- /dev/null
+++ b/App/ClamshellApp/Setup/Privilege/AdministratorAuthorizing.swift
@@ -0,0 +1,3 @@
+protocol AdministratorAuthorizing: Sendable {
+ func run(_ request: AdministratorRequest) async throws
+}
diff --git a/App/ClamshellApp/Setup/Privilege/AdministratorRequest.swift b/App/ClamshellApp/Setup/Privilege/AdministratorRequest.swift
new file mode 100644
index 0000000..ecf4e5f
--- /dev/null
+++ b/App/ClamshellApp/Setup/Privilege/AdministratorRequest.swift
@@ -0,0 +1,4 @@
+enum AdministratorRequest: Sendable, Equatable {
+ case install(exposeCommand: Bool)
+ case uninstall(removeCommand: Bool)
+}
diff --git a/App/ClamshellApp/Setup/Privilege/AppleScriptAdministratorAuthorizer.swift b/App/ClamshellApp/Setup/Privilege/AppleScriptAdministratorAuthorizer.swift
new file mode 100644
index 0000000..ef05bc1
--- /dev/null
+++ b/App/ClamshellApp/Setup/Privilege/AppleScriptAdministratorAuthorizer.swift
@@ -0,0 +1,84 @@
+import ClamshellCore
+import Foundation
+
+struct AppleScriptAdministratorAuthorizer: AdministratorAuthorizing {
+ private static let applicationPath = "/Applications/Clamshell.app"
+ private static let executablePath =
+ "/Applications/Clamshell.app/Contents/MacOS/clamshellctl"
+ private static let osascriptPath = "/usr/bin/osascript"
+
+ typealias ResolvedPath = @Sendable (URL) -> String
+
+ private let bundleURL: URL
+ private let accountName: String
+ private let runner: any ProcessRunning
+ private let resolvedPath: ResolvedPath
+
+ init(
+ bundleURL: URL,
+ accountName: String = NSUserName(),
+ runner: any ProcessRunning = FoundationProcessRunner(),
+ resolvedPath: @escaping ResolvedPath = {
+ $0.resolvingSymlinksInPath().standardizedFileURL.path
+ }
+ ) {
+ self.bundleURL = bundleURL
+ self.accountName = accountName
+ self.runner = runner
+ self.resolvedPath = resolvedPath
+ }
+
+ func run(_ request: AdministratorRequest) async throws {
+ let applicationURL = bundleURL.standardizedFileURL
+ let executableURL = applicationURL.appendingPathComponent("Contents/MacOS/clamshellctl")
+ guard
+ applicationURL.path == Self.applicationPath,
+ resolvedPath(applicationURL) == Self.applicationPath,
+ resolvedPath(executableURL) == Self.executablePath
+ else {
+ throw AdministratorAuthorizationError.invalidApplicationLocation
+ }
+
+ let runner = runner
+ let script = try administratorScript(for: request)
+ let result = try await Task.detached {
+ try runner.run(Self.osascriptPath, arguments: ["-e", script])
+ }.value
+
+ guard result.terminationStatus == 0 else {
+ throw ClamshellError.processFailed(
+ executable: Self.osascriptPath,
+ terminationStatus: result.terminationStatus,
+ standardError: result.standardError
+ )
+ }
+ }
+
+ private func administratorScript(for request: AdministratorRequest) throws -> String {
+ let account = try SudoersPolicy(username: accountName).username
+ let command =
+ (["/usr/bin/env", "SUDO_USER=\(account)", Self.executablePath] + arguments(for: request))
+ .map(shellQuote)
+ .joined(separator: " ")
+ return "do shell script \"\(appleScriptEscape(command))\" with administrator privileges"
+ }
+
+ private func arguments(for request: AdministratorRequest) -> [String] {
+ switch request {
+ case let .install(exposeCommand):
+ ["setup"] + (exposeCommand ? ["--expose-command"] : []) + ["--quiet"]
+ case let .uninstall(removeCommand):
+ ["uninstall"] + (removeCommand ? ["--remove-command"] : []) + ["--quiet"]
+ }
+ }
+
+ private func shellQuote(_ value: String) -> String {
+ "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'"
+ }
+
+ private func appleScriptEscape(_ value: String) -> String {
+ value
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ }
+}
diff --git a/App/ClamshellApp/Setup/SetupComposition.swift b/App/ClamshellApp/Setup/SetupComposition.swift
new file mode 100644
index 0000000..600a0f0
--- /dev/null
+++ b/App/ClamshellApp/Setup/SetupComposition.swift
@@ -0,0 +1,21 @@
+import ClamshellCore
+import Foundation
+
+@MainActor
+enum SetupComposition {
+ static func makeModel(bundle: Bundle = .main) -> SetupModel {
+ let bundleURL = bundle.bundleURL
+ let executablePath =
+ bundleURL
+ .appendingPathComponent("Contents/MacOS/clamshellctl")
+ .path
+
+ return SetupModel(
+ diagnostics: CompanionDiagnostics(
+ bundleURL: bundleURL,
+ installation: PrivilegedInstallation(executablePath: executablePath)
+ ),
+ authorizer: AppleScriptAdministratorAuthorizer(bundleURL: bundleURL)
+ )
+ }
+}
diff --git a/App/ClamshellApp/Setup/SetupModel.swift b/App/ClamshellApp/Setup/SetupModel.swift
new file mode 100644
index 0000000..165231a
--- /dev/null
+++ b/App/ClamshellApp/Setup/SetupModel.swift
@@ -0,0 +1,60 @@
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+final class SetupModel {
+ var exposeCommand = false
+ private(set) var errorMessage: String?
+ private(set) var isWorking = false
+ private(set) var state: SetupState = .needsSetup
+
+ private let diagnostics: any CompanionDiagnosing
+ private let authorizer: any AdministratorAuthorizing
+
+ init(
+ diagnostics: any CompanionDiagnosing,
+ authorizer: any AdministratorAuthorizing
+ ) {
+ self.diagnostics = diagnostics
+ self.authorizer = authorizer
+ }
+
+ func refresh() {
+ do {
+ state = try diagnostics.currentState()
+ errorMessage = nil
+ } catch {
+ state = .invalidHelper
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func setUp() async {
+ await perform(.install(exposeCommand: exposeCommand))
+ }
+
+ func installTerminalCommand() async {
+ await perform(.install(exposeCommand: true))
+ }
+
+ func removePrivilegedSetup() async {
+ await perform(.uninstall(removeCommand: true))
+ }
+
+ private func perform(_ request: AdministratorRequest) async {
+ guard !isWorking else {
+ return
+ }
+
+ isWorking = true
+ defer { isWorking = false }
+
+ do {
+ try await authorizer.run(request)
+ refresh()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/App/ClamshellApp/Setup/SetupState.swift b/App/ClamshellApp/Setup/SetupState.swift
new file mode 100644
index 0000000..87d09a1
--- /dev/null
+++ b/App/ClamshellApp/Setup/SetupState.swift
@@ -0,0 +1,24 @@
+enum SetupState: Sendable, Equatable {
+ case needsSetup
+ case ready
+ case invalidHelper
+ case missingBundlePayload(commandAvailable: Bool)
+
+ var allowsSetup: Bool {
+ switch self {
+ case .needsSetup, .invalidHelper:
+ true
+ case .ready, .missingBundlePayload:
+ false
+ }
+ }
+
+ var allowsPrivilegedRemoval: Bool {
+ switch self {
+ case .ready, .invalidHelper, .missingBundlePayload(commandAvailable: true):
+ true
+ case .needsSetup, .missingBundlePayload(commandAvailable: false):
+ false
+ }
+ }
+}
diff --git a/App/ClamshellApp/Setup/SetupView.swift b/App/ClamshellApp/Setup/SetupView.swift
new file mode 100644
index 0000000..5211b92
--- /dev/null
+++ b/App/ClamshellApp/Setup/SetupView.swift
@@ -0,0 +1,161 @@
+import SwiftUI
+
+struct SetupView: View {
+ @Bindable var model: SetupModel
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 24) {
+ header
+ status
+ setup
+ controlCentreInstructions
+ actions
+ }
+ .frame(width: 480)
+ .padding(28)
+ .task {
+ model.refresh()
+ }
+ }
+
+ private var header: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Clamshell")
+ .font(.largeTitle.bold())
+ Text("Control battery clamshell mode from Control Centre or Terminal.")
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var status: some View {
+ HStack(spacing: 14) {
+ Image(systemName: model.state.symbolName)
+ .font(.title2)
+ .foregroundStyle(model.state.tint)
+ .frame(width: 28)
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text(model.state.title)
+ .font(.headline)
+ Text(model.state.detail)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+ }
+ .padding(16)
+ .background(.quaternary, in: .rect(cornerRadius: 12))
+ }
+
+ @ViewBuilder
+ private var setup: some View {
+ if model.state.allowsSetup {
+ VStack(alignment: .leading, spacing: 12) {
+ Toggle("Install the Terminal command", isOn: $model.exposeCommand)
+ .disabled(model.isWorking)
+ Text("This optional setting adds clamshellctl to /usr/local/bin.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Button("Set Up") {
+ Task { await model.setUp() }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(model.isWorking)
+ }
+ }
+ }
+
+ private var controlCentreInstructions: some View {
+ GroupBox("Add the control") {
+ Text("Open Control Centre and select Edit Controls. Add Battery Clamshell Mode.")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top, 4)
+ }
+ }
+
+ private var actions: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ if let errorMessage = model.errorMessage {
+ Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ }
+
+ HStack {
+ Button("Refresh") {
+ model.refresh()
+ }
+ .disabled(model.isWorking)
+
+ if model.state == .ready {
+ Button("Install Terminal Command") {
+ Task { await model.installTerminalCommand() }
+ }
+ .disabled(model.isWorking)
+ }
+
+ Spacer()
+
+ if model.state.allowsPrivilegedRemoval {
+ Button("Remove Privileged Setup", role: .destructive) {
+ Task { await model.removePrivilegedSetup() }
+ }
+ .disabled(model.isWorking)
+ }
+ }
+ }
+ }
+}
+
+private extension SetupState {
+ var title: String {
+ switch self {
+ case .needsSetup:
+ "Setup required"
+ case .ready:
+ "Ready"
+ case .invalidHelper:
+ "Setup needs repair"
+ case .missingBundlePayload:
+ "App files are incomplete"
+ }
+ }
+
+ var detail: String {
+ switch self {
+ case .needsSetup:
+ "Install the restricted helper to enable the control."
+ case .ready:
+ "The Control Centre control is ready."
+ case .invalidHelper:
+ "Run setup again to replace the invalid helper files."
+ case .missingBundlePayload(commandAvailable: true):
+ "Remove the privileged setup. Then reinstall a complete Clamshell app."
+ case .missingBundlePayload(commandAvailable: false):
+ "Reinstall a complete Clamshell app and try again."
+ }
+ }
+
+ var symbolName: String {
+ switch self {
+ case .needsSetup:
+ "lock.shield"
+ case .ready:
+ "checkmark.circle.fill"
+ case .invalidHelper, .missingBundlePayload:
+ "exclamationmark.triangle.fill"
+ }
+ }
+
+ var tint: Color {
+ switch self {
+ case .needsSetup:
+ .accentColor
+ case .ready:
+ .green
+ case .invalidHelper, .missingBundlePayload:
+ .orange
+ }
+ }
+
+}
diff --git a/App/ClamshellApp/Setup/SetupWindowController.swift b/App/ClamshellApp/Setup/SetupWindowController.swift
new file mode 100644
index 0000000..9f1a8c9
--- /dev/null
+++ b/App/ClamshellApp/Setup/SetupWindowController.swift
@@ -0,0 +1,27 @@
+import AppKit
+import SwiftUI
+
+@MainActor
+final class SetupWindowController {
+ private let window: NSWindow
+
+ convenience init(model: SetupModel) {
+ let content = NSHostingController(rootView: SetupView(model: model))
+ let window = NSWindow(contentViewController: content)
+ window.title = "Clamshell"
+ window.styleMask = [.titled, .closable]
+ window.isReleasedWhenClosed = false
+ window.center()
+ self.init(window: window)
+ }
+
+ init(window: NSWindow) {
+ self.window = window
+ }
+
+ func show() {
+ NSApp.activate()
+ window.makeKeyAndOrderFront(nil)
+ window.orderFrontRegardless()
+ }
+}
diff --git a/App/ClamshellAppTests/AppleScriptAdministratorAuthorizerTests.swift b/App/ClamshellAppTests/AppleScriptAdministratorAuthorizerTests.swift
new file mode 100644
index 0000000..4f296c5
--- /dev/null
+++ b/App/ClamshellAppTests/AppleScriptAdministratorAuthorizerTests.swift
@@ -0,0 +1,151 @@
+import ClamshellCore
+import Foundation
+import Testing
+
+@testable import Clamshell
+
+@Suite("Administrator authorizer")
+struct AppleScriptAdministratorAuthorizerTests {
+ @Test("requests one allowlisted setup command")
+ func setup() async throws {
+ let runner = RecordingProcessRunner()
+ let authorizer = AppleScriptAdministratorAuthorizer(
+ bundleURL: URL(fileURLWithPath: "/Applications/Clamshell.app"),
+ accountName: "liam",
+ runner: runner
+ )
+
+ try await authorizer.run(.install(exposeCommand: true))
+
+ #expect(
+ runner.invocations == [
+ ProcessInvocation(
+ executable: "/usr/bin/osascript",
+ arguments: [
+ "-e",
+ "do shell script \"'/usr/bin/env' 'SUDO_USER=liam' '/Applications/Clamshell.app/Contents/MacOS/clamshellctl' 'setup' '--expose-command' '--quiet'\" with administrator privileges",
+ ]
+ )
+ ])
+ }
+
+ @Test("requests one allowlisted removal command")
+ func removal() async throws {
+ let runner = RecordingProcessRunner()
+ let authorizer = AppleScriptAdministratorAuthorizer(
+ bundleURL: URL(fileURLWithPath: "/Applications/Clamshell.app"),
+ accountName: "liam",
+ runner: runner
+ )
+
+ try await authorizer.run(.uninstall(removeCommand: true))
+
+ #expect(
+ runner.invocations.first?.arguments.last
+ == "do shell script \"'/usr/bin/env' 'SUDO_USER=liam' '/Applications/Clamshell.app/Contents/MacOS/clamshellctl' 'uninstall' '--remove-command' '--quiet'\" with administrator privileges"
+ )
+ #expect(runner.invocations.count == 1)
+ }
+
+ @Test("rejects a companion outside Applications without starting a process")
+ func invalidApplicationLocation() async {
+ let runner = RecordingProcessRunner()
+ let authorizer = AppleScriptAdministratorAuthorizer(
+ bundleURL: URL(fileURLWithPath: "/tmp/Clamshell.app"),
+ accountName: "liam",
+ runner: runner
+ )
+
+ await #expect(throws: AdministratorAuthorizationError.invalidApplicationLocation) {
+ try await authorizer.run(.install(exposeCommand: false))
+ }
+ #expect(runner.invocations.isEmpty)
+ }
+
+ @Test(
+ "rejects a substituted path without starting a process",
+ arguments: ["Clamshell.app", "clamshellctl"]
+ )
+ func substitutedPath(lastPathComponent: String) async {
+ let runner = RecordingProcessRunner()
+ let authorizer = AppleScriptAdministratorAuthorizer(
+ bundleURL: URL(fileURLWithPath: "/Applications/Clamshell.app"),
+ accountName: "liam",
+ runner: runner,
+ resolvedPath: { url in
+ url.lastPathComponent == lastPathComponent ? "/tmp/substitution" : url.path
+ }
+ )
+
+ await #expect(throws: AdministratorAuthorizationError.invalidApplicationLocation) {
+ try await authorizer.run(.install(exposeCommand: false))
+ }
+ #expect(runner.invocations.isEmpty)
+ }
+
+ @Test("rejects an unsafe account name without starting a process")
+ func unsafeAccountName() async {
+ let runner = RecordingProcessRunner()
+ let authorizer = AppleScriptAdministratorAuthorizer(
+ bundleURL: URL(fileURLWithPath: "/Applications/Clamshell.app"),
+ accountName: "name with spaces",
+ runner: runner
+ )
+
+ await #expect(throws: ClamshellError.invalidUsername("name with spaces")) {
+ try await authorizer.run(.install(exposeCommand: false))
+ }
+ #expect(runner.invocations.isEmpty)
+ }
+
+ @Test("reports an osascript failure")
+ func processFailure() async {
+ let runner = RecordingProcessRunner(
+ result: ProcessResult(
+ standardOutput: "",
+ standardError: "User cancelled.",
+ terminationStatus: 1
+ )
+ )
+ let authorizer = AppleScriptAdministratorAuthorizer(
+ bundleURL: URL(fileURLWithPath: "/Applications/Clamshell.app"),
+ accountName: "liam",
+ runner: runner
+ )
+
+ await #expect(
+ throws: ClamshellError.processFailed(
+ executable: "/usr/bin/osascript",
+ terminationStatus: 1,
+ standardError: "User cancelled."
+ )
+ ) {
+ try await authorizer.run(.install(exposeCommand: false))
+ }
+ }
+}
+
+private struct ProcessInvocation: Equatable {
+ let executable: String
+ let arguments: [String]
+}
+
+private final class RecordingProcessRunner: ProcessRunning, @unchecked Sendable {
+ private let result: ProcessResult
+ private(set) var invocations: [ProcessInvocation] = []
+
+ init(
+ result: ProcessResult = ProcessResult(
+ standardOutput: "",
+ standardError: "",
+ terminationStatus: 0
+ )
+ ) {
+ self.result = result
+ }
+
+ func run(_ executable: String, arguments: [String]) throws -> ProcessResult {
+ invocations.append(ProcessInvocation(executable: executable, arguments: arguments))
+ return result
+ }
+}
diff --git a/App/ClamshellAppTests/ApplicationInstancePolicyTests.swift b/App/ClamshellAppTests/ApplicationInstancePolicyTests.swift
new file mode 100644
index 0000000..572b42e
--- /dev/null
+++ b/App/ClamshellAppTests/ApplicationInstancePolicyTests.swift
@@ -0,0 +1,74 @@
+import Foundation
+import Testing
+
+@testable import Clamshell
+
+@Suite("Application instance policy")
+struct ApplicationInstancePolicyTests {
+ private let installedURL = URL(fileURLWithPath: "/Applications/Clamshell.app")
+ private let debugURL = URL(fileURLWithPath: "/tmp/DerivedData/Clamshell.app")
+
+ @Test("keeps the installed app when a Debug copy is newer")
+ func installedAppPriority() {
+ let current = instance(processIdentifier: 10, bundleURL: installedURL, launchedAt: 1)
+ let debug = instance(processIdentifier: 20, bundleURL: debugURL, launchedAt: 2)
+
+ let resolution = ApplicationInstancePolicy().resolve(
+ current: current,
+ running: [current, debug]
+ )
+
+ #expect(resolution == .continueAndTerminate([20]))
+ }
+
+ @Test("terminates a Debug copy when the installed app is running")
+ func debugCopyTermination() {
+ let installed = instance(processIdentifier: 10, bundleURL: installedURL, launchedAt: 1)
+ let current = instance(processIdentifier: 20, bundleURL: debugURL, launchedAt: 2)
+
+ let resolution = ApplicationInstancePolicy().resolve(
+ current: current,
+ running: [installed, current]
+ )
+
+ #expect(resolution == .terminateCurrent)
+ }
+
+ @Test("keeps the newest copy when paths have equal priority")
+ func newestLaunch() {
+ let older = instance(processIdentifier: 10, bundleURL: debugURL, launchedAt: 1)
+ let current = instance(processIdentifier: 20, bundleURL: debugURL, launchedAt: 2)
+
+ let resolution = ApplicationInstancePolicy().resolve(
+ current: current,
+ running: [older, current]
+ )
+
+ #expect(resolution == .continueAndTerminate([10]))
+ }
+
+ @Test("uses the process identifier to resolve a launch-date tie")
+ func processIdentifierTieBreak() {
+ let olderProcess = instance(processIdentifier: 10, bundleURL: debugURL, launchedAt: 1)
+ let current = instance(processIdentifier: 20, bundleURL: debugURL, launchedAt: 1)
+
+ let resolution = ApplicationInstancePolicy().resolve(
+ current: current,
+ running: [olderProcess, current]
+ )
+
+ #expect(resolution == .continueAndTerminate([10]))
+ }
+
+ private func instance(
+ processIdentifier: Int32,
+ bundleURL: URL,
+ launchedAt: TimeInterval
+ ) -> ApplicationInstancePolicy.Instance {
+ ApplicationInstancePolicy.Instance(
+ processIdentifier: processIdentifier,
+ bundleURL: bundleURL,
+ launchDate: Date(timeIntervalSince1970: launchedAt)
+ )
+ }
+}
diff --git a/App/ClamshellAppTests/ApplicationLaunchPolicyTests.swift b/App/ClamshellAppTests/ApplicationLaunchPolicyTests.swift
new file mode 100644
index 0000000..b0604f7
--- /dev/null
+++ b/App/ClamshellAppTests/ApplicationLaunchPolicyTests.swift
@@ -0,0 +1,31 @@
+import AppKit
+import Testing
+
+@testable import Clamshell
+
+@Suite("Application launch policy")
+@MainActor
+struct ApplicationLaunchPolicyTests {
+ @Test("shows setup for a default user launch")
+ func defaultLaunch() {
+ let userInfo: [AnyHashable: Any] = [
+ NSApplication.launchIsDefaultUserInfoKey: true
+ ]
+
+ #expect(ApplicationLaunchPolicy.shouldShowSetup(userInfo: userInfo))
+ }
+
+ @Test("does not show setup for a URL launch")
+ func URLLaunch() {
+ let userInfo: [AnyHashable: Any] = [
+ NSApplication.launchIsDefaultUserInfoKey: false
+ ]
+
+ #expect(!ApplicationLaunchPolicy.shouldShowSetup(userInfo: userInfo))
+ }
+
+ @Test("shows setup when macOS does not provide a launch value")
+ func missingLaunchValue() {
+ #expect(ApplicationLaunchPolicy.shouldShowSetup(userInfo: nil))
+ }
+}
diff --git a/App/ClamshellAppTests/ApplicationRuntimePolicyTests.swift b/App/ClamshellAppTests/ApplicationRuntimePolicyTests.swift
new file mode 100644
index 0000000..e1c5fae
--- /dev/null
+++ b/App/ClamshellAppTests/ApplicationRuntimePolicyTests.swift
@@ -0,0 +1,18 @@
+import Testing
+
+@testable import Clamshell
+
+@Suite("Application runtime policy")
+struct ApplicationRuntimePolicyTests {
+ @Test("skips application coordination in an Xcode test host")
+ func testHost() {
+ let environment = ["XCTestConfigurationFilePath": "/tmp/Clamshell.xctestconfiguration"]
+
+ #expect(!ApplicationRuntimePolicy.shouldStartApplication(environment: environment))
+ }
+
+ @Test("starts the application outside a test host")
+ func application() {
+ #expect(ApplicationRuntimePolicy.shouldStartApplication(environment: [:]))
+ }
+}
diff --git a/App/ClamshellAppTests/CompanionDiagnosticsTests.swift b/App/ClamshellAppTests/CompanionDiagnosticsTests.swift
new file mode 100644
index 0000000..d037365
--- /dev/null
+++ b/App/ClamshellAppTests/CompanionDiagnosticsTests.swift
@@ -0,0 +1,103 @@
+import ClamshellCore
+import Foundation
+import Testing
+
+@testable import Clamshell
+
+@Suite("Companion diagnostics")
+struct CompanionDiagnosticsTests {
+ @Test("reports recoverable incomplete files when only the helper is absent")
+ func missingHelper() throws {
+ let fixture = try CompanionBundleFixture()
+ defer { fixture.remove() }
+ try fixture.createCLI()
+
+ let diagnostics = CompanionDiagnostics(
+ bundleURL: fixture.bundleURL,
+ installation: StubInstallationStatusReader(status: .ready)
+ )
+
+ #expect(
+ try diagnostics.currentState() == .missingBundlePayload(commandAvailable: true)
+ )
+ }
+
+ @Test("reports required reinstallation when the CLI is absent")
+ func missingCLI() throws {
+ let fixture = try CompanionBundleFixture()
+ defer { fixture.remove() }
+
+ let diagnostics = CompanionDiagnostics(
+ bundleURL: fixture.bundleURL,
+ installation: StubInstallationStatusReader(status: .ready)
+ )
+
+ #expect(
+ try diagnostics.currentState() == .missingBundlePayload(commandAvailable: false)
+ )
+ }
+
+ @Test(
+ "maps every installation status when the bundle payload is complete",
+ arguments: [
+ (InstallationStatus.notInstalled, SetupState.needsSetup),
+ (.ready, .ready),
+ (.invalid, .invalidHelper),
+ ]
+ )
+ func installationStatus(status: InstallationStatus, expectedState: SetupState) throws {
+ let fixture = try CompanionBundleFixture()
+ defer { fixture.remove() }
+ try fixture.createPayload()
+
+ let diagnostics = CompanionDiagnostics(
+ bundleURL: fixture.bundleURL,
+ installation: StubInstallationStatusReader(status: status)
+ )
+
+ #expect(try diagnostics.currentState() == expectedState)
+ }
+}
+
+private struct StubInstallationStatusReader: InstallationStatusReading {
+ let status: InstallationStatus
+
+ func currentStatus() throws -> InstallationStatus {
+ status
+ }
+}
+
+private struct CompanionBundleFixture {
+ let bundleURL: URL
+
+ init() throws {
+ bundleURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ .appendingPathComponent("Clamshell.app", isDirectory: true)
+ try FileManager.default.createDirectory(
+ at: bundleURL,
+ withIntermediateDirectories: true
+ )
+ }
+
+ func createCLI() throws {
+ try createFile(at: bundleURL.appendingPathComponent("Contents/MacOS/clamshellctl"))
+ }
+
+ func createPayload() throws {
+ try createCLI()
+ try createFile(at: bundleURL.appendingPathComponent("Contents/Resources/clamshellctl-helper"))
+ }
+
+ func remove() {
+ try? FileManager.default.removeItem(at: bundleURL)
+ }
+
+ private func createFile(at url: URL) throws {
+ try FileManager.default.createDirectory(
+ at: url.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ FileManager.default.createFile(atPath: url.path, contents: Data())
+ }
+}
diff --git a/App/ClamshellAppTests/ControlActionHandlerTests.swift b/App/ClamshellAppTests/ControlActionHandlerTests.swift
new file mode 100644
index 0000000..16b28d5
--- /dev/null
+++ b/App/ClamshellAppTests/ControlActionHandlerTests.swift
@@ -0,0 +1,120 @@
+import ClamshellControlModel
+import ClamshellControlProtocol
+import ClamshellCore
+import Foundation
+import Synchronization
+import Testing
+
+@testable import Clamshell
+
+@Suite("Control action handler")
+struct ControlActionHandlerTests {
+ @Test("applies a valid request and reloads the control")
+ func validRequest() throws {
+ let power = RecordingPower(states: [.disabled, .enabled])
+ let reloads = ReloadRecorder()
+ let handler = ControlActionHandler(
+ model: ControlModel(stateReader: power, stateWriter: power),
+ reloadControls: reloads.record
+ )
+
+ let outcome = handler.handle(
+ try #require(URL(string: "clamshellctl://battery-clamshell/enable")))
+
+ #expect(outcome == .applied)
+ #expect(power.requestedStates == [.enabled])
+ #expect(reloads.count == 1)
+ }
+
+ @Test("ignores an invalid request without changing or reloading the control")
+ func invalidRequest() throws {
+ let power = RecordingPower(states: [.disabled])
+ let reloads = ReloadRecorder()
+ let handler = ControlActionHandler(
+ model: ControlModel(stateReader: power, stateWriter: power),
+ reloadControls: reloads.record
+ )
+
+ let outcome = handler.handle(
+ try #require(URL(string: "clamshellctl://battery-clamshell/enable?unexpected=true")))
+
+ #expect(outcome == .ignored)
+ #expect(power.requestedStates.isEmpty)
+ #expect(reloads.isEmpty)
+ }
+
+ @Test("reports a helper failure and reloads the real state")
+ func helperFailure() throws {
+ let power = RecordingPower(states: [.disabled], writeError: .helperFailed)
+ let reloads = ReloadRecorder()
+ let handler = ControlActionHandler(
+ model: ControlModel(stateReader: power, stateWriter: power),
+ reloadControls: reloads.record
+ )
+
+ let outcome = handler.handle(
+ try #require(URL(string: "clamshellctl://battery-clamshell/enable")))
+
+ #expect(outcome == .failed)
+ #expect(power.requestedStates == [.enabled])
+ #expect(reloads.count == 1)
+ }
+}
+
+private enum ControlActionTestError: Error {
+ case helperFailed
+ case unexpectedRead
+}
+
+private final class RecordingPower: PowerStateReading, PowerStateWriting, Sendable {
+ private struct State: Sendable {
+ var states: [ClamshellState]
+ var requestedStates: [ClamshellState] = []
+ }
+
+ private let state: Mutex
+ private let writeError: ControlActionTestError?
+
+ var requestedStates: [ClamshellState] {
+ state.withLock(\.requestedStates)
+ }
+
+ init(states: [ClamshellState], writeError: ControlActionTestError? = nil) {
+ state = Mutex(State(states: states))
+ self.writeError = writeError
+ }
+
+ func currentState() throws -> ClamshellState {
+ try state.withLock { state in
+ guard !state.states.isEmpty else {
+ throw ControlActionTestError.unexpectedRead
+ }
+ return state.states.removeFirst()
+ }
+ }
+
+ func setState(_ requestedState: ClamshellState) throws {
+ state.withLock { state in
+ state.requestedStates.append(requestedState)
+ }
+ if let writeError {
+ throw writeError
+ }
+ }
+}
+
+private final class ReloadRecorder: Sendable {
+ private let storage = Mutex(0)
+
+ var isEmpty: Bool {
+ storage.withLock { $0 == 0 }
+ }
+
+ var count: Int {
+ storage.withLock { $0 }
+ }
+
+ func record() {
+ storage.withLock { $0 += 1 }
+ }
+}
diff --git a/App/ClamshellAppTests/SetupModelTests.swift b/App/ClamshellAppTests/SetupModelTests.swift
new file mode 100644
index 0000000..732df80
--- /dev/null
+++ b/App/ClamshellAppTests/SetupModelTests.swift
@@ -0,0 +1,122 @@
+import Foundation
+import Testing
+
+@testable import Clamshell
+
+@MainActor
+@Suite("Setup model")
+struct SetupModelTests {
+ @Test(
+ "shows every diagnostic state without requesting administrator access",
+ arguments: [
+ SetupState.needsSetup,
+ .ready,
+ .invalidHelper,
+ .missingBundlePayload(commandAvailable: true),
+ .missingBundlePayload(commandAvailable: false),
+ ]
+ )
+ func diagnosticState(state: SetupState) {
+ let diagnostics = RecordingDiagnostics(states: [state])
+ let authorizer = RecordingAuthorizer()
+ let model = SetupModel(diagnostics: diagnostics, authorizer: authorizer)
+
+ model.refresh()
+
+ #expect(model.state == state)
+ #expect(diagnostics.callCount == 1)
+ #expect(authorizer.requests.isEmpty)
+ }
+
+ @Test("requests setup once and refreshes diagnostics")
+ func setup() async {
+ let diagnostics = RecordingDiagnostics(states: [.ready])
+ let authorizer = RecordingAuthorizer()
+ let model = SetupModel(diagnostics: diagnostics, authorizer: authorizer)
+ model.exposeCommand = true
+
+ await model.setUp()
+
+ #expect(authorizer.requests == [.install(exposeCommand: true)])
+ #expect(diagnostics.callCount == 1)
+ #expect(model.state == .ready)
+ #expect(model.errorMessage == nil)
+ }
+
+ @Test("installs the optional Terminal command after setup")
+ func terminalCommand() async {
+ let diagnostics = RecordingDiagnostics(states: [.ready])
+ let authorizer = RecordingAuthorizer()
+ let model = SetupModel(diagnostics: diagnostics, authorizer: authorizer)
+
+ await model.installTerminalCommand()
+
+ #expect(authorizer.requests == [.install(exposeCommand: true)])
+ #expect(model.state == .ready)
+ }
+
+ @Test("requests removal once and refreshes diagnostics")
+ func removal() async {
+ let diagnostics = RecordingDiagnostics(states: [.needsSetup])
+ let authorizer = RecordingAuthorizer()
+ let model = SetupModel(diagnostics: diagnostics, authorizer: authorizer)
+
+ await model.removePrivilegedSetup()
+
+ #expect(authorizer.requests == [.uninstall(removeCommand: true)])
+ #expect(diagnostics.callCount == 1)
+ #expect(model.state == .needsSetup)
+ #expect(model.errorMessage == nil)
+ }
+
+ @Test("reports an authorisation failure without a diagnostic mutation")
+ func authorisationFailure() async {
+ let diagnostics = RecordingDiagnostics(states: [.ready])
+ let authorizer = RecordingAuthorizer(error: SetupTestError.authorisationFailed)
+ let model = SetupModel(diagnostics: diagnostics, authorizer: authorizer)
+
+ await model.setUp()
+
+ #expect(authorizer.requests == [.install(exposeCommand: false)])
+ #expect(diagnostics.callCount == 0)
+ #expect(model.errorMessage == "Authorisation failed")
+ }
+}
+
+private final class RecordingDiagnostics: CompanionDiagnosing, @unchecked Sendable {
+ private(set) var callCount = 0
+ private var states: [SetupState]
+
+ init(states: [SetupState]) {
+ self.states = states
+ }
+
+ func currentState() throws -> SetupState {
+ callCount += 1
+ return states.removeFirst()
+ }
+}
+
+private final class RecordingAuthorizer: AdministratorAuthorizing, @unchecked Sendable {
+ private(set) var requests: [AdministratorRequest] = []
+ private let error: Error?
+
+ init(error: Error? = nil) {
+ self.error = error
+ }
+
+ func run(_ request: AdministratorRequest) async throws {
+ requests.append(request)
+ if let error {
+ throw error
+ }
+ }
+}
+
+private enum SetupTestError: LocalizedError {
+ case authorisationFailed
+
+ var errorDescription: String? {
+ "Authorisation failed"
+ }
+}
diff --git a/App/ClamshellAppTests/SetupStateTests.swift b/App/ClamshellAppTests/SetupStateTests.swift
new file mode 100644
index 0000000..35cc092
--- /dev/null
+++ b/App/ClamshellAppTests/SetupStateTests.swift
@@ -0,0 +1,22 @@
+import Testing
+
+@testable import Clamshell
+
+@Suite("Setup state")
+struct SetupStateTests {
+ @Test("allows removal when an incomplete bundle still contains the CLI")
+ func recoverableIncompleteBundle() {
+ let state = SetupState.missingBundlePayload(commandAvailable: true)
+
+ #expect(state.allowsPrivilegedRemoval)
+ #expect(!state.allowsSetup)
+ }
+
+ @Test("requires reinstallation when an incomplete bundle has no CLI")
+ func unrecoverableIncompleteBundle() {
+ let state = SetupState.missingBundlePayload(commandAvailable: false)
+
+ #expect(!state.allowsPrivilegedRemoval)
+ #expect(!state.allowsSetup)
+ }
+}
diff --git a/App/ClamshellAppTests/SetupWindowControllerTests.swift b/App/ClamshellAppTests/SetupWindowControllerTests.swift
new file mode 100644
index 0000000..6201e8c
--- /dev/null
+++ b/App/ClamshellAppTests/SetupWindowControllerTests.swift
@@ -0,0 +1,36 @@
+import AppKit
+import Testing
+
+@testable import Clamshell
+
+@Suite("Setup window controller")
+@MainActor
+struct SetupWindowControllerTests {
+ @Test("raises the setup window above other applications")
+ func raisesSetupWindow() {
+ let window = RecordingWindow()
+ let controller = SetupWindowController(window: window)
+
+ controller.show()
+
+ #expect(window.presentationCalls == [.makeKeyAndOrderFront, .orderFrontRegardless])
+ }
+}
+
+@MainActor
+private final class RecordingWindow: NSWindow {
+ enum PresentationCall: Equatable {
+ case makeKeyAndOrderFront
+ case orderFrontRegardless
+ }
+
+ private(set) var presentationCalls: [PresentationCall] = []
+
+ override func makeKeyAndOrderFront(_ sender: Any?) {
+ presentationCalls.append(.makeKeyAndOrderFront)
+ }
+
+ override func orderFrontRegardless() {
+ presentationCalls.append(.orderFrontRegardless)
+ }
+}
diff --git a/App/ClamshellControl/BatteryClamshellControl.swift b/App/ClamshellControl/BatteryClamshellControl.swift
new file mode 100644
index 0000000..ac1bcae
--- /dev/null
+++ b/App/ClamshellControl/BatteryClamshellControl.swift
@@ -0,0 +1,26 @@
+import ClamshellControlIntent
+import ClamshellControlModel
+import SwiftUI
+import WidgetKit
+
+struct BatteryClamshellControl: ControlWidget {
+ nonisolated static let kind = "uk.co.lmliam.clamshell.control.battery"
+
+ var body: some ControlWidgetConfiguration {
+ StaticControlConfiguration(kind: Self.kind, provider: Provider()) { isEnabled in
+ ControlWidgetToggle(isOn: isEnabled, action: SetBatteryClamshellIntent()) {
+ Label("Battery Clamshell Mode", systemImage: "laptopcomputer")
+ }
+ }
+ .displayName("Battery Clamshell Mode")
+ .description("Allow clamshell mode while the Mac uses battery power.")
+ }
+}
+
+private struct Provider: ControlValueProvider {
+ let previewValue = false
+
+ func currentValue() async throws -> Bool {
+ try ControlModel.live.currentValue()
+ }
+}
diff --git a/App/ClamshellControl/ClamshellControlBundle.swift b/App/ClamshellControl/ClamshellControlBundle.swift
new file mode 100644
index 0000000..a9ada59
--- /dev/null
+++ b/App/ClamshellControl/ClamshellControlBundle.swift
@@ -0,0 +1,9 @@
+import SwiftUI
+import WidgetKit
+
+@main
+struct ClamshellControlBundle: WidgetBundle {
+ var body: some Widget {
+ BatteryClamshellControl()
+ }
+}
diff --git a/App/ClamshellControl/Info.plist b/App/ClamshellControl/Info.plist
new file mode 100644
index 0000000..42366c8
--- /dev/null
+++ b/App/ClamshellControl/Info.plist
@@ -0,0 +1,29 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Clamshell Control
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/App/ClamshellControl/Intent/ClamshellControlIntents.swift b/App/ClamshellControl/Intent/ClamshellControlIntents.swift
new file mode 100644
index 0000000..2f807bc
--- /dev/null
+++ b/App/ClamshellControl/Intent/ClamshellControlIntents.swift
@@ -0,0 +1,3 @@
+import AppIntents
+
+public struct ClamshellControlIntents: AppIntentsPackage {}
diff --git a/App/ClamshellControl/Intent/SetBatteryClamshellIntent.swift b/App/ClamshellControl/Intent/SetBatteryClamshellIntent.swift
new file mode 100644
index 0000000..fb43c50
--- /dev/null
+++ b/App/ClamshellControl/Intent/SetBatteryClamshellIntent.swift
@@ -0,0 +1,23 @@
+import AppIntents
+import ClamshellControlProtocol
+
+public struct SetBatteryClamshellIntent: SetValueIntent {
+ public static let title: LocalizedStringResource = "Set battery clamshell mode"
+ public static let supportedModes: IntentModes = .background
+
+ @Parameter(title: "Enabled")
+ public var value: Bool
+
+ public init() {}
+
+ public func perform() async throws -> some IntentResult {
+ try await perform(using: .live)
+ return .result()
+ }
+
+ public func perform(using requester: WorkspaceControlActionRequester) async throws {
+ let request: ControlActionRequest =
+ value ? .enableBatteryClamshellMode : .disableBatteryClamshellMode
+ try await requester.request(request)
+ }
+}
diff --git a/App/ClamshellControl/Intent/WorkspaceControlActionError.swift b/App/ClamshellControl/Intent/WorkspaceControlActionError.swift
new file mode 100644
index 0000000..72476dd
--- /dev/null
+++ b/App/ClamshellControl/Intent/WorkspaceControlActionError.swift
@@ -0,0 +1,3 @@
+public enum WorkspaceControlActionError: Error, Equatable, Sendable {
+ case stateNotConfirmed
+}
diff --git a/App/ClamshellControl/Intent/WorkspaceControlActionRequester.swift b/App/ClamshellControl/Intent/WorkspaceControlActionRequester.swift
new file mode 100644
index 0000000..f08f088
--- /dev/null
+++ b/App/ClamshellControl/Intent/WorkspaceControlActionRequester.swift
@@ -0,0 +1,53 @@
+import AppKit
+import ClamshellControlModel
+import ClamshellControlProtocol
+import Foundation
+
+public struct WorkspaceControlActionRequester: Sendable {
+ public typealias OpenURL = @Sendable (URL) async throws -> Void
+ public typealias CurrentValue = @Sendable () async throws -> Bool
+ public typealias WaitForRetry = @Sendable () async throws -> Void
+
+ public static let live = Self(
+ openURL: { url in
+ let configuration = NSWorkspace.OpenConfiguration()
+ configuration.activates = false
+ _ = try await NSWorkspace.shared.open(url, configuration: configuration)
+ },
+ currentValue: { try ControlModel.live.currentValue() },
+ waitForRetry: { try await Task.sleep(for: .milliseconds(100)) }
+ )
+
+ private let openURL: OpenURL
+ private let currentValue: CurrentValue
+ private let waitForRetry: WaitForRetry
+ private let maximumAttempts: Int
+
+ public init(
+ openURL: @escaping OpenURL,
+ currentValue: @escaping CurrentValue,
+ waitForRetry: @escaping WaitForRetry,
+ maximumAttempts: Int = 50
+ ) {
+ precondition(maximumAttempts > 0)
+ self.openURL = openURL
+ self.currentValue = currentValue
+ self.waitForRetry = waitForRetry
+ self.maximumAttempts = maximumAttempts
+ }
+
+ public func request(_ request: ControlActionRequest) async throws {
+ try await openURL(request.url)
+
+ for attempt in 1...maximumAttempts {
+ if try await currentValue() == request.isEnabled {
+ return
+ }
+ if attempt < maximumAttempts {
+ try await waitForRetry()
+ }
+ }
+
+ throw WorkspaceControlActionError.stateNotConfirmed
+ }
+}
diff --git a/App/ClamshellControl/Model/ControlModel.swift b/App/ClamshellControl/Model/ControlModel.swift
new file mode 100644
index 0000000..dee300e
--- /dev/null
+++ b/App/ClamshellControl/Model/ControlModel.swift
@@ -0,0 +1,34 @@
+import ClamshellCore
+
+public struct ControlModel: Sendable {
+ public static let live: Self = {
+ let runner = FoundationProcessRunner()
+ return Self(
+ stateReader: PowerSettingsClient(runner: runner),
+ stateWriter: PrivilegedHelperClient(runner: runner)
+ )
+ }()
+
+ private let stateReader: any PowerStateReading
+ private let service: ClamshellService
+
+ public init(
+ stateReader: any PowerStateReading,
+ stateWriter: any PowerStateWriting
+ ) {
+ self.stateReader = stateReader
+ service = ClamshellService(
+ stateReader: stateReader,
+ stateWriter: stateWriter
+ )
+ }
+
+ public func currentValue() throws -> Bool {
+ try stateReader.currentState() == .enabled
+ }
+
+ public func setValue(_ isEnabled: Bool) throws {
+ let requestedState = isEnabled ? ClamshellState.enabled : .disabled
+ _ = try service.set(requestedState)
+ }
+}
diff --git a/App/ClamshellControlProtocol/ControlActionRequest.swift b/App/ClamshellControlProtocol/ControlActionRequest.swift
new file mode 100644
index 0000000..b4c4d76
--- /dev/null
+++ b/App/ClamshellControlProtocol/ControlActionRequest.swift
@@ -0,0 +1,45 @@
+import Foundation
+
+public enum ControlActionRequest: Equatable, Sendable {
+ case enableBatteryClamshellMode
+ case disableBatteryClamshellMode
+
+ public init?(url: URL) {
+ switch url.absoluteString {
+ case Self.enableURL.absoluteString:
+ self = .enableBatteryClamshellMode
+ case Self.disableURL.absoluteString:
+ self = .disableBatteryClamshellMode
+ default:
+ return nil
+ }
+ }
+
+ public var isEnabled: Bool {
+ self == .enableBatteryClamshellMode
+ }
+
+ public var url: URL {
+ switch self {
+ case .enableBatteryClamshellMode:
+ Self.enableURL
+ case .disableBatteryClamshellMode:
+ Self.disableURL
+ }
+ }
+
+ private static let enableURL = makeURL(path: "/enable")
+ private static let disableURL = makeURL(path: "/disable")
+
+ private static func makeURL(path: String) -> URL {
+ var components = URLComponents()
+ components.scheme = "clamshellctl"
+ components.host = "battery-clamshell"
+ components.path = path
+
+ guard let url = components.url else {
+ preconditionFailure("The static control action URL is invalid")
+ }
+ return url
+ }
+}
diff --git a/App/ClamshellControlTests/ControlActionRequestTests.swift b/App/ClamshellControlTests/ControlActionRequestTests.swift
new file mode 100644
index 0000000..7cfa691
--- /dev/null
+++ b/App/ClamshellControlTests/ControlActionRequestTests.swift
@@ -0,0 +1,47 @@
+import ClamshellControlProtocol
+import Foundation
+import Testing
+
+@Suite("Control action request")
+struct ControlActionRequestTests {
+ @Test("maps each request to one exact URL")
+ func exactURLs() {
+ #expect(
+ ControlActionRequest.enableBatteryClamshellMode.url.absoluteString
+ == "clamshellctl://battery-clamshell/enable"
+ )
+ #expect(
+ ControlActionRequest.disableBatteryClamshellMode.url.absoluteString
+ == "clamshellctl://battery-clamshell/disable"
+ )
+ }
+
+ @Test("parses both exact URLs")
+ func validURLs() throws {
+ let enableURL = try #require(
+ URL(string: "clamshellctl://battery-clamshell/enable")
+ )
+ let disableURL = try #require(
+ URL(string: "clamshellctl://battery-clamshell/disable")
+ )
+
+ #expect(ControlActionRequest(url: enableURL) == .enableBatteryClamshellMode)
+ #expect(ControlActionRequest(url: disableURL) == .disableBatteryClamshellMode)
+ }
+
+ @Test(
+ "rejects every URL outside the exact contract",
+ arguments: [
+ "other://battery-clamshell/enable",
+ "clamshellctl://other/enable",
+ "clamshellctl://battery-clamshell/toggle",
+ "clamshellctl://battery-clamshell/enable?command=other",
+ "clamshellctl://battery-clamshell/enable#other",
+ "clamshellctl://user@battery-clamshell/enable",
+ ]
+ )
+ func invalidURL(value: String) throws {
+ let url = try #require(URL(string: value))
+ #expect(ControlActionRequest(url: url) == nil)
+ }
+}
diff --git a/App/ClamshellControlTests/ControlModelTests.swift b/App/ClamshellControlTests/ControlModelTests.swift
new file mode 100644
index 0000000..1679be0
--- /dev/null
+++ b/App/ClamshellControlTests/ControlModelTests.swift
@@ -0,0 +1,92 @@
+import ClamshellControlModel
+import ClamshellCore
+import Testing
+
+@Suite("Control model")
+struct ControlModelTests {
+ @Test("maps the current power state to a Boolean value")
+ func currentValue() throws {
+ let enabledPower = RecordingPower(states: [.enabled])
+ let disabledPower = RecordingPower(states: [.disabled])
+
+ #expect(try ControlModel(stateReader: enabledPower, stateWriter: enabledPower).currentValue())
+ #expect(
+ try !ControlModel(stateReader: disabledPower, stateWriter: disabledPower).currentValue())
+ }
+
+ @Test("does not mutate an already selected state")
+ func unchangedState() throws {
+ let power = RecordingPower(states: [.enabled])
+ let model = ControlModel(stateReader: power, stateWriter: power)
+
+ try model.setValue(true)
+
+ #expect(power.requestedStates.isEmpty)
+ #expect(power.states.isEmpty)
+ }
+
+ @Test("requests one exact state and verifies the result")
+ func changedState() throws {
+ let power = RecordingPower(states: [.disabled, .enabled])
+ let model = ControlModel(stateReader: power, stateWriter: power)
+
+ try model.setValue(true)
+
+ #expect(power.requestedStates == [.enabled])
+ #expect(power.states.isEmpty)
+ }
+
+ @Test("propagates a helper failure without another state read")
+ func helperFailure() {
+ let power = RecordingPower(states: [.disabled], writeError: .helperFailed)
+ let model = ControlModel(stateReader: power, stateWriter: power)
+
+ #expect(throws: ControlTestError.helperFailed) {
+ try model.setValue(true)
+ }
+ #expect(power.requestedStates == [.enabled])
+ #expect(power.states.isEmpty)
+ }
+
+ @Test("rejects a result that does not match the requested state")
+ func verificationFailure() {
+ let power = RecordingPower(states: [.disabled, .disabled])
+ let model = ControlModel(stateReader: power, stateWriter: power)
+
+ #expect(throws: ClamshellError.self) {
+ try model.setValue(true)
+ }
+ #expect(power.requestedStates == [.enabled])
+ #expect(power.states.isEmpty)
+ }
+}
+
+private enum ControlTestError: Error {
+ case helperFailed
+ case unexpectedRead
+}
+
+private final class RecordingPower: PowerStateReading, PowerStateWriting, @unchecked Sendable {
+ var states: [ClamshellState]
+ private(set) var requestedStates: [ClamshellState] = []
+ private let writeError: ControlTestError?
+
+ init(states: [ClamshellState], writeError: ControlTestError? = nil) {
+ self.states = states
+ self.writeError = writeError
+ }
+
+ func currentState() throws -> ClamshellState {
+ guard !states.isEmpty else {
+ throw ControlTestError.unexpectedRead
+ }
+ return states.removeFirst()
+ }
+
+ func setState(_ state: ClamshellState) throws {
+ requestedStates.append(state)
+ if let writeError {
+ throw writeError
+ }
+ }
+}
diff --git a/App/ClamshellControlTests/SetBatteryClamshellIntentTests.swift b/App/ClamshellControlTests/SetBatteryClamshellIntentTests.swift
new file mode 100644
index 0000000..1aa7ce5
--- /dev/null
+++ b/App/ClamshellControlTests/SetBatteryClamshellIntentTests.swift
@@ -0,0 +1,70 @@
+import AppIntents
+import ClamshellControlIntent
+import Foundation
+import Testing
+
+@Suite("Battery clamshell intent")
+struct SetBatteryClamshellIntentTests {
+ @Test("runs in the control extension")
+ func extensionExecution() {
+ #expect(SetBatteryClamshellIntent.supportedModes == .background)
+ }
+
+ @Test("maps enabled and disabled values to exact requests")
+ func requests() async throws {
+ let recorder = URLRecorder()
+ let enabledRequester = requester(recorder: recorder, currentValue: true)
+ let disabledRequester = requester(recorder: recorder, currentValue: false)
+ let intent = SetBatteryClamshellIntent()
+
+ intent.value = true
+ try await intent.perform(using: enabledRequester)
+ intent.value = false
+ try await intent.perform(using: disabledRequester)
+
+ #expect(
+ await recorder.values == [
+ "clamshellctl://battery-clamshell/enable",
+ "clamshellctl://battery-clamshell/disable",
+ ]
+ )
+ }
+
+ @Test("propagates relay failures")
+ func relayFailure() async {
+ let requester = WorkspaceControlActionRequester(
+ openURL: { _ in throw IntentTestError.relayFailed },
+ currentValue: { false },
+ waitForRetry: {}
+ )
+ let intent = SetBatteryClamshellIntent()
+ intent.value = true
+
+ await #expect(throws: IntentTestError.relayFailed) {
+ try await intent.perform(using: requester)
+ }
+ }
+
+ private func requester(
+ recorder: URLRecorder,
+ currentValue: Bool
+ ) -> WorkspaceControlActionRequester {
+ WorkspaceControlActionRequester(
+ openURL: { url in await recorder.record(url) },
+ currentValue: { currentValue },
+ waitForRetry: {}
+ )
+ }
+}
+
+private actor URLRecorder {
+ private(set) var values: [String] = []
+
+ func record(_ url: URL) {
+ values.append(url.absoluteString)
+ }
+}
+
+private enum IntentTestError: Error {
+ case relayFailed
+}
diff --git a/App/ClamshellControlTests/WorkspaceControlActionRequesterTests.swift b/App/ClamshellControlTests/WorkspaceControlActionRequesterTests.swift
new file mode 100644
index 0000000..b868163
--- /dev/null
+++ b/App/ClamshellControlTests/WorkspaceControlActionRequesterTests.swift
@@ -0,0 +1,76 @@
+import ClamshellControlIntent
+import ClamshellControlProtocol
+import Foundation
+import Testing
+
+@Suite("Workspace control action requester")
+struct WorkspaceControlActionRequesterTests {
+ @Test("opens the exact URL for the request")
+ func exactURL() async throws {
+ let recorder = URLRecorder()
+ let requester = WorkspaceControlActionRequester(
+ openURL: { url in await recorder.record(url) },
+ currentValue: { true },
+ waitForRetry: {}
+ )
+
+ try await requester.request(.enableBatteryClamshellMode)
+
+ #expect(
+ await recorder.url?.absoluteString
+ == "clamshellctl://battery-clamshell/enable"
+ )
+ }
+
+ @Test("waits until the requested state is confirmed")
+ func stateConfirmation() async throws {
+ let states = StateSequence([false, true])
+ let requester = WorkspaceControlActionRequester(
+ openURL: { _ in },
+ currentValue: { try await states.next() },
+ waitForRetry: {}
+ )
+
+ try await requester.request(.enableBatteryClamshellMode)
+
+ #expect(await states.readCount == 2)
+ }
+
+ @Test("fails when the requested state is not confirmed")
+ func unconfirmedState() async {
+ let requester = WorkspaceControlActionRequester(
+ openURL: { _ in },
+ currentValue: { false },
+ waitForRetry: {},
+ maximumAttempts: 1
+ )
+
+ await #expect(throws: WorkspaceControlActionError.stateNotConfirmed) {
+ try await requester.request(.enableBatteryClamshellMode)
+ }
+ }
+}
+
+private actor URLRecorder {
+ private(set) var url: URL?
+
+ func record(_ url: URL) {
+ self.url = url
+ }
+}
+
+private actor StateSequence {
+ private var states: [Bool]
+ private(set) var readCount = 0
+
+ init(_ states: [Bool]) {
+ self.states = states
+ }
+
+ func next() throws -> Bool {
+ readCount += 1
+ let state = try #require(states.first)
+ states.removeFirst()
+ return state
+ }
+}
diff --git a/Package.swift b/Package.swift
index c3c2d4a..64aad1c 100644
--- a/Package.swift
+++ b/Package.swift
@@ -6,6 +6,7 @@ let package = Package(
name: "clamshellctl",
platforms: [.macOS(.v13)],
products: [
+ .library(name: "ClamshellCore", targets: ["ClamshellCore"]),
.executable(name: "clamshellctl", targets: ["ClamshellCLI"]),
.executable(name: "clamshellctl-helper", targets: ["ClamshellHelper"]),
],
diff --git a/README.md b/README.md
index 2808816..4935d52 100644
--- a/README.md
+++ b/README.md
@@ -52,15 +52,10 @@ password-free access to the public CLI, app bundle, arbitrary `pmset`
arguments, or a shell. After each mutation, `clamshellctl` will reread `pmset`
before reporting success.
-See the [design](docs/clamshellctl-design.md) for the complete privilege and
-component boundaries.
-
## Project status
-The package foundation is in place. The
-[implementation plan](docs/implementation-plan.md) tracks the remaining work.
-Each behaviour will have automated tests before the repository publishes
-installation instructions.
+The CLI, privileged helper, companion app, and Control Centre control are in
+active development. Automated tests cover each published behaviour.
## Development
diff --git a/Sources/ClamshellCLI/Commands/SetupCommand.swift b/Sources/ClamshellCLI/Commands/SetupCommand.swift
index 160af00..10f05e9 100644
--- a/Sources/ClamshellCLI/Commands/SetupCommand.swift
+++ b/Sources/ClamshellCLI/Commands/SetupCommand.swift
@@ -9,12 +9,15 @@ struct SetupCommand: ParsableCommand {
@OptionGroup var output: OutputOptions
+ @Flag(help: "Expose the app-bundled command at /usr/local/bin/clamshellctl.")
+ var exposeCommand = false
+
func run() throws {
try run(installation: CommandComposition.privilegedInstallation())
}
func run(installation: PrivilegedInstallation) throws {
- let result = try installation.install()
+ let result = try installation.install(exposeCommand: exposeCommand)
let console = Console(isQuiet: output.quiet)
if result.didChange {
diff --git a/Sources/ClamshellCLI/Commands/UninstallCommand.swift b/Sources/ClamshellCLI/Commands/UninstallCommand.swift
index 9f451a0..ddc7f3a 100644
--- a/Sources/ClamshellCLI/Commands/UninstallCommand.swift
+++ b/Sources/ClamshellCLI/Commands/UninstallCommand.swift
@@ -9,12 +9,15 @@ struct UninstallCommand: ParsableCommand {
@OptionGroup var output: OutputOptions
+ @Flag(help: "Remove the app-bundled command link when this app owns it.")
+ var removeCommand = false
+
func run() throws {
try run(installation: CommandComposition.privilegedInstallation())
}
func run(installation: PrivilegedInstallation) throws {
- let result = try installation.uninstall()
+ let result = try installation.uninstall(removeCommand: removeCommand)
let console = Console(isQuiet: output.quiet)
guard !result.removedPaths.isEmpty else {
diff --git a/Sources/ClamshellCore/Errors/ClamshellError.swift b/Sources/ClamshellCore/Errors/ClamshellError.swift
index a87caf7..16f7656 100644
--- a/Sources/ClamshellCore/Errors/ClamshellError.swift
+++ b/Sources/ClamshellCore/Errors/ClamshellError.swift
@@ -2,6 +2,8 @@ import Foundation
public enum ClamshellError: Error, Equatable, LocalizedError {
case administratorPrivilegesRequired(command: String)
+ case cliLinkConflict
+ case companionExecutableRequired
case executablePathUnavailable
case helperPayloadNotFound
case installationVerificationFailed
@@ -29,6 +31,10 @@ public enum ClamshellError: Error, Equatable, LocalizedError {
switch self {
case let .administratorPrivilegesRequired(command):
"Administrator privileges are required. Run: \(command)"
+ case .cliLinkConflict:
+ "Cannot expose the terminal command because /usr/local/bin/clamshellctl already exists."
+ case .companionExecutableRequired:
+ "The terminal command can be exposed only from /Applications/Clamshell.app."
case .executablePathUnavailable:
"Unable to resolve the clamshellctl executable path."
case .helperPayloadNotFound:
diff --git a/Sources/ClamshellCore/Power/PowerSettingsClient.swift b/Sources/ClamshellCore/Power/PowerSettingsClient.swift
index 7acb7bd..1045bcb 100644
--- a/Sources/ClamshellCore/Power/PowerSettingsClient.swift
+++ b/Sources/ClamshellCore/Power/PowerSettingsClient.swift
@@ -11,9 +11,9 @@ public struct PowerSettingsClient: PowerStateReading, PowerStateWriting, Sendabl
}
public func currentState() throws -> ClamshellState {
- let result = try runPmset(arguments: ["-g", "custom"])
+ let result = try runPmset(arguments: ["-g"])
- return try parser.batteryState(from: result.standardOutput)
+ return try parser.state(from: result.standardOutput)
}
public func setState(_ state: ClamshellState) throws {
diff --git a/Sources/ClamshellCore/Power/PowerSettingsParser.swift b/Sources/ClamshellCore/Power/PowerSettingsParser.swift
index d96e498..e82e193 100644
--- a/Sources/ClamshellCore/Power/PowerSettingsParser.swift
+++ b/Sources/ClamshellCore/Power/PowerSettingsParser.swift
@@ -3,32 +3,16 @@ import Foundation
public struct PowerSettingsParser: Sendable {
public init() {}
- /// Treats a missing `disablesleep` entry as disabled and rejects malformed values.
- public func batteryState(from output: String) throws -> ClamshellState {
+ public func state(from output: String) throws -> ClamshellState {
let lines = output.split(
omittingEmptySubsequences: false,
whereSeparator: \Character.isNewline
)
- guard
- let batteryHeader = lines.firstIndex(where: {
- $0.trimmingCharacters(in: .whitespaces) == "Battery Power:"
- })
- else {
- throw ClamshellError.unrecognisedPowerSettings
- }
-
- for line in lines[lines.index(after: batteryHeader)...] {
+ for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
- guard !trimmed.isEmpty else {
- continue
- }
- guard line.first?.isWhitespace == true else {
- break
- }
-
let fields = trimmed.split(whereSeparator: \Character.isWhitespace)
- guard fields.first == "disablesleep" else {
+ guard fields.first == "SleepDisabled" else {
continue
}
guard fields.count == 2 else {
@@ -45,6 +29,6 @@ public struct PowerSettingsParser: Sendable {
}
}
- return .disabled
+ throw ClamshellError.unrecognisedPowerSettings
}
}
diff --git a/Sources/ClamshellCore/Privilege/Installation/CompanionCommandInstallation.swift b/Sources/ClamshellCore/Privilege/Installation/CompanionCommandInstallation.swift
new file mode 100644
index 0000000..b8a941e
--- /dev/null
+++ b/Sources/ClamshellCore/Privilege/Installation/CompanionCommandInstallation.swift
@@ -0,0 +1,63 @@
+import Foundation
+
+struct CompanionCommandInstallation: Sendable {
+ private static let executablePath = "/Applications/Clamshell.app/Contents/MacOS/clamshellctl"
+
+ private let fileSystem: any InstallationFileSystem
+ private let sourceExecutablePath: String
+
+ init(fileSystem: any InstallationFileSystem, sourceExecutablePath: String) {
+ self.fileSystem = fileSystem
+ self.sourceExecutablePath = sourceExecutablePath
+ }
+
+ func preflightExposure() throws {
+ guard isCompanionExecutable else {
+ throw ClamshellError.companionExecutableRequired
+ }
+
+ if let destination = fileSystem.symbolicLinkDestination(at: PrivilegedPaths.cliLink) {
+ guard URL(fileURLWithPath: destination).standardizedFileURL.path == Self.executablePath
+ else {
+ throw ClamshellError.cliLinkConflict
+ }
+ return
+ }
+ guard !fileSystem.itemExists(at: PrivilegedPaths.cliLink) else {
+ throw ClamshellError.cliLinkConflict
+ }
+ }
+
+ func expose() throws -> Bool {
+ try preflightExposure()
+
+ if fileSystem.symbolicLinkDestination(at: PrivilegedPaths.cliLink) != nil {
+ return false
+ }
+ guard !fileSystem.itemExists(at: PrivilegedPaths.cliLink) else {
+ throw ClamshellError.cliLinkConflict
+ }
+
+ try fileSystem.createSymbolicLink(
+ at: PrivilegedPaths.cliLink,
+ destination: Self.executablePath
+ )
+ return true
+ }
+
+ func removeIfManaged() throws -> Bool {
+ guard
+ isCompanionExecutable,
+ fileSystem.symbolicLinkDestination(at: PrivilegedPaths.cliLink) == Self.executablePath
+ else {
+ return false
+ }
+
+ try fileSystem.removeItem(at: PrivilegedPaths.cliLink)
+ return true
+ }
+
+ private var isCompanionExecutable: Bool {
+ URL(fileURLWithPath: sourceExecutablePath).standardizedFileURL.path == Self.executablePath
+ }
+}
diff --git a/Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift b/Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift
index ded6964..43d52f0 100644
--- a/Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift
+++ b/Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift
@@ -69,6 +69,17 @@ public struct FoundationInstallationFileSystem: InstallationFileSystem {
}
}
+ public func symbolicLinkDestination(at path: String) -> String? {
+ try? FileManager.default.destinationOfSymbolicLink(atPath: path)
+ }
+
+ public func createSymbolicLink(at path: String, destination: String) throws {
+ try FileManager.default.createSymbolicLink(
+ atPath: path,
+ withDestinationPath: destination
+ )
+ }
+
public func removeItem(at path: String) throws {
guard itemExists(at: path) else {
return
diff --git a/Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift b/Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift
index 5e08a7d..13a78a3 100644
--- a/Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift
+++ b/Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift
@@ -9,6 +9,8 @@ public protocol InstallationFileSystem: Sendable {
func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws
func setPermissions(_ permissions: UInt16, at path: String) throws
func replaceItem(at destination: String, withItemAt replacement: String) throws
+ func symbolicLinkDestination(at path: String) -> String?
+ func createSymbolicLink(at path: String, destination: String) throws
func removeItem(at path: String) throws
}
diff --git a/Sources/ClamshellCore/Privilege/Installation/InstallationStatus.swift b/Sources/ClamshellCore/Privilege/Installation/InstallationStatus.swift
new file mode 100644
index 0000000..4d112f3
--- /dev/null
+++ b/Sources/ClamshellCore/Privilege/Installation/InstallationStatus.swift
@@ -0,0 +1,5 @@
+public enum InstallationStatus: Sendable, Equatable {
+ case notInstalled
+ case ready
+ case invalid
+}
diff --git a/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift b/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift
index 9911a01..95ced31 100644
--- a/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift
+++ b/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift
@@ -36,7 +36,7 @@ public struct PrivilegedInstallation: Sendable {
}
/// Stages, validates, and verifies root-owned files, skipping an identical installation.
- public func install() throws -> InstallationResult {
+ public func install(exposeCommand: Bool = false) throws -> InstallationResult {
try requireAdministratorPrivileges()
guard let originalUser = environment["SUDO_USER"], !originalUser.isEmpty else {
@@ -44,11 +44,62 @@ public struct PrivilegedInstallation: Sendable {
}
let policy = try SudoersPolicy(username: originalUser)
let payload = try helperPayloadPath()
+ if exposeCommand {
+ try companionCommandInstallation.preflightExposure()
+ }
+
+ var didChange = false
+ if try !isConfigured(payload: payload, policy: policy) {
+ try install(payload: payload, policy: policy)
+ didChange = true
+ }
- if try isConfigured(payload: payload, policy: policy) {
- return installationResult(didChange: false)
+ if exposeCommand {
+ didChange = try companionCommandInstallation.expose() || didChange
}
+ return installationResult(didChange: didChange)
+ }
+
+ public func status() throws -> InstallationStatus {
+ let helperExists = fileSystem.isRegularFile(at: PrivilegedPaths.helper)
+ let policyExists = fileSystem.isRegularFile(at: PrivilegedPaths.sudoersPolicy)
+ guard helperExists || policyExists else {
+ return .notInstalled
+ }
+ guard helperExists, policyExists else {
+ return .invalid
+ }
+
+ guard
+ let payload = try? helperPayloadPath(),
+ fileSystem.contentsEqual(at: payload, and: PrivilegedPaths.helper)
+ else {
+ return .invalid
+ }
+
+ let helperAttributes = try fileSystem.attributes(at: PrivilegedPaths.helper)
+ let policyAttributes = try fileSystem.attributes(at: PrivilegedPaths.sudoersPolicy)
+ guard
+ helperAttributes == InstalledFileAttributes(userID: 0, groupID: 0, permissions: 0o755),
+ policyAttributes == InstalledFileAttributes(userID: 0, groupID: 0, permissions: 0o440)
+ else {
+ return .invalid
+ }
+
+ for action in ["enable", "disable"] {
+ let result = try runner.run(
+ "/usr/bin/sudo",
+ arguments: ["-n", "-l", "--", PrivilegedPaths.helper, action]
+ )
+ guard result.terminationStatus == 0 else {
+ return .invalid
+ }
+ }
+ return .ready
+ }
+
+ private func install(payload: String, policy: SudoersPolicy) throws {
let helperTemporary = try stageHelper(payload: payload)
var helperTemporaryExists = true
defer {
@@ -76,14 +127,17 @@ public struct PrivilegedInstallation: Sendable {
throw ClamshellError.installationVerificationFailed
}
- return installationResult(didChange: true)
}
- /// Removes only the two managed installation paths and is safe to repeat.
- public func uninstall() throws -> UninstallationResult {
+ /// Removes only managed installation paths and is safe to repeat.
+ public func uninstall(removeCommand: Bool = false) throws -> UninstallationResult {
try requireAdministratorPrivileges(command: PrivilegedHelperClient.uninstallCommand)
var removedPaths: [String] = []
+ if removeCommand, try companionCommandInstallation.removeIfManaged() {
+ removedPaths.append(PrivilegedPaths.cliLink)
+ }
+
for path in [PrivilegedPaths.helper, PrivilegedPaths.sudoersPolicy]
where fileSystem.itemExists(at: path) {
try fileSystem.removeItem(at: path)
@@ -155,6 +209,13 @@ public struct PrivilegedInstallation: Sendable {
)
}
+ private var companionCommandInstallation: CompanionCommandInstallation {
+ CompanionCommandInstallation(
+ fileSystem: fileSystem,
+ sourceExecutablePath: executablePath
+ )
+ }
+
private func requireAdministratorPrivileges(command: String) throws {
guard effectiveUserID == 0 else {
throw ClamshellError.administratorPrivilegesRequired(
@@ -170,6 +231,15 @@ public struct PrivilegedInstallation: Sendable {
executableDirectory.appendingPathComponent("clamshellctl-helper").path
]
+ if executableDirectory.lastPathComponent == "MacOS" {
+ candidates.append(
+ executableDirectory
+ .deletingLastPathComponent()
+ .appendingPathComponent("Resources/clamshellctl-helper")
+ .path
+ )
+ }
+
if executableDirectory.lastPathComponent == "bin" {
candidates.append(
executableDirectory
diff --git a/Sources/ClamshellCore/Privilege/PrivilegedPaths.swift b/Sources/ClamshellCore/Privilege/PrivilegedPaths.swift
index 9036e70..e9cd48c 100644
--- a/Sources/ClamshellCore/Privilege/PrivilegedPaths.swift
+++ b/Sources/ClamshellCore/Privilege/PrivilegedPaths.swift
@@ -1,4 +1,5 @@
public enum PrivilegedPaths {
+ public static let cliLink = "/usr/local/bin/clamshellctl"
public static let helper = "/Library/PrivilegedHelperTools/clamshellctl-helper"
public static let sudoersPolicy = "/etc/sudoers.d/clamshellctl"
}
diff --git a/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift b/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift
index 481835d..c2a5cf6 100644
--- a/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift
+++ b/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift
@@ -6,6 +6,25 @@ import Testing
@Suite("Setup commands")
struct SetupCommandTests {
+ @Test("setup accepts explicit companion command exposure")
+ func setupCommandExposure() throws {
+ let command = try #require(
+ try ClamshellCommand.parseAsRoot(["setup", "--expose-command"]) as? SetupCommand
+ )
+
+ #expect(command.exposeCommand)
+ }
+
+ @Test("uninstall accepts explicit companion command removal")
+ func uninstallCommandRemoval() throws {
+ let command = try #require(
+ try ClamshellCommand.parseAsRoot(["uninstall", "--remove-command"])
+ as? UninstallCommand
+ )
+
+ #expect(command.removeCommand)
+ }
+
@Test("setup requires an explicit administrator invocation")
func setupRequiresRoot() throws {
let command = try #require(try ClamshellCommand.parseAsRoot(["setup"]) as? SetupCommand)
@@ -65,6 +84,10 @@ struct SetupCommandTests {
func replaceItem(at destination: String, withItemAt replacement: String) throws {
throw UnexpectedOperation()
}
+ func symbolicLinkDestination(at path: String) -> String? { nil }
+ func createSymbolicLink(at path: String, destination: String) throws {
+ throw UnexpectedOperation()
+ }
func removeItem(at path: String) throws { throw UnexpectedOperation() }
}
diff --git a/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift b/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift
index d993430..31a57ae 100644
--- a/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift
+++ b/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift
@@ -5,15 +5,15 @@ import Testing
@Suite("Power settings client")
struct PowerSettingsClientTests {
- @Test("reads the battery state with pmset custom settings")
+ @Test("reads the system state with pmset current settings")
func currentState() throws {
let runner = RecordingProcessRunner(
result: ProcessResult(
standardOutput: """
- Battery Power:
- disablesleep 1
- AC Power:
- disablesleep 0
+ System-wide power settings:
+ SleepDisabled 1
+ Currently in use:
+ sleep 1
""",
standardError: "",
terminationStatus: 0
@@ -27,7 +27,7 @@ struct PowerSettingsClientTests {
runner.invocations == [
ProcessInvocation(
executable: "/usr/bin/pmset",
- arguments: ["-g", "custom"]
+ arguments: ["-g"]
)
])
}
diff --git a/Tests/ClamshellCoreTests/Power/PowerSettingsParserTests.swift b/Tests/ClamshellCoreTests/Power/PowerSettingsParserTests.swift
index 904f4d3..5893427 100644
--- a/Tests/ClamshellCoreTests/Power/PowerSettingsParserTests.swift
+++ b/Tests/ClamshellCoreTests/Power/PowerSettingsParserTests.swift
@@ -4,65 +4,63 @@ import Testing
@Suite("pmset parser")
struct PowerSettingsParserTests {
- @Test("reads enabled from Battery Power")
+ @Test("reads the enabled system state")
func enabled() throws {
let output = """
- Battery Power:
+ System-wide power settings:
+ SleepDisabled 1
+ Currently in use:
sleep 1
- disablesleep 1
- AC Power:
- sleep 1
- disablesleep 0
"""
- #expect(try PowerSettingsParser().batteryState(from: output) == .enabled)
+ #expect(try PowerSettingsParser().state(from: output) == .enabled)
}
- @Test("reads disabled from Battery Power")
+ @Test("reads the disabled system state")
func disabled() throws {
let output = """
- Battery Power:
+ System-wide power settings:
+ SleepDisabled 0
+ Currently in use:
sleep 1
- disablesleep 0
- AC Power:
- disablesleep 1
"""
- #expect(try PowerSettingsParser().batteryState(from: output) == .disabled)
+ #expect(try PowerSettingsParser().state(from: output) == .disabled)
}
- @Test("treats an absent battery disablesleep key as disabled")
- func absentMeansDisabled() throws {
+ @Test("rejects output without a system state")
+ func missingState() {
let output = """
- Battery Power:
+ Currently in use:
sleep 1
- AC Power:
- disablesleep 1
"""
- #expect(try PowerSettingsParser().batteryState(from: output) == .disabled)
+ #expect(throws: ClamshellError.self) {
+ try PowerSettingsParser().state(from: output)
+ }
}
- @Test("rejects output without a Battery Power section")
- func missingBatterySection() {
+ @Test("rejects an unexpected system state value")
+ func unexpectedValue() {
+ let output = """
+ System-wide power settings:
+ SleepDisabled 2
+ """
+
#expect(throws: ClamshellError.self) {
- try PowerSettingsParser().batteryState(
- from: "AC Power:\n disablesleep 1"
- )
+ try PowerSettingsParser().state(from: output)
}
}
- @Test("rejects an unexpected battery disablesleep value")
- func unexpectedValue() {
+ @Test("rejects a malformed system state")
+ func malformedValue() {
let output = """
- Battery Power:
- disablesleep 2
- AC Power:
- disablesleep 0
+ System-wide power settings:
+ SleepDisabled 1 unexpected
"""
#expect(throws: ClamshellError.self) {
- try PowerSettingsParser().batteryState(from: output)
+ try PowerSettingsParser().state(from: output)
}
}
}
diff --git a/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationCompanionTests.swift b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationCompanionTests.swift
new file mode 100644
index 0000000..3cf7b21
--- /dev/null
+++ b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationCompanionTests.swift
@@ -0,0 +1,204 @@
+import Testing
+
+@testable import ClamshellCore
+
+@Suite("Companion privileged installation")
+struct PrivilegedInstallationCompanionTests {
+ private let executable = "/Applications/Clamshell.app/Contents/MacOS/clamshellctl"
+ private let helperPayload = "/Applications/Clamshell.app/Contents/Resources/clamshellctl-helper"
+
+ @Test("finds the helper payload in the companion resources")
+ func companionPayload() throws {
+ let log = InstallationOperationLog()
+ let fileSystem = RecordingInstallationFileSystem(
+ files: [helperPayload: "helper payload"],
+ log: log
+ )
+ let installation = makeInstallation(fileSystem: fileSystem, log: log)
+
+ _ = try installation.install()
+
+ #expect(fileSystem.contents(at: PrivilegedPaths.helper) == "helper payload")
+ }
+
+ @Test("reports missing setup without a privileged operation")
+ func missingStatus() throws {
+ let log = InstallationOperationLog()
+ let installation = makeInstallation(
+ fileSystem: RecordingInstallationFileSystem(files: [:], log: log),
+ log: log,
+ effectiveUserID: 501,
+ environment: [:]
+ )
+
+ #expect(try installation.status() == .notInstalled)
+ #expect(!log.operations.contains { $0.isMutation })
+ }
+
+ @Test("reports a verified setup without changing it")
+ func readyStatus() throws {
+ let log = InstallationOperationLog()
+ let fileSystem = RecordingInstallationFileSystem(
+ files: [helperPayload: "helper payload"],
+ log: log
+ )
+ let installation = makeInstallation(fileSystem: fileSystem, log: log)
+ _ = try installation.install()
+ let operationCount = log.operations.count
+
+ #expect(try installation.status() == .ready)
+ #expect(!log.operations.dropFirst(operationCount).contains { $0.isMutation })
+ }
+
+ @Test("reports a stale installed helper as invalid")
+ func staleHelperStatus() throws {
+ let log = InstallationOperationLog()
+ let fileSystem = RecordingInstallationFileSystem(
+ files: [helperPayload: "helper payload"],
+ log: log
+ )
+ let installation = makeInstallation(fileSystem: fileSystem, log: log)
+ _ = try installation.install()
+ fileSystem.replaceContents(at: PrivilegedPaths.helper, with: "stale helper")
+
+ #expect(try installation.status() == .invalid)
+ }
+
+ @Test("reports a partial setup as invalid")
+ func invalidStatus() throws {
+ let log = InstallationOperationLog()
+ let installation = makeInstallation(
+ fileSystem: RecordingInstallationFileSystem(
+ files: [PrivilegedPaths.helper: "helper"],
+ log: log
+ ),
+ log: log,
+ effectiveUserID: 501,
+ environment: [:]
+ )
+
+ #expect(try installation.status() == .invalid)
+ #expect(!log.operations.contains { $0.isMutation })
+ }
+
+ @Test("exposes only the app-bundled command at the managed path")
+ func exposesCommand() throws {
+ let log = InstallationOperationLog()
+ let fileSystem = RecordingInstallationFileSystem(
+ files: [helperPayload: "helper payload"],
+ log: log
+ )
+ let installation = makeInstallation(fileSystem: fileSystem, log: log)
+
+ _ = try installation.install(exposeCommand: true)
+
+ #expect(fileSystem.symbolicLinkDestination(at: PrivilegedPaths.cliLink) == executable)
+ #expect(
+ log.operations.contains(
+ .createSymbolicLink(path: PrivilegedPaths.cliLink, destination: executable)
+ )
+ )
+ }
+
+ @Test("does not replace an unrelated command")
+ func commandConflict() {
+ let log = InstallationOperationLog()
+ let fileSystem = RecordingInstallationFileSystem(
+ files: [
+ helperPayload: "helper payload",
+ PrivilegedPaths.cliLink: "unrelated command",
+ ],
+ log: log
+ )
+ let installation = makeInstallation(fileSystem: fileSystem, log: log)
+
+ #expect(throws: ClamshellError.cliLinkConflict) {
+ try installation.install(exposeCommand: true)
+ }
+ #expect(fileSystem.contents(at: PrivilegedPaths.cliLink) == "unrelated command")
+ #expect(!log.operations.contains { $0.isMutation })
+ }
+
+ @Test("does not expose a command from outside the companion bundle")
+ func rejectsExternalCommand() {
+ let log = InstallationOperationLog()
+ let source = "/tmp/build/clamshellctl-helper"
+ let installation = PrivilegedInstallation(
+ fileSystem: RecordingInstallationFileSystem(
+ files: [source: "helper payload"],
+ log: log
+ ),
+ runner: InstallationRecordingRunner(result: .success, log: log),
+ effectiveUserID: 0,
+ environment: ["SUDO_USER": "liam"],
+ executablePath: "/tmp/build/clamshellctl",
+ temporarySuffix: "test"
+ )
+
+ #expect(throws: ClamshellError.companionExecutableRequired) {
+ try installation.install(exposeCommand: true)
+ }
+ #expect(!log.operations.contains { $0.isMutation })
+ }
+
+ @Test("removes the command only when it targets this companion")
+ func removesManagedCommand() throws {
+ let log = InstallationOperationLog()
+ let fileSystem = RecordingInstallationFileSystem(
+ files: [:],
+ symbolicLinks: [PrivilegedPaths.cliLink: executable],
+ log: log
+ )
+ let installation = makeInstallation(fileSystem: fileSystem, log: log)
+
+ let result = try installation.uninstall(removeCommand: true)
+
+ #expect(result.removedPaths == [PrivilegedPaths.cliLink])
+ #expect(fileSystem.symbolicLinkDestination(at: PrivilegedPaths.cliLink) == nil)
+ }
+
+ @Test("preserves a command that targets another executable")
+ func preservesUnrelatedCommand() throws {
+ let log = InstallationOperationLog()
+ let fileSystem = RecordingInstallationFileSystem(
+ files: [:],
+ symbolicLinks: [PrivilegedPaths.cliLink: "/tmp/not-clamshellctl"],
+ log: log
+ )
+ let installation = makeInstallation(fileSystem: fileSystem, log: log)
+
+ _ = try installation.uninstall(removeCommand: true)
+
+ #expect(
+ fileSystem.symbolicLinkDestination(at: PrivilegedPaths.cliLink)
+ == "/tmp/not-clamshellctl"
+ )
+ }
+
+ private func makeInstallation(
+ fileSystem: RecordingInstallationFileSystem,
+ log: InstallationOperationLog,
+ effectiveUserID: UInt32 = 0,
+ environment: [String: String] = ["SUDO_USER": "liam"]
+ ) -> PrivilegedInstallation {
+ PrivilegedInstallation(
+ fileSystem: fileSystem,
+ runner: InstallationRecordingRunner(result: .success, log: log),
+ effectiveUserID: effectiveUserID,
+ environment: environment,
+ executablePath: executable,
+ temporarySuffix: "test"
+ )
+ }
+}
+
+private extension InstallationOperation {
+ var isMutation: Bool {
+ switch self {
+ case .copy, .createSymbolicLink, .remove, .replace, .setOwner, .setPermissions, .write:
+ true
+ default:
+ false
+ }
+ }
+}
diff --git a/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift b/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift
index 421726c..0a31620 100644
--- a/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift
+++ b/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift
@@ -9,8 +9,10 @@ enum InstallationOperation: Equatable {
case readText(String)
case attributes(String)
case copy(source: String, destination: String)
+ case createSymbolicLink(path: String, destination: String)
case setOwner(path: String, userID: UInt32, groupID: UInt32)
case setPermissions(path: String, permissions: UInt16)
+ case symbolicLinkDestination(String)
case replace(replacement: String, destination: String)
case write(contents: String, path: String)
case remove(String)
@@ -41,13 +43,16 @@ final class RecordingInstallationFileSystem:
private let reportsMatchingContents: Bool
private var files: [String: String]
private var fileAttributes: [String: InstalledFileAttributes]
+ private var symbolicLinks: [String: String]
init(
files: [String: String],
+ symbolicLinks: [String: String] = [:],
log: InstallationOperationLog,
reportsMatchingContents: Bool = true
) {
self.files = files
+ self.symbolicLinks = symbolicLinks
fileAttributes = Dictionary(
uniqueKeysWithValues: files.keys.map {
($0, InstalledFileAttributes(userID: 501, groupID: 20, permissions: 0o755))
@@ -59,7 +64,7 @@ final class RecordingInstallationFileSystem:
func itemExists(at path: String) -> Bool {
log.append(.itemExists(path))
- return lock.withLock { files[path] != nil }
+ return lock.withLock { files[path] != nil || symbolicLinks[path] != nil }
}
func isRegularFile(at path: String) -> Bool {
@@ -154,17 +159,37 @@ final class RecordingInstallationFileSystem:
}
}
+ func symbolicLinkDestination(at path: String) -> String? {
+ log.append(.symbolicLinkDestination(path))
+ return lock.withLock { symbolicLinks[path] }
+ }
+
+ func createSymbolicLink(at path: String, destination: String) throws {
+ log.append(.createSymbolicLink(path: path, destination: destination))
+ try lock.withLock {
+ guard files[path] == nil, symbolicLinks[path] == nil else {
+ throw ClamshellError.cliLinkConflict
+ }
+ symbolicLinks[path] = destination
+ }
+ }
+
func removeItem(at path: String) throws {
log.append(.remove(path))
lock.withLock {
_ = files.removeValue(forKey: path)
_ = fileAttributes.removeValue(forKey: path)
+ _ = symbolicLinks.removeValue(forKey: path)
}
}
func contents(at path: String) -> String? {
lock.withLock { files[path] }
}
+
+ func replaceContents(at path: String, with contents: String) {
+ lock.withLock { files[path] = contents }
+ }
}
final class InstallationRecordingRunner: ProcessRunning, @unchecked Sendable {
diff --git a/docs/clamshellctl-design.md b/docs/clamshellctl-design.md
deleted file mode 100644
index 229c6bb..0000000
--- a/docs/clamshellctl-design.md
+++ /dev/null
@@ -1,294 +0,0 @@
-# clamshellctl Design
-
-## Summary
-
-`clamshellctl` allows a Mac notebook to remain awake in clamshell mode while it is running on battery power. It wraps the relevant `pmset` setting behind a small, testable Swift core and a narrowly restricted privileged helper.
-
-The CLI is the primary product and Homebrew is its default installation method. An optional, self-contained macOS companion app provides a stateful WidgetKit control that users can place in Control Centre. The companion is distributed as a GitHub Release DMG and does not require a separate Homebrew installation.
-
-## Goals
-
-- Provide clear `status`, `enable`, `disable`, and `toggle` commands.
-- Support temporary enablement with `enable --for `.
-- Require an administrator password only during initial privileged setup or removal.
-- Keep password-free operation limited to the two required `pmset` mutations.
-- Make repeated commands safe and idempotent.
-- Provide an optional native toggle that users can add to Control Centre.
-- Reflect the verified enabled state through the control's active and inactive appearance.
-- Package the companion app, control extension, CLI, and helper payload together in one DMG installation.
-- Keep the companion out of the Dock and menu bar during normal operation.
-- Publish releases through release-please and distribute through `LMLiam/homebrew-tap`.
-- Work on both Apple silicon and Intel Macs supported by the selected Swift toolchain.
-
-## Non-goals for v1
-
-- A persistent menu-bar application.
-- A traditional desktop or Notification Centre widget.
-- Apple Developer Programme signing or notarisation.
-- Automatically changing the setting when USB-C devices are disconnected.
-- Linux or Windows support.
-- JSON output or a persistent application database.
-
-Automatic USB-C disconnect handling remains a separate future issue because the implementation must distinguish displays and power sources from unrelated USB-C devices.
-
-## User experience
-
-Users choose one of two independent installation paths:
-
-- Homebrew installs the CLI and helper payload for terminal-first use.
-- The GitHub DMG installs `Clamshell.app`, which includes the Control Centre extension, the same CLI, and the helper payload.
-
-Installing the DMG does not require Homebrew. The app's first-run setup explains the unsigned-app Gatekeeper approval, installs the root-owned helper and narrow sudoers policy after administrator authorisation, and confirms that the system can discover the Control Centre toggle. The user adds the toggle to Control Centre because macOS does not allow an app to make that personalisation choice automatically.
-
-The app has no persistent Dock or menu-bar presence. It presents a small setup and diagnostics window when opened directly. Users who want terminal access can optionally expose the bundled CLI at `/usr/local/bin/clamshellctl`; this is a symlink to the app-bundled executable rather than a second copy.
-
-The public command surface is:
-
-```text
-clamshellctl status
-clamshellctl enable
-clamshellctl enable --for 2h
-clamshellctl disable
-clamshellctl toggle
-clamshellctl setup
-clamshellctl uninstall
-clamshellctl --version
-clamshellctl --help
-```
-
-`status` prints either `Battery clamshell mode: enabled` or `Battery clamshell mode: disabled`. If a timer is active, it also prints the scheduled automatic-disable time.
-
-`enable`, `disable`, and `toggle` verify the resulting system state before reporting success. Repeating `enable` when already enabled or `disable` when already disabled succeeds without an unnecessary mutation.
-
-`--quiet` suppresses successful output for automation and scripts. Errors still go to standard error and use a non-zero exit status.
-
-Durations accept a deliberately small syntax: whole numbers followed by `m`, `h`, or `d`, such as `30m`, `2h`, or `1d`. A new timed enable replaces an existing timer. Manual disablement cancels any active timer.
-
-## Architecture
-
-The repository contains a Swift package for the shared implementation and CLI products, plus an Xcode project for the native companion:
-
-1. `ClamshellCore` owns state parsing, command decisions, duration handling, timer metadata, setup-file generation, and domain errors. It does not execute privileged system changes directly.
-2. `ClamshellCLI` produces the `clamshellctl` executable. It parses arguments, reads current state, invokes the helper when needed, manages the timer, and renders user-facing output.
-3. `ClamshellHelper` produces the `clamshellctl-helper` executable. It accepts only `enable` or `disable`, invokes `pmset`, and verifies the resulting state.
-4. `ClamshellApp` provides first-run setup, diagnostics, and helper removal. It has no persistent Dock or menu-bar item. Normal state reads and mutations call `ClamshellCore` directly.
-5. `ClamshellControl` is a WidgetKit extension that supplies a `ControlWidgetToggle`. Its value provider performs the read-only `pmset` query through `ClamshellCore`; its `SetValueIntent` uses the same narrow helper client as the app and verifies the requested state before returning.
-
-System commands are accessed through injected process-running interfaces so unit and integration tests can use deterministic fakes. `pmset` remains the sole source of truth for whether battery clamshell mode is enabled.
-
-The app and CLI both call `ClamshellCore`; normal app operation does not shell out through the CLI. First-run setup is the deliberate exception: the app uses the macOS administrator-authorisation dialog to run the bundled CLI's existing `setup` operation, keeping one audited privileged-installation path. The DMG bundles the CLI executable for optional terminal use and bundles the helper only as an installation payload. The privileged helper installed on the system is a root-owned copy outside the user-writable app bundle.
-
-The CLI supports macOS 13 and later. The companion app and Control Centre extension support macOS 26 and later, which is the first macOS SDK that exposes WidgetKit controls on Mac. Keeping separate deployment targets preserves broad CLI compatibility without weakening the native experience.
-
-The implementation favours idiomatic Swift, small responsibility-based types, explicit dependency boundaries, and value semantics where appropriate. Public and internal APIs remain easy to extend without speculative abstractions: each component exposes the smallest interface needed by its callers, while platform interactions stay behind replaceable adapters.
-
-## State and command flow
-
-The current battery setting is read from the Battery Power section of `/usr/bin/pmset -g custom`. No cached copy is used to answer `status` or decide `toggle`.
-
-For a mutation:
-
-1. The CLI reads the current state.
-2. It returns successfully if the requested state is already active.
-3. Otherwise it runs the root-owned helper through password-free, non-interactive `sudo`.
-4. The helper validates its exact argument and runs `/usr/bin/pmset -b disablesleep 1` or `/usr/bin/pmset -b disablesleep 0`.
-5. The helper re-reads the setting and fails if the expected state was not applied.
-6. The CLI performs its own final read and reports the verified result.
-
-This flow keeps normal operation fast while preventing a successful message from being printed when the setting did not change.
-
-## Privileged setup and removal
-
-Homebrew installs the public CLI and an unprivileged helper payload. The companion app contains equivalent payloads inside its bundle. Neither installation path performs privileged changes before the user explicitly starts setup.
-
-The documented setup command runs the installed CLI with `sudo`, using its explicit Homebrew path where the administrator's secure path does not include Homebrew. Setup then:
-
-- copies the helper to `/Library/PrivilegedHelperTools/clamshellctl-helper`;
-- sets ownership to `root:wheel` and mode `0755`;
-- writes `/etc/sudoers.d/clamshellctl` with mode `0440`;
-- permits the current user to run only the helper's exact `enable` and `disable` commands without a password;
-- validates the sudoers file with `/usr/sbin/visudo -cf` before reporting success; and
-- performs a read-only installation check.
-
-The sudoers rule never grants password-free access to arbitrary `pmset` arguments, the public CLI, a shell, or a user-writable file.
-
-`uninstall` removes only the helper and its sudoers file after validating their expected paths. When the companion created the optional `/usr/local/bin/clamshellctl` symlink, removal also deletes that symlink after verifying that it targets the companion bundle. Homebrew remains responsible for uninstalling a Homebrew-managed CLI. Removing the privileged components is safe to repeat.
-
-## Timed enablement
-
-`enable --for ` records an absolute deadline and installs a user LaunchAgent dedicated to the pending disable operation. The LaunchAgent calls the installed CLI with `disable --quiet`, then removes its timer metadata after a successful or terminal attempt.
-
-The timer is based on an absolute deadline so sleep does not restart its duration. If macOS cannot run the job at the exact deadline, the disable operation runs when the user LaunchAgent is next eligible. `status` ignores stale timer metadata after its deadline and reports an actionable warning if cleanup is required.
-
-Timer files use a stable application-support location under the user's Library and do not require root access.
-
-## Companion app and Control Centre
-
-The companion supplies a `ControlWidgetToggle` rather than a traditional widget or Shortcut. Its value provider displays the battery clamshell state read directly from `pmset`. Its `SetValueIntent` requests the exact state selected by the user instead of blindly toggling, which makes retries and optimistic system UI updates safe. The intent can execute from the system's selected app or extension process, but it reaches privilege only through the installed root-owned helper's exact `enable` or `disable` command. No app or extension process invokes `pmset` with mutation arguments directly.
-
-When enabled, the system renders the control in its active appearance; when disabled, it renders the inactive appearance. After an action completes, the app requests a control reload so the displayed value is reconciled with the `pmset` source of truth.
-
-The unsigned distribution path has a mandatory feasibility gate before release. An ad-hoc-signed build must prove on a clean local account that macOS discovers the extension after Gatekeeper approval, the value provider can obtain current state, the intent can reach only the narrow helper operation, and an app update preserves the control and privileged setup. If any part of that boundary cannot be secured without Developer ID capabilities, the CLI ships independently while the companion remains unreleased rather than broadening privileges.
-
-The project cannot add the control to Control Centre automatically. Setup provides concise instructions and verifies discovery, while placement remains the user's explicit macOS personalisation choice.
-
-## DMG distribution
-
-GitHub Releases publish an ad-hoc-signed DMG containing `Clamshell.app`. A free Apple Account is sufficient to develop and test the app, but it does not provide Developer ID signing or notarisation. The README therefore explains the initial Gatekeeper warning and the exact Privacy & Security `Open Anyway` flow without implying that the app has been reviewed by Apple.
-
-The app bundle contains the control extension, shared implementation, bundled CLI, and helper payload. Dragging the app to `/Applications` is the only application installation step. First-run setup performs the privileged installation separately so the security-sensitive helper is immutable by the normal user.
-
-Removing privileged setup deletes only the root-owned helper, sudoers rule, and optional CLI symlink. Removing `Clamshell.app` deletes the companion and Control Centre extension. The app and documentation guide users to remove privileged setup before deleting the bundle.
-
-## Errors and recovery
-
-User-facing errors are concise and actionable:
-
-- missing helper or sudoers rule: provide the exact setup command;
-- malformed duration: show the accepted duration forms;
-- unsupported or unrecognised `pmset` output: report that the current macOS behaviour is unsupported rather than guessing;
-- permission failure: identify the privileged setup as invalid and suggest rerunning setup;
-- failed state verification: report the expected and observed states;
-- timer installation failure: disable the newly enabled mode unless the user explicitly requested permanent enablement; and
-- unavailable Control Centre action: direct the user to open the companion for helper and extension diagnostics.
-
-Diagnostic command output is included only when it is safe and useful. No operation silently broadens privileges or changes AC-power settings.
-
-## Repository layout
-
-```text
-clamshellctl/
-├── .github/
-│ ├── ISSUE_TEMPLATE/
-│ ├── workflows/
-│ ├── CODEOWNERS
-│ ├── CONTRIBUTING.md
-│ ├── SECURITY.md
-│ └── SUPPORT.md
-├── Sources/
-│ ├── ClamshellCore/
-│ │ ├── Errors/
-│ │ ├── Power/
-│ │ ├── Privilege/Installation/
-│ │ ├── Process/
-│ │ ├── State/
-│ │ └── Timing/ # Added with timed enablement
-│ ├── ClamshellCLI/
-│ │ └── Commands/
-│ └── ClamshellHelper/
-├── App/
-│ ├── ClamshellApp/
-│ └── ClamshellControl/
-├── Tests/
-│ ├── ClamshellCoreTests/
-│ │ ├── Power/
-│ │ ├── Privilege/Installation/
-│ │ ├── Process/
-│ │ ├── State/
-│ │ └── Support/
-│ └── ClamshellCLITests/Commands/
-├── docs/
-│ ├── assets/
-│ └── clamshellctl-design.md
-├── scripts/
-├── .release-please-manifest.json
-├── CHANGELOG.md
-├── LICENSE
-├── Package.swift
-├── README.md
-├── release-please-config.json
-├── version.txt
-└── project.yml # Added with the native companion
-```
-
-The approved transparent artwork is stored as `docs/assets/clamshellctl.png` and used near the top of the README.
-
-`project.yml` is the reviewable source of truth for the native targets. XcodeGen creates `Clamshell.xcodeproj` locally and in CI; the generated project is not committed.
-
-## Testing and verification
-
-Unit tests cover:
-
-- recognised and malformed `pmset` output;
-- enabled, disabled, and unexpected states;
-- idempotent command decisions;
-- duration parsing and absolute-deadline calculation;
-- timer replacement and cancellation;
-- helper argument rejection;
-- generated sudoers contents and file paths; and
-- error rendering and exit-status mapping.
-
-Integration tests execute the CLI and helper against fake process runners and temporary files. CI never invokes a real privileged mutation or changes a runner's power settings.
-
-GitHub Actions runs Swift build and test jobs on macOS. Release readiness also requires a manual acceptance pass on a Mac notebook covering battery enablement, AC behaviour remaining unchanged, disablement, toggle, timed disablement, and setup removal.
-
-The companion has an additional clean-account acceptance pass covering Gatekeeper approval, first-run privileged setup, Control Centre discovery, correct active and inactive rendering, external CLI state changes, app updates, optional CLI exposure, and complete removal. The release workflow does not publish the DMG until these behaviours pass with the actual ad-hoc-signed release artefact.
-
-## Releases
-
-The project uses Conventional Commits and release-please, matching the established Kotventure workflow.
-
-- `googleapis/release-please-action` v5 is pinned to an exact commit SHA.
-- The manifest configuration uses `release-type: simple` for the single root package.
-- Development begins at `0.1.0` and uses `v0.1.0`-style tags.
-- Pre-1.0 features bump the minor version and fixes bump the patch version.
-- Release PRs update `CHANGELOG.md`, `.release-please-manifest.json`, `version.txt`, and the Swift version source used by `clamshellctl --version`.
-- Changelog sections follow Kotventure's style but remain limited to features, fixes, refactors, and documentation; tests and routine chores are hidden.
-- Pull-request titles are validated as Conventional Commits so squash merges feed release-please correctly.
-
-The release workflow uses `RELEASE_PLEASE_TOKEN`, with `GITHUB_TOKEN` available only as a documented fallback, so CI runs on release-please PRs. It uses a separate, narrowly scoped `TAP_GITHUB_TOKEN` to update the Homebrew tap.
-
-Merging the release PR creates the version tag and GitHub Release. The action's `release_created` output gates subsequent publication work in the same workflow.
-
-The GitHub Release also contains the companion DMG when its native acceptance gate is enabled for that release. Homebrew publication and the CLI release remain independent of DMG eligibility so a companion-specific failure cannot block CLI users.
-
-## Homebrew distribution
-
-The existing `LMLiam/homebrew-tap` repository receives `Formula/clamshellctl.rb`.
-
-The formula builds the Swift package from the tagged source release instead of distributing an unsigned prebuilt binary. It installs the public CLI and helper payload, prints the explicit privileged setup command as a caveat, and tests the installation with `clamshellctl --version` without changing system power settings.
-
-When release-please creates a release, the publication job calculates the new source checksum, generates the formula, validates it, and updates `homebrew-tap`. The workflow follows the proven cross-repository authentication pattern used by `remote-monitor` while omitting GoReleaser, multi-platform binaries, SBOM generation, and other machinery that does not serve this macOS-only Swift utility.
-
-The documented installation flow is:
-
-```text
-brew install LMLiam/tap/clamshellctl
-sudo "$(brew --prefix)/bin/clamshellctl" setup
-```
-
-No paid Apple Developer Programme membership is required for this source-build distribution model.
-
-The DMG is the independent installation path for users who want the native Control Centre experience. It does not require Homebrew and includes the same version of the CLI and helper payload as the tagged source release.
-
-## Public repository and community setup
-
-The local repository lives at `/Users/liam/Developer/clamshellctl`. The public repository is `LMLiam/clamshellctl`, uses the `main` branch, and is licensed under MIT.
-
-The initial public setup includes:
-
-- a concise README with the logo, purpose, safety explanation, Homebrew and DMG installation, setup, Control Centre placement, Gatekeeper approval, troubleshooting, and uninstall instructions;
-- contribution, security, and support documents;
-- focused issue templates;
-- CI, Conventional PR-title validation, release-please, and Homebrew publication workflows; and
-- GitHub issues that describe the intended outcome and retain the approved implementation plan in their bodies.
-
-Initial issues cover the core CLI, privileged setup, timed enablement, native companion feasibility, Control Centre integration, DMG packaging, documentation, Homebrew publication, and release automation. A later issue covers USB-C disconnect automation.
-
-After the first public release succeeds, `LMLiam/LMLiam` is updated to feature `clamshellctl` on the GitHub profile.
-
-## Success criteria
-
-The initial release is complete when:
-
-- a fresh Homebrew installation and one-time setup work on a supported Mac notebook;
-- all public commands behave as documented and verify their results;
-- AC-power settings are never changed;
-- repeated operations and uninstall are safe;
-- the timed-disable path survives ordinary sleep and cancels correctly;
-- the optional companion installs without Homebrew and exposes a stateful toggle in Control Centre;
-- the Control Centre appearance matches the verified `pmset` state after app, CLI, and timer changes;
-- the ad-hoc-signed release passes the clean-account security and lifecycle acceptance gate;
-- CI passes without privileged mutation;
-- merging a release-please PR creates the GitHub Release and updates the Homebrew formula; and
-- the public README and GitHub profile link users to the project.
diff --git a/docs/coderabbit-implementation-plan.md b/docs/coderabbit-implementation-plan.md
deleted file mode 100644
index b4d6543..0000000
--- a/docs/coderabbit-implementation-plan.md
+++ /dev/null
@@ -1,212 +0,0 @@
-# CodeRabbit Configuration Implementation Plan
-
-**Goal:** Add a version-controlled CodeRabbit policy that produces strict,
-high-signal reviews without spending review allowance on drafts or incremental
-pushes.
-
-**Architecture:** `.coderabbit.yaml` owns review timing, presentation,
-pre-merge checks, tool configuration, and path-specific guidance. The existing
-repository-quality design records the policy, while CodeRabbit's manual
-pre-merge command verifies the effective configuration on pull request #11.
-
-**Tech stack:** CodeRabbit schema v2, YAML, SwiftLint, ShellCheck, GitHub pull
-requests.
-
----
-
-## Task 1: Add the repository configuration
-
-**Files:**
-
-- Create: `.coderabbit.yaml`
-
-- [ ] **Step 1: Replace the minimal configuration**
-
-Use this complete configuration:
-
-```yaml
-# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
-language: "en-GB"
-early_access: false
-
-reviews:
- profile: "assertive"
- request_changes_workflow: true
- high_level_summary: true
- high_level_summary_in_walkthrough: true
- high_level_summary_instructions: >-
- Summarise user-visible behaviour, security boundaries, and verification in
- concise British English. Omit release-note filler.
- review_status: true
- review_details: false
- commit_status: true
- fail_commit_status: false
- collapse_walkthrough: true
- changed_files_summary: true
- sequence_diagrams: false
- estimate_code_review_effort: false
- assess_linked_issues: true
- related_issues: false
- related_prs: false
- suggested_labels: false
- auto_apply_labels: false
- suggested_reviewers: false
- in_progress_fortune: false
- poem: false
- enable_prompt_for_ai_agents: false
- abort_on_close: true
-
- auto_review:
- enabled: true
- auto_incremental_review: false
- drafts: false
-
- finishing_touches:
- docstrings:
- enabled: false
-
- pre_merge_checks:
- docstrings:
- mode: "off"
- title:
- mode: "off"
- description:
- mode: "warning"
- issue_assessment:
- mode: "warning"
-
- path_instructions:
- - path: "**/*"
- instructions: |
- Report only findings with a concrete correctness, security, or
- maintainability consequence. State the consequence and the smallest
- suitable remedy. Do not request cosmetic churn, redundant comments,
- or documentation for self-explanatory implementation details. Verify
- each finding against the current code and repository conventions.
- - path: "**/*.swift"
- instructions: |
- Follow the Google Swift Style Guide and the repository SwiftLint
- policy. Prefer idiomatic Swift 6, narrow access control, Sendable-safe
- concurrency, typed errors, and explicit failure handling. Treat one
- primary responsibility per file as a design goal, not a rigid
- one-declaration rule.
- - path: "Sources/ClamshellCore/Privilege/**/*.swift"
- instructions: |
- Treat helper actions, sudoers generation, filesystem replacement,
- ownership, permissions, username handling, and process construction as
- security boundaries. Check exact allow-lists and failure consistency.
- - path: "Sources/ClamshellHelper/**/*.swift"
- instructions: |
- Treat every accepted argument and privileged command as part of the
- root boundary. Reject broader command shapes and require observable
- verification of requested state changes.
- - path: "Tests/**/*.swift"
- instructions: |
- Review observable behaviour, failure paths, and privilege constraints.
- Tests must not mutate live power settings or privileged system files.
- Do not request assertions about private implementation or call order
- unless order is part of the contract.
- - path: ".github/workflows/**"
- instructions: |
- Check immutable action pinning, least-privilege permissions, untrusted
- pull-request code execution, concurrency, timeouts, and fail-closed
- status handling. Request comments only for non-obvious security
- constraints or necessary tool suppressions.
- - path: "scripts/**"
- instructions: |
- Check quoting, exit-status preservation, temporary-file cleanup, and
- fail-closed behaviour. Prefer portable shell accepted by ShellCheck.
- - path: "**/*.md"
- instructions: |
- Check technical accuracy against current behaviour. Use concise British
- English. Reject stale paths, unsupported claims, and internal AI
- process metadata.
-
- tools:
- swiftlint:
- enabled: true
- config_file: ".swiftlint.yml"
- shellcheck:
- enabled: true
-
-chat:
- auto_reply: true
-```
-
-- [ ] **Step 2: Parse the YAML locally**
-
-Run:
-
-```bash
-ruby -ryaml -e '
- config = YAML.safe_load(File.read(".coderabbit.yaml"), aliases: false)
- abort "invalid CodeRabbit config" unless
- config.dig("reviews", "profile") == "assertive" &&
- config.dig("reviews", "auto_review", "drafts") == false &&
- config.dig("reviews", "auto_review", "auto_incremental_review") == false &&
- config.dig("reviews", "pre_merge_checks", "docstrings", "mode") == "off"
-'
-```
-
-Expected: exit status 0 with no output.
-
-- [ ] **Step 3: Check formatting and the repository diff**
-
-Run:
-
-```bash
-git diff --check
-scripts/check.sh
-```
-
-Expected: no whitespace errors; formatting, SwiftLint, tests, builds, and
-workflow lint all pass.
-
-### Task 2: Publish and verify the effective policy
-
-**Files:**
-
-- Add: `.coderabbit.yaml`
-- Verify: pull request #11 metadata and CodeRabbit walkthrough
-
-- [ ] **Step 1: Commit the configuration**
-
-Run:
-
-```bash
-git add .coderabbit.yaml
-git commit -m "ci(review): configure high-signal CodeRabbit reviews"
-git push
-```
-
-Expected: only `.coderabbit.yaml` is included in the commit.
-
-- [ ] **Step 2: Verify pull-request issue coverage**
-
-Confirm that pull request #11 states that it partially addresses issues #7 and #8
-without closing either issue.
-
-- [ ] **Step 3: Run the configured pre-merge checks**
-
-Post this pull-request comment:
-
-```text
-@coderabbitai run pre-merge checks
-```
-
-Expected: the docstring and title checks are disabled. Description and linked
-issue assessment run as warnings, with the issue assessment recognising #7 and #8
-as partial scope.
-
-- [ ] **Step 4: Verify the final review state**
-
-Run:
-
-```bash
-gh pr checks 11 --repo LMLiam/clamshellctl
-git status --short --branch
-```
-
-Expected: required checks pass or remain in progress with no configuration
-failure; no CodeRabbit-authored review thread remains unresolved; `.vscode/`
-remains untouched.
diff --git a/docs/control-feasibility-test.md b/docs/control-feasibility-test.md
new file mode 100644
index 0000000..c29b027
--- /dev/null
+++ b/docs/control-feasibility-test.md
@@ -0,0 +1,88 @@
+# Control Centre feasibility test
+
+Status: Pending
+
+This test checks the ad-hoc-signed Control Centre companion before publication.
+Do not publish the DMG until all manual checks pass.
+
+## Test environment
+
+- Date: 29 July 2026
+- macOS: 27.0 (26A5353q)
+- Xcode: 26.6 (17F113)
+- Swift: 6.3.3
+- Architecture: arm64
+- Signing: ad hoc
+
+## Automated checks
+
+| Check | Result | Observation |
+| --- | --- | --- |
+| Package tests | Pass | All 71 tests passed. |
+| Control extension tests | Pass | All 10 tests passed in the Release configuration. |
+| Companion app tests | Pass | All 17 tests passed in the Release configuration. |
+| Release app build | Pass | Xcode built the app and control extension with ad-hoc signing. |
+| Nested signature | Pass | `codesign --verify --deep --strict` reported a valid app. |
+| URL registration | Pass | The app registered the exact `clamshellctl` scheme. |
+| Intent boundary | Pass | The extension contains the control intent. The main executable does not contain it. |
+| Cold request | Pass | macOS started the app in the background and disabled battery clamshell mode. The active app did not change. |
+| Warm request | Pass | The running app enabled battery clamshell mode in the background. The active app did not change. |
+| Failure request | Pass | A request with no installed helper did not change the power state or show setup. |
+| Control Centre request | Pass | The control applied disable and stayed inactive. The CLI and `pmset` reported the disabled state. |
+| Disk image checksum | Pass | `hdiutil verify` reported a valid checksum. |
+| Gatekeeper rejection | Pass | `spctl` rejected the unnotarised disk image as expected. |
+| Quarantine marker | Pass | The local disk image has a test quarantine attribute. |
+
+The test artefact is `.build/feasibility/Clamshell-feasibility.dmg`.
+It is an arm64-only local artefact and is not a release artefact.
+The installed action relay came from
+`.build/feasibility/Clamshell-action-relay-v2.app`.
+
+## Build commands
+
+```bash
+swift build -c release --product clamshellctl
+swift build -c release --product clamshellctl-helper
+xcodegen generate
+xcodebuild -project Clamshell.xcodeproj -scheme ClamshellControlTests \
+ -configuration Release -destination 'platform=macOS,arch=arm64' \
+ CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= test
+xcodebuild -project Clamshell.xcodeproj -scheme ClamshellAppTests \
+ -configuration Release -destination 'platform=macOS,arch=arm64' \
+ CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= test
+xcodebuild -project Clamshell.xcodeproj -scheme ClamshellApp \
+ -configuration Release -destination 'platform=macOS,arch=arm64' \
+ CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= build
+codesign --verify --deep --strict --verbose=2 \
+ .build/feasibility/Clamshell-action-relay-v2.app
+/usr/bin/open 'clamshellctl://battery-clamshell/disable'
+/Applications/Clamshell.app/Contents/MacOS/clamshellctl status
+/usr/bin/open 'clamshellctl://battery-clamshell/enable'
+/Applications/Clamshell.app/Contents/MacOS/clamshellctl status
+hdiutil verify .build/feasibility/Clamshell-feasibility.dmg
+spctl --assess --type open --context context:primary-signature \
+ .build/feasibility/Clamshell-feasibility.dmg
+```
+
+## Manual checks
+
+Run these checks from a separate local test account.
+
+- [ ] macOS shows the expected Gatekeeper warning.
+- [ ] Privacy & Security permits the app after explicit approval.
+- [x] macOS discovers Battery Clamshell Mode in Control Centre.
+- [x] The control shows the inactive state when `SleepDisabled` is `0`.
+- [x] One authorisation prompt completes setup.
+- [x] The helper owner is `root:wheel` and its mode is `0755`.
+- [x] The sudoers policy owner is `root:wheel` and its mode is `0440`.
+- [x] Setup validates the sudoers policy with `visudo`.
+- [x] The background relay enables battery clamshell mode.
+- [x] The control shows the active state after enablement.
+- [x] The control disables battery clamshell mode.
+- [x] The control reloads from the real system state.
+- [ ] Repeated setup does not change valid files.
+- [ ] Repeated removal succeeds safely.
+- [ ] Removal preserves an unrelated `/usr/local/bin/clamshellctl` item.
+- [ ] A second ad-hoc-signed app build preserves control discovery and setup.
+
+Record only the result and non-sensitive command output. Do not record account names or passwords.
diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md
deleted file mode 100644
index 3d01bbb..0000000
--- a/docs/implementation-plan.md
+++ /dev/null
@@ -1,1358 +0,0 @@
-# clamshellctl MVP Implementation Plan
-
-**Goal:** Build, verify, publish, and distribute a secure Swift CLI plus an optional self-contained macOS Control Centre companion for controlling battery clamshell mode.
-
-**Architecture:** A pure `ClamshellCore` module owns parsing, decisions, file generation, timer rules, and the narrow helper client. Thin CLI, helper, app, and WidgetKit control targets compose those rules with Foundation-backed platform adapters. Homebrew installs the CLI path; an ad-hoc-signed DMG packages the app, control extension, CLI, and helper payload without requiring Homebrew.
-
-**Tech stack:** Swift 6.3, Swift Package Manager, Swift Testing, SwiftUI, WidgetKit, App Intents, XcodeGen, Apple Swift Argument Parser 1.8, macOS `pmset`, `sudo`, `launchd`, GitHub Actions, release-please v5, Homebrew, and `hdiutil`.
-
-**Working convention:** Complete tasks in order. Develop behaviour test-first, stage explicit paths, and use `verb(area): description` commit messages. Never run privileged integration tests in CI or mutate the developer Mac unless a step is marked as a manual acceptance test.
-
----
-
-## File map
-
-The implementation uses responsibility-based files rather than grouping unrelated behaviour into large command files.
-
-```text
-Package.swift SwiftPM products, targets, dependency versions
-Sources/ClamshellCore/BuildVersion.swift Release-please-managed version
-Sources/ClamshellCore/Errors/*.swift Domain errors and user-facing recovery
-Sources/ClamshellCore/Power/*.swift pmset parsing, reads, writes, and helper arguments
-Sources/ClamshellCore/Privilege/*.swift Helper client, paths, and sudoers policy
-Sources/ClamshellCore/Privilege/Installation/*.swift Root setup, verification, and removal
-Sources/ClamshellCore/Process/*.swift Process values, protocol, and Foundation adapter
-Sources/ClamshellCore/State/*.swift State contracts and idempotent transitions
-Sources/ClamshellCore/Timing/*.swift Duration and LaunchAgent lifecycle added in Phase 4
-Sources/ClamshellCLI/ClamshellCommand.swift Root ArgumentParser command and composition root
-Sources/ClamshellCLI/Commands/*.swift One public command per file
-Sources/ClamshellCLI/CommandComposition.swift Production dependency composition
-Sources/ClamshellCLI/Console.swift Quiet-aware stdout and stderr rendering
-Sources/ClamshellCLI/OutputOptions.swift Shared command output flags
-Sources/ClamshellHelper/ClamshellHelper.swift Minimal privileged executable entry point
-App/ClamshellApp/ClamshellApp.swift Setup-only SwiftUI application entry point
-App/ClamshellApp/SetupView.swift First-run, diagnostics, and removal interface
-App/ClamshellApp/SetupModel.swift Testable companion setup state and actions
-App/ClamshellApp/Info.plist Background-style app bundle metadata
-App/ClamshellControl/ClamshellControlBundle.swift WidgetKit extension entry point
-App/ClamshellControl/BatteryClamshellControl.swift Stateful Control Centre toggle
-App/ClamshellControl/SetBatteryClamshellIntent.swift Exact-state App Intent action
-App/ClamshellControl/Info.plist WidgetKit extension metadata
-App/ClamshellAppTests/*.swift Companion model tests
-App/ClamshellControlTests/*.swift Control model tests
-Tests/ClamshellCoreTests/{Power,Privilege,Process,State}/*.swift Domain suites
-Tests/ClamshellCoreTests/Support/*.swift Shared test support by responsibility
-Tests/ClamshellCLITests/Commands/*.swift Black-box CLI tests without privilege changes
-project.yml Deterministic XcodeGen app project definition
-scripts/embed-command-products.sh Bundle release CLI and helper payloads
-scripts/package-dmg.sh Build the ad-hoc-signed release DMG
-Tests/Scripts/run-dmg-packaging-tests.sh DMG structure and input validation tests
-scripts/generate-homebrew-formula.sh Deterministic formula generation
-scripts/check-conventional-subject.sh Commit and pull-request title validation
-scripts/check.sh Complete local quality gate
-.github/workflows/ci.yml macOS build and test checks
-.github/workflows/pr.yml Conventional pull-request title check
-.github/workflows/release.yml release-please and tap publication
-release-please-config.json Single-package release configuration
-.release-please-manifest.json Released version manifest
-version.txt Simple release strategy version file
-Formula/clamshellctl.rb Generated formula fixture for validation
-docs/assets/clamshellctl.png Approved transparent README artwork
-README.md Installation, safety, use, and troubleshooting
-.github/CONTRIBUTING.md Development and commit conventions
-.github/SECURITY.md Privilege boundary and reporting policy
-.github/SUPPORT.md Support channels and diagnostics
-LICENSE MIT licence
-```
-
-## Phase 1: Repository and package foundation
-
-### Task 1: Create the Swift package skeleton
-
-**Files:**
-
-- Create: `Package.swift`
-- Create: `Sources/ClamshellCore/BuildVersion.swift`
-- Create: `Sources/ClamshellCLI/ClamshellCommand.swift`
-- Create: `Sources/ClamshellHelper/ClamshellHelper.swift`
-- Create: `Tests/ClamshellCoreTests/BuildVersionTests.swift`
-- Create: `.gitignore`
-- Create: `version.txt`
-
-- [ ] **Step 1: Add a failing version test**
-
-```swift
-import Testing
-@testable import ClamshellCore
-
-@Suite("Build version")
-struct BuildVersionTests {
- @Test("starts at the planned initial version")
- func initialVersion() {
- #expect(BuildVersion.current == "0.1.0")
- }
-}
-```
-
-- [ ] **Step 2: Add the SwiftPM manifest**
-
-```swift
-// swift-tools-version: 6.0
-
-import PackageDescription
-
-let package = Package(
- name: "clamshellctl",
- platforms: [.macOS(.v13)],
- products: [
- .executable(name: "clamshellctl", targets: ["ClamshellCLI"]),
- .executable(name: "clamshellctl-helper", targets: ["ClamshellHelper"]),
- ],
- dependencies: [
- .package(
- url: "https://github.com/apple/swift-argument-parser",
- from: "1.8.0"
- ),
- ],
- targets: [
- .target(name: "ClamshellCore"),
- .executableTarget(
- name: "ClamshellCLI",
- dependencies: [
- "ClamshellCore",
- .product(name: "ArgumentParser", package: "swift-argument-parser"),
- ]
- ),
- .executableTarget(
- name: "ClamshellHelper",
- dependencies: ["ClamshellCore"]
- ),
- .testTarget(name: "ClamshellCoreTests", dependencies: ["ClamshellCore"]),
- .testTarget(
- name: "ClamshellCLITests",
- dependencies: ["ClamshellCore"]
- ),
- ]
-)
-```
-
-- [ ] **Step 3: Run the test and confirm the missing symbol**
-
-Run: `swift test --filter BuildVersionTests`
-
-Expected: compilation fails because `BuildVersion` does not exist.
-
-- [ ] **Step 4: Add the initial version and executable entry points**
-
-```swift
-// Sources/ClamshellCore/BuildVersion.swift
-public enum BuildVersion {
- // x-release-please-version
- public static let current = "0.1.0"
-}
-```
-
-```swift
-// Sources/ClamshellCLI/ClamshellCommand.swift
-import ArgumentParser
-import ClamshellCore
-
-@main
-struct ClamshellCommand: ParsableCommand {
- static let configuration = CommandConfiguration(
- commandName: "clamshellctl",
- abstract: "Control battery clamshell mode on macOS.",
- version: BuildVersion.current
- )
-}
-```
-
-```swift
-// Sources/ClamshellHelper/ClamshellHelper.swift
-import Foundation
-
-@main
-enum ClamshellHelper {
- static func main() {
- FileHandle.standardError.write(Data("Invalid helper invocation.\n".utf8))
- Foundation.exit(64)
- }
-}
-```
-
-Write `0.1.0` followed by a newline to `version.txt`. Ignore `.build/`, `.swiftpm/`, `.DS_Store`, and `xcuserdata/` in `.gitignore`.
-
-- [ ] **Step 5: Verify the package**
-
-Run: `swift test && swift build -c release && swift run clamshellctl --version`
-
-Expected: tests and release build pass; the version command prints `0.1.0`.
-
-- [ ] **Step 6: Commit the foundation**
-
-```bash
-git add .gitignore Package.swift Sources Tests version.txt Package.resolved
-git commit -m "build(package): initialise Swift command package"
-```
-
-### Task 2: Publish the repository shell and initial issues
-
-**Files:**
-
-- Create: `LICENSE`
-- Create: `docs/assets/clamshellctl.png`
-- Create: `README.md`
-- Create: `.github/ISSUE_TEMPLATE/bug.yml`
-- Create: `.github/ISSUE_TEMPLATE/feature.yml`
-
-- [ ] **Step 1: Copy the approved logo and add the MIT licence**
-
-Copy the validated transparent asset from the prototype into `docs/assets/clamshellctl.png`. Use Liam's GitHub identity and 2026 in the standard MIT licence text.
-
-- [ ] **Step 2: Add a truthful pre-release README**
-
-The README must show the logo, identify the project as under active development, describe the intended safety boundary, and avoid publishing installation commands until the formula exists. It must link to `docs/clamshellctl-design.md` for the detailed design.
-
-- [ ] **Step 3: Add focused issue forms**
-
-The bug form requests macOS version, Mac model, installation method, `clamshellctl --version`, `clamshellctl status`, expected behaviour, and actual behaviour. The feature form asks for the user problem, desired outcome, alternatives, and security or privilege impact.
-
-- [ ] **Step 4: Commit the public repository shell**
-
-```bash
-git add LICENSE README.md docs/assets .github/ISSUE_TEMPLATE
-git commit -m "docs(project): add public repository foundation"
-```
-
-- [ ] **Step 5: Create and push the public GitHub repository**
-
-Run:
-
-```bash
-gh repo create LMLiam/clamshellctl \
- --public \
- --description "Control battery clamshell mode on macOS" \
- --source . \
- --remote origin \
- --push
-```
-
-Expected: `main` tracks `origin/main`, the repository is public, and the design and implementation plan are visible under `docs/` without internal tooling paths or process notes.
-
-- [ ] **Step 6: Create issue labels and issue-ready milestones**
-
-Create labels `area: cli`, `area: privilege`, `area: timer`, `area: app`, `area: control`, `area: dmg`, `area: docs`, `area: release`, `area: homebrew`, `type: feature`, and `type: maintenance`. Create one issue for each remaining phase in this plan. Each issue body must state the end goal, acceptance criteria, owned files, dependencies, verification commands, and the corresponding plan tasks.
-
-## Phase 2: Read-only state support
-
-### Task 3: Parse the Battery Power section safely
-
-**Files:**
-
-- Create: `Sources/ClamshellCore/ClamshellState.swift`
-- Create: `Sources/ClamshellCore/PowerSettingsParser.swift`
-- Create: `Sources/ClamshellCore/ClamshellError.swift`
-- Create: `Tests/ClamshellCoreTests/PowerSettingsParserTests.swift`
-
-- [ ] **Step 1: Add parameterised parser tests**
-
-Use real-shaped fixtures that prove:
-
-```swift
-import Testing
-@testable import ClamshellCore
-
-@Suite("pmset parser")
-struct PowerSettingsParserTests {
- @Test("reads enabled from Battery Power")
- func enabled() throws {
- let output = """
- Battery Power:
- sleep 1
- disablesleep 1
- AC Power:
- sleep 1
- disablesleep 0
- """
- #expect(try PowerSettingsParser().batteryState(from: output) == .enabled)
- }
-
- @Test("treats an absent battery disablesleep key as disabled")
- func absentMeansDisabled() throws {
- let output = """
- Battery Power:
- sleep 1
- AC Power:
- disablesleep 1
- """
- #expect(try PowerSettingsParser().batteryState(from: output) == .disabled)
- }
-
- @Test("rejects output without a Battery Power section")
- func missingBatterySection() {
- #expect(throws: ClamshellError.self) {
- try PowerSettingsParser().batteryState(from: "AC Power:\n sleep 1")
- }
- }
-}
-```
-
-- [ ] **Step 2: Run the tests and confirm missing types**
-
-Run: `swift test --filter PowerSettingsParserTests`
-
-Expected: compilation fails for the undefined state, parser, and error types.
-
-- [ ] **Step 3: Implement the minimal parser**
-
-Define `public enum ClamshellState: String, Sendable { case enabled, disabled }`. Split output into trimmed lines, locate `Battery Power:`, stop at the next non-indented section heading, and inspect only an exact `disablesleep` key. Accept values `1` and `0`; throw `ClamshellError.unrecognisedPowerSettings` for other values or a missing Battery Power section.
-
-- [ ] **Step 4: Verify parser behaviour**
-
-Run: `swift test --filter PowerSettingsParserTests`
-
-Expected: all parser tests pass, including AC/Battery separation and malformed output.
-
-- [ ] **Step 5: Commit the parser**
-
-```bash
-git add Sources/ClamshellCore Tests/ClamshellCoreTests
-git commit -m "feat(state): parse battery clamshell setting"
-```
-
-### Task 4: Add the process boundary and status command
-
-**Files:**
-
-- Create: `Sources/ClamshellCore/ProcessRunner.swift`
-- Create: `Sources/ClamshellCore/PowerSettingsClient.swift`
-- Create: `Sources/ClamshellCLI/Console.swift`
-- Create: `Sources/ClamshellCLI/Commands/StatusCommand.swift`
-- Modify: `Sources/ClamshellCLI/ClamshellCommand.swift`
-- Create: `Tests/ClamshellCoreTests/PowerSettingsClientTests.swift`
-
-- [ ] **Step 1: Test the read-only client with a recording runner**
-
-Define a test runner returning a configured `ProcessResult`. Assert that `currentState()` calls `/usr/bin/pmset` with `[-g, custom]`, returns `.enabled` for valid output, and maps non-zero termination to `ClamshellError.processFailed` with captured stderr.
-
-- [ ] **Step 2: Run the tests and confirm the missing process API**
-
-Run: `swift test --filter PowerSettingsClientTests`
-
-Expected: compilation fails for `ProcessRunning`, `ProcessResult`, and `PowerSettingsClient`.
-
-- [ ] **Step 3: Implement the process API**
-
-```swift
-public struct ProcessResult: Sendable, Equatable {
- public let standardOutput: String
- public let standardError: String
- public let terminationStatus: Int32
-}
-
-public protocol ProcessRunning: Sendable {
- func run(_ executable: String, arguments: [String]) throws -> ProcessResult
-}
-```
-
-`FoundationProcessRunner` uses `Process`, separate stdout and stderr pipes, absolute executable paths, and UTF-8 decoding with an explicit decoding error. `PowerSettingsClient.currentState()` calls only `/usr/bin/pmset -g custom` and delegates parsing.
-
-- [ ] **Step 4: Add `status` composition**
-
-Register `StatusCommand` as the default subcommand. It constructs `PowerSettingsClient(runner: FoundationProcessRunner())`, prints `Battery clamshell mode: `, and never invokes `sudo` or `pmset` mutation arguments.
-
-- [ ] **Step 5: Verify without mutating power settings**
-
-Run: `swift test && swift run clamshellctl status`
-
-Expected: tests pass and status reports the observed battery state.
-
-- [ ] **Step 6: Commit status support**
-
-```bash
-git add Sources Tests
-git commit -m "feat(status): report battery clamshell state"
-```
-
-## Phase 3: Secure state mutation
-
-### Task 5: Implement idempotent state transitions
-
-**Files:**
-
-- Create: `Sources/ClamshellCore/ClamshellService.swift`
-- Create: `Tests/ClamshellCoreTests/ClamshellServiceTests.swift`
-
-- [ ] **Step 1: Test the transition matrix**
-
-For every requested/current pair, assert the expected decision:
-
-```swift
-@Test(arguments: [
- (ClamshellState.disabled, ClamshellState.disabled, false),
- (.disabled, .enabled, true),
- (.enabled, .enabled, false),
- (.enabled, .disabled, true),
-])
-func transition(
- current: ClamshellState,
- requested: ClamshellState,
- shouldMutate: Bool
-) throws {
- let power = RecordingPowerSettings(current: current)
- let service = ClamshellService(stateReader: power, stateWriter: power)
- let result = try service.set(requested)
- #expect(result.didChange == shouldMutate)
- #expect(power.requestedStates == (shouldMutate ? [requested] : []))
-}
-```
-
-Add a verification test in which the fake reports the wrong final state and the service throws `stateVerificationFailed`.
-
-- [ ] **Step 2: Implement the smallest service API**
-
-`PowerStateReading` exposes `currentState()` and `PowerStateWriting` exposes `setState(_:)`. `ClamshellService` receives the two capabilities separately, reads through the former, and mutates through the latter. `set(_:)` skips an equal state, requests a change, reads again, and returns `TransitionResult(previous:current:didChange:)` only after verification. `toggle()` derives the opposite requested state from a fresh read. A single recording fake can conform to both protocols in tests; production CLI composition uses separate reader and writer implementations.
-
-- [ ] **Step 3: Run and commit**
-
-Run: `swift test --filter ClamshellServiceTests`
-
-Expected: all transition and verification tests pass.
-
-```bash
-git add Sources/ClamshellCore Tests/ClamshellCoreTests
-git commit -m "feat(control): add verified state transitions"
-```
-
-### Task 6: Implement the restricted privileged helper
-
-**Files:**
-
-- Modify: `Sources/ClamshellCore/PowerSettingsClient.swift`
-- Replace: `Sources/ClamshellHelper/ClamshellHelper.swift`
-- Create: `Tests/ClamshellCoreTests/PowerMutationTests.swift`
-
-- [ ] **Step 1: Test the exact helper argument grammar**
-
-Define `PowerMutation(rawArguments:)` and prove that only `["enable"]` and `["disable"]` are accepted. Empty input, extra arguments, flags, mixed case, and arbitrary `pmset` values must throw `invalidHelperArguments`.
-
-- [ ] **Step 2: Add controlled mutation to `PowerSettingsClient`**
-
-Map `.enabled` exclusively to `/usr/bin/pmset -b disablesleep 1` and `.disabled` exclusively to `/usr/bin/pmset -b disablesleep 0`. Verify through the existing read path after a zero exit status.
-
-- [ ] **Step 3: Replace the helper entry point**
-
-The helper must:
-
-1. require effective UID zero;
-2. parse exactly one mutation argument;
-3. run the controlled mutation;
-4. verify the final state;
-5. print nothing on success; and
-6. write a sanitised error to stderr and exit non-zero on failure.
-
-It must not use ArgumentParser, shell execution, relative executable paths, or environment-provided command paths.
-
-- [ ] **Step 4: Verify safely**
-
-Run: `swift test --filter PowerMutationTests && swift build -c release`
-
-Then run the helper without `sudo`:
-
-```bash
-.build/release/clamshellctl-helper enable
-```
-
-Expected: it exits non-zero with an administrator-privilege error before invoking `pmset`.
-
-- [ ] **Step 5: Commit the helper**
-
-```bash
-git add Sources/ClamshellCore Sources/ClamshellHelper Tests/ClamshellCoreTests
-git commit -m "feat(helper): restrict privileged power mutations"
-```
-
-### Task 7: Add enable, disable, toggle, and quiet output
-
-**Files:**
-
-- Create: `Sources/ClamshellCore/PrivilegedHelperClient.swift`
-- Create: `Sources/ClamshellCLI/Commands/EnableCommand.swift`
-- Create: `Sources/ClamshellCLI/Commands/DisableCommand.swift`
-- Create: `Sources/ClamshellCLI/Commands/ToggleCommand.swift`
-- Modify: `Sources/ClamshellCLI/ClamshellCommand.swift`
-- Modify: `Sources/ClamshellCLI/Console.swift`
-- Create: `Tests/ClamshellCoreTests/PrivilegedHelperClientTests.swift`
-
-- [ ] **Step 1: Test exact sudo invocations**
-
-Assert that the client runs only:
-
-```text
-/usr/bin/sudo -n /Library/PrivilegedHelperTools/clamshellctl-helper enable
-/usr/bin/sudo -n /Library/PrivilegedHelperTools/clamshellctl-helper disable
-```
-
-Map sudo's non-zero result to an error that names the setup command without echoing arbitrary stderr.
-
-- [ ] **Step 2: Implement the client and command composition**
-
-Each public mutation command composes a read-only `PowerSettingsClient` with `PrivilegedHelperClient` through `ClamshellService`. `--quiet` is a shared `@OptionGroup`; it suppresses only successful output. Normal output distinguishes `already enabled`, `enabled`, `already disabled`, `disabled`, and the verified toggle result.
-
-- [ ] **Step 3: Verify help and failure safety**
-
-Run:
-
-```bash
-swift test
-swift run clamshellctl --help
-swift run clamshellctl enable
-```
-
-Expected: tests pass, help lists the four state commands, and enable fails with setup guidance when the project helper is not installed. It must not prompt for a password because runtime sudo uses `-n`.
-
-- [ ] **Step 4: Commit public mutations**
-
-```bash
-git add Sources Tests
-git commit -m "feat(cli): add clamshell control commands"
-```
-
-## Phase 4: Privileged installation lifecycle
-
-### Task 8: Generate and validate the sudoers policy
-
-**Files:**
-
-- Create: `Sources/ClamshellCore/SudoersPolicy.swift`
-- Create: `Sources/ClamshellCore/PrivilegedInstallation.swift`
-- Create: `Tests/ClamshellCoreTests/SudoersPolicyTests.swift`
-- Create: `Tests/ClamshellCoreTests/PrivilegedInstallationTests.swift`
-
-- [ ] **Step 1: Test username validation and exact policy text**
-
-Accept only ASCII usernames matching `[A-Za-z0-9._-]+`. The generated policy contains exactly two non-comment command rules, one for `enable` and one for `disable`, both targeting `/Library/PrivilegedHelperTools/clamshellctl-helper`. Reject whitespace, path separators, shell punctuation, empty names, and newlines.
-
-- [ ] **Step 2: Test installation operations through an injected file system**
-
-Assert the operation order: verify root, locate payload, copy to a temporary sibling, set `root:wheel` and `0755`, atomically replace the helper, write a temporary sudoers file, set `0440`, run `/usr/sbin/visudo -cf `, and atomically replace `/etc/sudoers.d/clamshellctl`. A failed `visudo` must leave the existing policy untouched.
-
-- [ ] **Step 3: Implement setup and uninstall services**
-
-Resolve the original user from validated `SUDO_USER`; never default to root. Locate the helper beside a development build or in Homebrew's sibling `libexec` directory. Uninstall only the two exact managed paths and remain idempotent when either is absent.
-
-- [ ] **Step 4: Run tests without root writes**
-
-Run: `swift test --filter SudoersPolicyTests && swift test --filter PrivilegedInstallationTests`
-
-Expected: all tests use temporary directories and recording runners; `/Library/PrivilegedHelperTools` and `/etc/sudoers.d` remain unchanged.
-
-- [ ] **Step 5: Commit installation services**
-
-```bash
-git add Sources/ClamshellCore Tests/ClamshellCoreTests
-git commit -m "feat(setup): define secure helper installation"
-```
-
-### Task 9: Expose setup and uninstall commands
-
-**Files:**
-
-- Create: `Sources/ClamshellCLI/Commands/SetupCommand.swift`
-- Create: `Sources/ClamshellCLI/Commands/UninstallCommand.swift`
-- Modify: `Sources/ClamshellCLI/ClamshellCommand.swift`
-- Create: `Tests/ClamshellCLITests/SetupCommandTests.swift`
-
-- [ ] **Step 1: Add black-box argument tests**
-
-Test that setup and uninstall reject execution without effective UID zero, show the explicit `sudo "$(brew --prefix)/bin/clamshellctl" setup` guidance, and never attempt partial installation.
-
-- [ ] **Step 2: Register the commands**
-
-The commands call `PrivilegedInstallation`, print the installed or removed paths, and provide an idempotent `already configured` or `already removed` result. Setup performs a final `visudo -cf /etc/sudoers.d/clamshellctl` and helper ownership check.
-
-- [ ] **Step 3: Verify without sudo**
-
-Run: `swift test && swift run clamshellctl setup`
-
-Expected: tests pass; the local command exits non-zero with explicit sudo guidance and makes no protected writes.
-
-- [ ] **Step 4: Commit lifecycle commands**
-
-```bash
-git add Sources/ClamshellCLI Tests/ClamshellCLITests
-git commit -m "feat(setup): add helper setup and removal commands"
-```
-
-## Phase 5: Timed enablement
-
-### Task 10: Parse bounded durations and timer metadata
-
-**Files:**
-
-- Create: `Sources/ClamshellCore/Duration.swift`
-- Create: `Sources/ClamshellCore/TimerMetadata.swift`
-- Create: `Tests/ClamshellCoreTests/DurationTests.swift`
-- Create: `Tests/ClamshellCoreTests/TimerMetadataTests.swift`
-
-- [ ] **Step 1: Add strict duration tests**
-
-Accept `1m`, `30m`, `1h`, `2h`, and `1d`. Reject zero, negative signs, decimals, spaces, missing units, uppercase units, overflow, and values longer than 30 days. Convert through checked integer arithmetic.
-
-- [ ] **Step 2: Add metadata round-trip tests**
-
-`TimerMetadata` contains schema version `1`, an absolute UTC deadline, and the absolute CLI executable path. Encode with stable sorted JSON keys and ISO-8601 dates. Reject unknown schema versions and non-absolute executable paths.
-
-- [ ] **Step 3: Implement and verify**
-
-Run: `swift test --filter DurationTests && swift test --filter TimerMetadataTests`
-
-Expected: all accepted, rejected, and round-trip cases pass.
-
-- [ ] **Step 4: Commit timer values**
-
-```bash
-git add Sources/ClamshellCore Tests/ClamshellCoreTests
-git commit -m "feat(timer): model bounded disable deadlines"
-```
-
-### Task 11: Manage the one-shot LaunchAgent
-
-**Files:**
-
-- Create: `Sources/ClamshellCore/TimerController.swift`
-- Create: `Tests/ClamshellCoreTests/TimerControllerTests.swift`
-
-- [ ] **Step 1: Test LaunchAgent generation and lifecycle**
-
-Assert that the generated plist:
-
-- uses label `io.github.lmliam.clamshellctl.timer`;
-- runs the absolute CLI path with hidden `timer-check`;
-- enables `RunAtLoad` to reconcile a missed deadline after login or reboot;
-- schedules `StartCalendarInterval` for the absolute deadline;
-- contains no shell command or user-controlled plist key; and
-- is written atomically beneath `~/Library/LaunchAgents`.
-
-Test replacement unloads the previous agent before writing and bootstrapping the new one. Cancellation bootouts the label and deletes only the managed plist and metadata.
-
-- [ ] **Step 2: Implement the controller**
-
-Use `/bin/launchctl bootout gui/ ` and `/bin/launchctl bootstrap gui/ ` through `ProcessRunning`. `timer-check` reads metadata: it exits successfully without mutation before the deadline; at or after the deadline it requests disable, then cancels timer files after verified success.
-
-- [ ] **Step 3: Verify with fakes**
-
-Run: `swift test --filter TimerControllerTests`
-
-Expected: tests cover create, replace, cancel, pre-deadline check, expired check, missed-deadline login, and cleanup failure without touching the real LaunchAgents directory.
-
-- [ ] **Step 4: Commit the timer controller**
-
-```bash
-git add Sources/ClamshellCore Tests/ClamshellCoreTests
-git commit -m "feat(timer): schedule verified automatic disable"
-```
-
-### Task 12: Connect timed enablement to the CLI
-
-**Files:**
-
-- Modify: `Sources/ClamshellCLI/Commands/EnableCommand.swift`
-- Modify: `Sources/ClamshellCLI/Commands/DisableCommand.swift`
-- Create: `Sources/ClamshellCLI/Commands/TimerCheckCommand.swift`
-- Modify: `Sources/ClamshellCLI/Commands/StatusCommand.swift`
-- Modify: `Sources/ClamshellCLI/ClamshellCommand.swift`
-- Create: `Tests/ClamshellCLITests/TimedCommandTests.swift`
-
-- [ ] **Step 1: Add command-level timer tests**
-
-Prove that `enable --for 2h` enables first and then installs the timer; timer-install failure rolls the new enablement back to disabled; a successful manual disable cancels the timer; status displays an active deadline; and `timer-check` stays hidden from help.
-
-- [ ] **Step 2: Implement the command flow**
-
-Add optional `--for ` to enable. Persist timer metadata only after verified enablement. If scheduling fails after a state change, request verified disable and report both failures if rollback also fails. Permanent `enable` cancels an old timer only after the user confirms intent through the explicit command invocation.
-
-- [ ] **Step 3: Verify**
-
-Run: `swift test && swift run clamshellctl enable --help`
-
-Expected: all tests pass and help documents the strict duration grammar and 30-day maximum.
-
-- [ ] **Step 4: Commit timed commands**
-
-```bash
-git add Sources/ClamshellCLI Tests/ClamshellCLITests
-git commit -m "feat(cli): support temporary clamshell enablement"
-```
-
-## Phase 6: Native Control Centre companion
-
-### Task 13: Generate the companion project and app shell
-
-**Files:**
-
-- Create: `project.yml`
-- Create: `App/ClamshellApp/ClamshellApp.swift`
-- Create: `App/ClamshellApp/SetupView.swift`
-- Create: `App/ClamshellApp/Info.plist`
-- Create: `App/ClamshellControl/ClamshellControlBundle.swift`
-- Create: `App/ClamshellControl/BatteryClamshellControl.swift`
-- Create: `App/ClamshellControl/SetBatteryClamshellIntent.swift`
-- Create: `App/ClamshellControl/Info.plist`
-- Modify: `.gitignore`
-
-- [ ] **Step 1: Add the deterministic Xcode project definition**
-
-Install XcodeGen with `brew install xcodegen`. Define one macOS 26 application, one embedded WidgetKit extension, and one unit-test target in `project.yml`. Link both production targets to the local `ClamshellCore` package product. Use bundle identifiers `uk.co.lmliam.clamshell` and `uk.co.lmliam.clamshell.control`, set `LSUIElement` to `true`, and use manual ad-hoc signing with no development team.
-
-```yaml
-name: Clamshell
-options:
- deploymentTarget:
- macOS: "26.0"
-packages:
- ClamshellKit:
- path: .
-targets:
- ClamshellApp:
- type: application
- platform: macOS
- sources: [App/ClamshellApp]
- info:
- path: App/ClamshellApp/Info.plist
- properties:
- CFBundleDisplayName: Clamshell
- LSUIElement: true
- dependencies:
- - package: ClamshellKit
- product: ClamshellCore
- - target: ClamshellControl
- embed: true
- settings:
- base:
- PRODUCT_BUNDLE_IDENTIFIER: uk.co.lmliam.clamshell
- CODE_SIGN_STYLE: Manual
- CODE_SIGN_IDENTITY: "-"
- DEVELOPMENT_TEAM: ""
- ARCHS: "arm64 x86_64"
- ONLY_ACTIVE_ARCH: NO
- MARKETING_VERSION: 0.1.0
- CURRENT_PROJECT_VERSION: 1
- ClamshellControl:
- type: app-extension
- platform: macOS
- sources: [App/ClamshellControl]
- info:
- path: App/ClamshellControl/Info.plist
- properties:
- NSExtension:
- NSExtensionPointIdentifier: com.apple.widgetkit-extension
- dependencies:
- - package: ClamshellKit
- product: ClamshellCore
- settings:
- base:
- PRODUCT_BUNDLE_IDENTIFIER: uk.co.lmliam.clamshell.control
- CODE_SIGN_STYLE: Manual
- CODE_SIGN_IDENTITY: "-"
- DEVELOPMENT_TEAM: ""
- ARCHS: "arm64 x86_64"
- ONLY_ACTIVE_ARCH: NO
- MARKETING_VERSION: 0.1.0
- CURRENT_PROJECT_VERSION: 1
- ClamshellAppTests:
- type: bundle.unit-test
- platform: macOS
- sources: [App/ClamshellAppTests]
- dependencies:
- - target: ClamshellApp
- ClamshellControlTests:
- type: bundle.unit-test
- platform: macOS
- sources: [App/ClamshellControlTests]
- dependencies:
- - target: ClamshellControl
-```
-
-- [ ] **Step 2: Add the smallest compiling app and control**
-
-Create an `@main` SwiftUI app with a single setup window. Create a WidgetKit `WidgetBundle` containing `BatteryClamshellControl`. Initially return `false` from the value provider and make the intent return `.result()` without mutation; this step proves project composition only.
-
-```swift
-import SwiftUI
-
-@main
-struct ClamshellApp: App {
- var body: some Scene {
- Window("Clamshell", id: "setup") {
- SetupView()
- }
- }
-}
-
-struct SetupView: View {
- var body: some View {
- Text("Clamshell setup")
- .padding()
- }
-}
-```
-
-```swift
-import AppIntents
-import SwiftUI
-import WidgetKit
-
-@main
-struct ClamshellControlBundle: WidgetBundle {
- var body: some Widget {
- BatteryClamshellControl()
- }
-}
-
-struct BatteryClamshellControl: ControlWidget {
- static let kind = "uk.co.lmliam.clamshell.control.battery"
-
- var body: some ControlWidgetConfiguration {
- StaticControlConfiguration(kind: Self.kind, provider: Provider()) {
- isEnabled in
- ControlWidgetToggle(
- "Battery Clamshell Mode",
- isOn: isEnabled,
- action: SetBatteryClamshellIntent()
- )
- }
- }
-
- struct Provider: ControlValueProvider {
- let previewValue = false
-
- func currentValue() async throws -> Bool {
- false
- }
- }
-}
-
-struct SetBatteryClamshellIntent: SetValueIntent {
- static let title: LocalizedStringResource = "Set battery clamshell mode"
-
- @Parameter(title: "Enabled")
- var value: Bool
-
- func perform() async throws -> some IntentResult {
- .result()
- }
-}
-```
-
-- [ ] **Step 3: Generate and build the app**
-
-Run:
-
-```bash
-xcodegen generate
-xcodebuild \
- -project Clamshell.xcodeproj \
- -scheme ClamshellApp \
- -configuration Debug \
- -destination 'platform=macOS' \
- CODE_SIGN_IDENTITY=- \
- DEVELOPMENT_TEAM= \
- build
-```
-
-Expected: XcodeGen produces the project and Xcode builds an ad-hoc-signed app containing `ClamshellControl.appex`. Add `Clamshell.xcodeproj/` to `.gitignore`; `project.yml` is the source of truth.
-
-- [ ] **Step 4: Commit the project shell**
-
-```bash
-git add .gitignore project.yml App/ClamshellApp App/ClamshellControl
-git commit -m "build(app): add generated companion project"
-```
-
-### Task 14: Prove the unsigned Control Centre boundary
-
-**Files:**
-
-- Create: `App/ClamshellControl/ControlModel.swift`
-- Create: `App/ClamshellControlTests/ControlModelTests.swift`
-- Modify: `App/ClamshellControl/BatteryClamshellControl.swift`
-- Modify: `App/ClamshellControl/SetBatteryClamshellIntent.swift`
-- Create: `docs/control-feasibility-test.md`
-
-- [ ] **Step 1: Test exact-state control requests**
-
-Use fakes to prove that requesting the current state performs no mutation, requesting a different state invokes exactly one helper operation, helper failure propagates, and the final state is verified before success.
-
-```swift
-import Testing
-@testable import ClamshellControl
-import ClamshellCore
-
-private final class RecordingPower: @unchecked Sendable,
- PowerStateReading, PowerStateWriting {
- var states: [ClamshellState]
- var requestedStates: [ClamshellState] = []
-
- init(states: [ClamshellState]) {
- self.states = states
- }
-
- func currentState() throws -> ClamshellState {
- states.removeFirst()
- }
-
- func setState(_ state: ClamshellState) throws {
- requestedStates.append(state)
- }
-}
-
-@Test("enables through the helper and verifies the result")
-func enablesAndVerifies() throws {
- let power = RecordingPower(states: [.disabled, .enabled])
- let model = ControlModel(stateReader: power, stateWriter: power)
-
- try model.setValue(true)
-
- #expect(power.requestedStates == [.enabled])
- #expect(power.states.isEmpty)
-}
-```
-
-- [ ] **Step 2: Run the test and confirm the missing service**
-
-Run: `xcodebuild -project Clamshell.xcodeproj -scheme ClamshellControlTests -destination 'platform=macOS' test`
-
-Expected: compilation fails because `ControlModel` does not exist.
-
-- [ ] **Step 3: Implement the control model by composing the shared service**
-
-Define `ControlModel` with injected `PowerStateReading` and `PowerStateWriting` dependencies. `currentValue()` maps `.enabled` to `true`; `setValue(_:)` maps the Boolean to `ClamshellState` and delegates to Task 5's `ClamshellService`. Production composition uses `PowerSettingsClient` for reads and `PrivilegedHelperClient` for writes, so the control reuses the same idempotency, exact sudo invocation, and final verification as the CLI.
-
-- [ ] **Step 4: Connect the WidgetKit value and intent**
-
-The provider calls `ControlModel.live.currentValue()`. The `SetValueIntent` calls `ControlModel.live.setValue(_:)`, declares background support, requests the value supplied by WidgetKit, and reloads the control only after verified success.
-
-```swift
-struct SetBatteryClamshellIntent: SetValueIntent {
- static let title: LocalizedStringResource = "Set battery clamshell mode"
- static var supportedModes: IntentModes { .background }
-
- @Parameter(title: "Enabled")
- var value: Bool
-
- func perform() async throws -> some IntentResult {
- try ControlModel.live.setValue(value)
- ControlCenter.shared.reloadControls(
- ofKind: BatteryClamshellControl.kind
- )
- return .result()
- }
-}
-```
-
-- [ ] **Step 5: Run the mandatory clean-account feasibility test**
-
-Build Release with `CODE_SIGN_IDENTITY=-`, copy `Clamshell.app` to `/Applications`, trigger Gatekeeper quarantine with a locally created DMG, approve the app through Privacy & Security, and add its control to Control Centre. Verify inactive state, enable, active state, disable, state changes made through the CLI, and behaviour after replacing the app with a second ad-hoc-signed build.
-
-Record the exact build, installation, `codesign`, `spctl`, helper, and `pmset` observations in `docs/control-feasibility-test.md`. Do not record account identifiers or passwords. If extension discovery, read-only `pmset`, helper invocation, state reconciliation, or update persistence fails, stop companion work and leave DMG publication disabled; the CLI plan continues independently.
-
-- [ ] **Step 6: Verify and commit the proven boundary**
-
-Run:
-
-```bash
-xcodebuild -project Clamshell.xcodeproj -scheme ClamshellControlTests -configuration Release -destination 'platform=macOS' CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= test
-xcodebuild -project Clamshell.xcodeproj -scheme ClamshellApp -configuration Release -destination 'platform=macOS' CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= build
-```
-
-Expected: automated tests pass and the feasibility record contains a pass for every mandatory observation.
-
-```bash
-git add App/ClamshellControl App/ClamshellControlTests project.yml docs/control-feasibility-test.md
-git commit -m "feat(control): add verified Control Centre toggle"
-```
-
-### Task 15: Add companion setup and optional CLI exposure
-
-**Files:**
-
-- Create: `App/ClamshellApp/SetupModel.swift`
-- Create: `App/ClamshellAppTests/SetupModelTests.swift`
-- Modify: `App/ClamshellApp/SetupView.swift`
-- Modify: `Sources/ClamshellCore/PrivilegedInstallation.swift`
-- Modify: `Sources/ClamshellCLI/Commands/SetupCommand.swift`
-- Modify: `Sources/ClamshellCLI/Commands/UninstallCommand.swift`
-
-- [ ] **Step 1: Test setup presentation states**
-
-Test `.needsSetup`, `.ready`, `.invalidHelper`, and `.missingBundlePayload`. Prove that setup requests administrator authorisation once, CLI exposure creates only `/usr/local/bin/clamshellctl`, removal deletes the symlink only when it resolves inside `Clamshell.app`, and diagnostic refresh never mutates system state.
-
-```swift
-@Test("does not remove an unrelated command")
-func preservesUnrelatedCommand() async throws {
- let fixture = SetupModelFixture(cliLinkTarget: "/tmp/not-clamshellctl")
-
- try await fixture.model.removePrivilegedSetup()
-
- #expect(fixture.files.removedPaths.contains("/usr/local/bin/clamshellctl") == false)
-}
-```
-
-- [ ] **Step 2: Run the tests and confirm missing setup types**
-
-Run:
-
-```bash
-xcodegen generate
-xcodebuild -project Clamshell.xcodeproj -scheme ClamshellApp -destination 'platform=macOS' test
-```
-
-Expected: compilation fails because `SetupModel` and its injected boundaries do not exist.
-
-- [ ] **Step 3: Implement the setup model and view**
-
-`SetupModel` owns display state only and depends on protocols for diagnostics, administrator-authorised setup, CLI-link installation, and removal. `SetupView` shows the current helper status, an explicit **Set Up** button, an opt-in **Install terminal command** toggle, Control Centre placement instructions, and **Remove privileged components**. It never displays or stores a password.
-
-First-run setup invokes the app-bundled `clamshellctl setup` through macOS's administrator-authorisation dialog. Resolve the app bundle before authorisation, reject a bundle outside `/Applications`, construct the command only from allowlisted arguments and shell-quoted absolute paths, and reuse Task 9's setup validation rather than duplicating file-writing rules. Never interpolate free-form user input into the authorised command.
-
-- [ ] **Step 4: Verify setup and removal manually**
-
-On a test account, confirm one authorisation prompt, exact helper ownership and modes, valid sudoers syntax, optional CLI symlink target, correct diagnostics after relaunch, safe repeated setup, safe repeated removal, and preservation of an unrelated `/usr/local/bin/clamshellctl` fixture.
-
-- [ ] **Step 5: Commit companion setup**
-
-```bash
-git add App/ClamshellApp App/ClamshellAppTests Sources/ClamshellCore/PrivilegedInstallation.swift Sources/ClamshellCLI/Commands/SetupCommand.swift Sources/ClamshellCLI/Commands/UninstallCommand.swift
-git commit -m "feat(app): add privileged setup experience"
-```
-
-### Task 16: Bundle command products and package the DMG
-
-**Files:**
-
-- Create: `scripts/embed-command-products.sh`
-- Create: `scripts/package-dmg.sh`
-- Create: `Tests/Scripts/run-dmg-packaging-tests.sh`
-- Modify: `project.yml`
-
-- [ ] **Step 1: Test packaging input validation**
-
-The shell test supplies temporary fixture app bundles and proves rejection of a malformed version, missing CLI, missing helper, missing extension, non-ad-hoc nested signature, and an output path outside the supplied directory. It also proves that a valid fixture creates exactly `clamshellctl-v1.2.3.dmg` containing `Clamshell.app` and an `/Applications` symlink.
-
-- [ ] **Step 2: Run the test and confirm the missing scripts**
-
-Run: `bash Tests/Scripts/run-dmg-packaging-tests.sh`
-
-Expected: failure because `scripts/package-dmg.sh` does not exist.
-
-- [ ] **Step 3: Implement deterministic embedding and packaging**
-
-`embed-command-products.sh` copies the universal release `clamshellctl` to `Contents/MacOS/clamshellctl` and the universal helper payload to `Contents/Resources/clamshellctl-helper`, then verifies both are executable and contain `arm64` and `x86_64` slices with `lipo -verify_arch`. `package-dmg.sh` accepts only `VERSION` and `OUTPUT_DIRECTORY`, builds SwiftPM release products for both architectures, generates the Xcode project, builds Release with `ARCHS='arm64 x86_64'`, `ONLY_ACTIVE_ARCH=NO`, and ad-hoc signing, verifies nested code with `codesign --verify --deep --strict`, stages only the app and `/Applications` symlink, creates a compressed read-only DMG with `hdiutil`, remounts it read-only, and verifies its contents before returning success.
-
-- [ ] **Step 4: Verify the real artefact**
-
-Run:
-
-```bash
-bash Tests/Scripts/run-dmg-packaging-tests.sh
-bash scripts/package-dmg.sh 0.1.0 .build/releases
-hdiutil verify .build/releases/clamshellctl-v0.1.0.dmg
-spctl --assess --type open --context context:primary-signature .build/releases/clamshellctl-v0.1.0.dmg || true
-```
-
-Expected: script tests and `hdiutil` pass. `spctl` rejects the intentionally unnotarised download artefact, matching the documented Gatekeeper flow.
-
-- [ ] **Step 5: Produce and verify the checksum**
-
-Write `clamshellctl-v0.1.0.dmg.sha256` using `shasum -a 256`, verify it with `shasum -a 256 -c`, and assert the checksum file contains only the artefact basename rather than a local absolute path.
-
-- [ ] **Step 6: Commit DMG packaging**
-
-```bash
-git add project.yml scripts/embed-command-products.sh scripts/package-dmg.sh Tests/Scripts/run-dmg-packaging-tests.sh
-git commit -m "build(dmg): package self-contained companion"
-```
-
-## Phase 7: Public documentation
-
-### Task 17: Complete user and contributor documentation
-
-**Files:**
-
-- Modify: `README.md`
-- Create: `.github/CONTRIBUTING.md`
-- Create: `.github/SECURITY.md`
-- Create: `.github/SUPPORT.md`
-- Create: `.markdownlint-cli2.jsonc`
-
-- [ ] **Step 1: Write the complete README**
-
-Cover purpose, warning and scope, separate Homebrew and DMG installation paths, macOS 13 CLI and macOS 26 companion requirements, explicit privileged setup, commands, duration grammar, Gatekeeper `Open Anyway`, Control Centre placement, active and inactive appearance, optional terminal command, troubleshooting, complete removal, security design, development, licence, and acknowledgements. State that the tool changes only the Battery Power `disablesleep` value and that the DMG is not notarised.
-
-- [ ] **Step 2: Write maintenance policies**
-
-`CONTRIBUTING.md` requires Swift 6, `scripts/check.sh`, Conventional Commits, and `verb(area): description`. `SECURITY.md` explains the helper and sudoers boundary and provides private reporting instructions. `SUPPORT.md` lists safe diagnostic commands and forbids posting sudoers contents containing unexpected local customisations without review. Configure markdownlint with `MD013` disabled so prose uses semantic lines without an arbitrary rendered-width limit; keep all other default rules enabled.
-
-- [ ] **Step 3: Check links and prose**
-
-Run: `scripts/check.sh && npx --yes markdownlint-cli2 '**/*.md' '#.build'`
-
-Expected: no Markdown errors. Manually verify every relative link and ensure no internal tooling paths or process notes exist anywhere in the repository.
-
-- [ ] **Step 4: Commit documentation**
-
-```bash
-git add .markdownlint-cli2.jsonc README.md .github docs
-git commit -m "docs(project): document installation and maintenance"
-```
-
-## Phase 8: CI and automated releases
-
-### Task 18: Add build, test, format, and PR-title checks
-
-**Files:**
-
-- Create: `.github/workflows/ci.yml`
-- Create: `.github/workflows/pr.yml`
-- Create: `scripts/check-conventional-subject.sh`
-
-- [ ] **Step 1: Test the title validator**
-
-Accept examples such as `feat(cli): add toggle command`, `fix(timer): handle missed deadline`, and `docs(readme): clarify setup`. Reject missing scopes, uppercase verbs, empty descriptions, trailing full stops, and merge prefixes.
-
-- [ ] **Step 2: Implement the validator**
-
-Use a portable anchored regular expression for the allowed Conventional Commit verbs and lowercase kebab-case areas. The script accepts one title argument and prints one actionable error with valid examples.
-
-- [ ] **Step 3: Add least-privilege workflows**
-
-`ci.yml` runs on pushes to `main` and pull requests, uses macOS runners, checks out pinned action SHAs, and runs formatting, SwiftLint, tests, and debug and release builds. Add XcodeGen and an unsigned `xcodebuild` when the native targets exist. It grants `contents: read` only.
-
-`pr.yml` validates the pull-request title and commit subjects. It checks out the
-trusted base and pull-request head. It executes only the trusted validator and
-grants `contents: read` only.
-
-- [ ] **Step 4: Verify locally and with actionlint**
-
-Run:
-
-```bash
-scripts/check-conventional-subject.sh "feat(cli): add toggle command"
-! scripts/check-conventional-subject.sh "feat: add toggle command"
-scripts/check.sh
-```
-
-Install `actionlint` with `brew install actionlint` first when it is not already available. Expected: all local checks pass.
-
-- [ ] **Step 5: Commit CI**
-
-```bash
-git add .github/workflows scripts
-git commit -m "ci(checks): verify Swift and pull-request quality"
-```
-
-### Task 19: Configure release-please
-
-**Files:**
-
-- Create: `release-please-config.json`
-- Create: `.release-please-manifest.json`
-- Create: `CHANGELOG.md`
-- Create: `.github/workflows/release.yml`
-- Modify: `Sources/ClamshellCore/BuildVersion.swift`
-
-- [ ] **Step 1: Add manifest configuration**
-
-Use `release-type: simple`, root package `.`, `include-v-in-tag: true`, `include-component-in-tag: false`, `bump-minor-pre-major: true`, and `bump-patch-for-minor-pre-major: false`. Configure generic updaters for `Sources/ClamshellCore/BuildVersion.swift` and both `MARKETING_VERSION` entries in `project.yml`. Use the agreed visible changelog sections and hide tests and routine chores.
-
-Initial manifest content is `{}`. Set `initial-version` to `0.1.0`. This value
-makes the first release `0.1.0`. After the first release, release-please uses
-Conventional Commits to select the next version. `version.txt` starts at
-`0.1.0` and must remain identical to `BuildVersion.current` and the two app
-`MARKETING_VERSION` values.
-
-- [ ] **Step 2: Add configuration validation**
-
-Run release-please in dry-run mode against the local configuration and assert the schema accepts every property. Add a test script that extracts `version.txt`, the Swift version literal, both app marketing-version values, and the manifest version when applicable and reports mismatches.
-
-- [ ] **Step 3: Add the release job**
-
-Mirror Kotventure's least-privilege pattern: top-level `permissions: {}`, job-scoped `contents: write`, `issues: write`, and `pull-requests: write`, ten-minute timeout, concurrency by workflow and ref, and release-please v5 pinned to the currently approved commit SHA. Use `${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}`.
-
-Expose `release_created`, `tag_name`, and `version`. When `release_created` is true, an output-gated macOS job installs XcodeGen, checks out the exact tag, runs `scripts/package-dmg.sh`, verifies the DMG and checksum, and uploads both to the existing GitHub Release. Grant only `contents: write`; do not configure signing secrets or claim notarisation.
-
-- [ ] **Step 4: Verify and commit**
-
-Run: `actionlint && bash scripts/check-version-consistency.sh`
-
-Expected: both checks pass.
-
-```bash
-git add .github/workflows/release.yml release-please-config.json .release-please-manifest.json CHANGELOG.md version.txt project.yml Sources/ClamshellCore/BuildVersion.swift scripts/check-version-consistency.sh
-git commit -m "ci(release): automate version pull requests"
-```
-
-## Phase 9: Homebrew publication
-
-### Task 20: Generate and test the Homebrew formula
-
-**Files:**
-
-- Create: `scripts/generate-homebrew-formula.sh`
-- Create: `Tests/Scripts/run-formula-generator-tests.sh`
-- Create: `Formula/clamshellctl.rb`
-
-- [ ] **Step 1: Test deterministic formula generation**
-
-Given repository, tag, source SHA-256, and version inputs, assert exact Ruby output. Reject tags not matching `vMAJOR.MINOR.PATCH` and checksums not matching 64 lowercase hexadecimal characters.
-
-- [ ] **Step 2: Implement the generator**
-
-The generated formula is macOS-only, depends on a Swift-capable Xcode Command Line Tools environment at build time, builds both release products with SwiftPM, installs `clamshellctl` to `bin`, installs `clamshellctl-helper` to `libexec`, prints the explicit setup caveat, and tests only `--version`.
-
-- [ ] **Step 3: Test a local formula build**
-
-Run:
-
-```bash
-bash Tests/Scripts/run-formula-generator-tests.sh
-brew install --build-from-source ./Formula/clamshellctl.rb
-"$(brew --prefix)/bin/clamshellctl" --version
-brew uninstall clamshellctl
-```
-
-Expected: the formula builds from source, reports `0.1.0`, makes no privileged changes, and uninstalls cleanly.
-
-- [ ] **Step 4: Commit formula support**
-
-```bash
-git add Formula scripts/generate-homebrew-formula.sh Tests/Scripts
-git commit -m "feat(homebrew): add source-build formula"
-```
-
-### Task 21: Publish formula updates after releases
-
-**Files:**
-
-- Modify: `.github/workflows/release.yml`
-- Create: `scripts/publish-homebrew-formula.sh`
-
-- [ ] **Step 1: Add an output-gated publication job**
-
-Expose release-please's `release_created`, `tag_name`, and `version` outputs. Run the Homebrew job only when `release_created` is true. Checkout the exact tag, calculate the GitHub source archive checksum, generate the formula, validate its syntax and audit rules, and update `Formula/clamshellctl.rb` in `LMLiam/homebrew-tap`.
-
-- [ ] **Step 2: Keep cross-repository credentials narrow**
-
-Use only `TAP_GITHUB_TOKEN` for cloning and pushing the tap. Do not print or persist it. Commit to the tap as `LMLiam` with message `brew(clamshellctl): update to vX.Y.Z`, matching the scoped convention. Exit successfully without a commit when the formula is already current.
-
-- [ ] **Step 3: Test publication against temporary repositories**
-
-The script test creates local bare source and tap repositories, supplies a fixture tag and checksum, and proves first-run commit, idempotent second run, and rejection of an unexpected remote or formula path. It must not access GitHub.
-
-- [ ] **Step 4: Verify and commit**
-
-Run: `bash Tests/Scripts/run-publish-formula-tests.sh && actionlint`
-
-Expected: publication fixtures and workflow lint pass.
-
-```bash
-git add .github/workflows/release.yml scripts/publish-homebrew-formula.sh Tests/Scripts
-git commit -m "ci(homebrew): publish released formula to tap"
-```
-
-## Phase 10: End-to-end verification and launch
-
-### Task 22: Run the privileged acceptance matrix
-
-**Files:**
-
-- Create: `docs/acceptance-test.md`
-
-- [ ] **Step 1: Capture the starting state**
-
-Record `pmset -g custom`, current power source, CLI version, helper ownership, and sudoers validation. Do not include machine serial numbers or unrelated system data.
-
-- [ ] **Step 2: Install through the local formula and set up once**
-
-Run the formula build, then the explicit setup command. Verify `root:wheel` ownership, modes `0755` and `0440`, `visudo -cf`, and that sudoers exposes only exact helper enable and disable commands.
-
-- [ ] **Step 3: Exercise behaviour on battery and AC**
-
-Verify status, enable, repeated enable, toggle, disable, repeated disable, quiet output, malformed duration rejection, timer replacement, automatic disable, missed-deadline recovery after sleep, Control Centre enable and disable, active and inactive rendering, and state reconciliation after CLI changes. Confirm the AC Power section remains byte-for-byte unchanged across every mutation.
-
-- [ ] **Step 4: Verify removal and recovery**
-
-Run uninstall twice, confirm both managed privileged files are absent, confirm state commands provide setup guidance, reinstall, and restore battery clamshell mode to disabled at the end.
-
-- [ ] **Step 5: Record results and commit the checklist**
-
-Document commands and pass/fail outcomes without local secrets.
-
-```bash
-git add docs/acceptance-test.md
-git commit -m "test(acceptance): verify macOS clamshell lifecycle"
-```
-
-### Task 23: Harden repository settings and perform the first release
-
-**Files:**
-
-- Modify: `README.md`
-- Modify: `LMLiam/LMLiam` profile README in its own checkout and commit
-
-- [ ] **Step 1: Configure repository settings**
-
-Enable issues, vulnerability reporting, default squash merging, automatic head-branch deletion, Actions permission to create pull requests, and `main` protection requiring CI and PR-title checks. Add `RELEASE_PLEASE_TOKEN` and `TAP_GITHUB_TOKEN` as repository secrets without exposing their values.
-
-- [ ] **Step 2: Verify every issue and check**
-
-Ensure each implementation issue contains its final approved plan, is closed by its implementation pull request, and has no unresolved acceptance criteria. Confirm the full CI suite passes on `main` and the working tree is clean.
-
-- [ ] **Step 3: Merge the first release-please PR**
-
-Expected results: tag `v0.1.0`, GitHub Release `v0.1.0`, updated `CHANGELOG.md`, and `Formula/clamshellctl.rb` committed to `LMLiam/homebrew-tap`.
-
-- [ ] **Step 4: Test the public installation from scratch**
-
-Run:
-
-```bash
-brew uninstall clamshellctl 2>/dev/null || true
-brew untap LMLiam/tap 2>/dev/null || true
-brew install LMLiam/tap/clamshellctl
-"$(brew --prefix)/bin/clamshellctl" --version
-```
-
-Expected: Homebrew installs `0.1.0` from the public release. Perform setup, one enable/disable cycle, uninstall privileged components, and leave the machine disabled.
-
-- [ ] **Step 5: Test the public DMG from scratch**
-
-Download the release DMG and checksum through the public GitHub Release URL, verify `shasum -a 256 -c`, mount it, drag `Clamshell.app` to `/Applications`, and follow only the published Gatekeeper instructions. Complete setup, add the toggle to Control Centre, verify one enable and disable cycle plus active and inactive appearance, expose the optional terminal command, remove privileged components, and delete the app. Confirm no helper, sudoers rule, or CLI symlink remains.
-
-- [ ] **Step 6: Update the GitHub profile repository**
-
-Add `clamshellctl` to `LMLiam/LMLiam` using the released repository URL, a one-sentence description, and the project logo or existing profile-card style. Validate Markdown rendering, commit as `docs(profile): feature clamshellctl`, and push the profile repository separately.
-
-- [ ] **Step 7: Mark the MVP complete**
-
-Close the launch milestone only after the GitHub Release, public DMG, Homebrew installation, Control Centre companion, profile link, and acceptance record are all verified. Create a separate backlog issue for USB-C disconnect automation; do not fold it into v1.
-
-## Final verification gate
-
-Before declaring the MVP complete, run:
-
-```bash
-scripts/check.sh
-bash scripts/check-version-consistency.sh
-while IFS= read -r subject; do
- scripts/check-conventional-subject.sh "$subject"
-done < <(git log --format=%s origin/main..HEAD)
-bash Tests/Scripts/run-dmg-packaging-tests.sh
-bash Tests/Scripts/run-formula-generator-tests.sh
-bash Tests/Scripts/run-publish-formula-tests.sh
-git diff --check
-git status --short
-```
-
-Expected: every command exits zero and `git status --short` prints nothing. Then repeat both public installation smoke tests and confirm `pmset -g custom` shows battery clamshell mode disabled, the AC Power section is unchanged, and DMG removal leaves no helper, sudoers rule, or CLI symlink.
diff --git a/docs/repository-quality-design.md b/docs/repository-quality-design.md
deleted file mode 100644
index 205347c..0000000
--- a/docs/repository-quality-design.md
+++ /dev/null
@@ -1,306 +0,0 @@
-# Repository Quality Design
-
-## Summary
-
-This document defines the quality standard for `clamshellctl`. It covers Swift
-style enforcement, source organisation, continuous integration, repository
-governance, security scanning, and the GitGuardian test-fixture finding.
-
-The goal is an idiomatic and maintainable Swift project whose local checks and
-GitHub checks produce the same result. The work must preserve the existing CLI
-behaviour and privileged-operation boundaries.
-
-## Style enforcement
-
-The project follows the [Google Swift Style Guide][google-swift-style]. Two
-tools enforce separate parts of that standard:
-
-- `swift-format` owns source formatting. Its checked-in `.swift-format`
- configuration uses two-space indentation, a 100-column line limit,
- deterministic imports, and the applicable Google conventions.
-- [SwiftLint][swiftlint] owns maintainability and correctness rules that
- formatting cannot express. Its checked-in `.swiftlint.yml` covers naming,
- API hygiene, unsafe constructs, complexity, and selected opt-in rules.
-
-SwiftLint is a standalone development tool. It is not a production package
-dependency. Local verification requires version 0.65.0. The GitHub
-`macos-26` runner also includes this version. This configuration keeps lint
-tools out of production builds and CodeQL builds. Local checks and CI use the
-same rules.
-
-SwiftLint configuration must be deliberate. The project does not enable every
-opt-in rule, duplicate formatting rules owned by `swift-format`, or accept a
-baseline of existing violations. A suppression must be limited to the smallest
-declaration or line and include a reason when the code does not make the reason
-obvious.
-
-`.editorconfig` defines editor-neutral properties for Swift and repository
-files: UTF-8, LF endings, a final newline, trailing-whitespace handling, and
-two-space indentation where appropriate. It complements the Swift tools; it is
-not the source of truth for Swift syntax formatting.
-
-## Documentation style
-
-All documentation uses ASD-STE100 Simplified Technical English and British
-English spelling. Sentences are short and use active voice. Each instruction
-contains one action. Each item or action has one term.
-
-Technical accuracy has priority over the controlled vocabulary. Product names,
-commands, code identifiers, API names, quoted interface text, and standard
-names can use their required terms.
-
-## Source organisation
-
-Directories describe responsibilities rather than current consumers. The core
-module uses these domains:
-
-```text
-Sources/ClamshellCore/
-├── Errors/
-├── Power/
-├── Privilege/
-│ └── Installation/
-├── Process/
-├── State/
-└── Timing/
-```
-
-- `State` owns the clamshell state and state-transition decisions.
-- `Power` owns `pmset` parsing and the read or mutation boundary.
-- `Process` owns process invocation values, protocols, and Foundation adapters.
-- `Privilege` owns the helper client and the exact sudoers policy.
-- `Privilege/Installation` owns privileged installation and removal workflows,
- their filesystem boundary, and operation results.
-- `Timing` owns duration parsing, timer metadata, and timer lifecycle rules.
-- `Errors` owns domain errors and their stable presentation or exit semantics.
-
-CLI subcommands remain in `Sources/ClamshellCLI/Commands`. Command composition,
-console output, and entry-point concerns use focused files outside that folder.
-The helper remains a thin executable target. Future app and control-extension
-folders continue to follow the architecture in `clamshellctl-design.md`.
-
-Tests mirror production responsibilities:
-
-```text
-Tests/ClamshellCoreTests/
-├── Power/
-├── Privilege/
-│ └── Installation/
-├── Process/
-├── State/
-├── Support/
-└── Timing/
-```
-
-Reusable test infrastructure belongs in `Support`. A fake used by one suite
-remains private in that suite's file.
-
-## File and declaration boundaries
-
-A production file has one primary responsibility, usually represented by one
-primary top-level type. This is a design rule rather than a declaration-count
-rule. Closely related declarations may share a file when separating them would
-make the code harder to understand. Examples include:
-
-- A protocol beside its sole production implementation.
-- A small result value beside the operation that returns it.
-- A private helper used only by the file's primary type.
-- A test suite and its suite-specific private fakes.
-
-Independent public types, reusable protocols, and unrelated operation results
-use separate files. Existing mixed-responsibility files are split where they
-cross these boundaries. In particular, privileged installation workflow,
-filesystem access, Foundation filesystem adaptation, and installation results
-must not remain bundled as unrelated declarations in one large file.
-
-The project does not add a brittle script that counts top-level declarations.
-SwiftLint complexity limits and code review enforce the intent while preserving
-the useful exceptions in the Google guide.
-
-## Idiomatic Swift boundaries
-
-`ClamshellCore` stays independent of argument parsing, SwiftUI, WidgetKit, and
-App Intents. Domain decisions use values and narrow protocols. Foundation types
-adapt operating-system behaviour at the edge.
-
-System operations remain injectable. Tests must not invoke real `pmset`,
-`sudo`, filesystem ownership changes, or `launchd`. Generic dumping grounds
-such as `Utils`, `Common`, or `Models` are prohibited.
-
-Access control is as narrow as target boundaries permit. Declarations exposed
-to the future app package product are public. Other declarations remain
-internal or private. Documentation is added when it records contracts,
-constraints, or failure behaviour that the declaration does not make clear;
-implementation comments explain non-obvious decisions instead of restating
-code.
-
-The refactor does not change command output, exit codes, accepted arguments,
-privileged paths, or the sudoers allow-list unless a separate behaviour change
-is explicitly designed and tested.
-
-## Error handling
-
-Errors remain typed and actionable. Domain errors preserve stable CLI exit-code
-behaviour. Platform adapters include enough underlying context to diagnose a
-failure but do not expose sensitive command output unnecessarily.
-
-The codebase does not introduce forced casts, forced tries, avoidable forced
-unwraps, silent catches, or `fatalError` for recoverable conditions. A process,
-filesystem, parsing, or verification failure propagates through an explicit
-error path and receives behavioural test coverage.
-
-## Testing and local verification
-
-Tests verify externally observable behaviour rather than private implementation
-shape. Coverage includes:
-
-- Valid and malformed battery sections in `pmset` output.
-- Idempotent enable and disable transitions.
-- Exact privileged command allow-listing.
-- Installation ownership, permissions, rollback, and repeatability.
-- Process output capture and failure propagation.
-- CLI exit codes and human-readable output.
-- Regression protection before existing code is moved or rewritten.
-
-The repository exposes one documented local verification entry point. It runs,
-in order:
-
-1. `swift-format` in strict lint mode.
-2. SwiftLint in strict lint mode.
-3. The complete Swift test suite.
-4. Debug and release package builds.
-5. `actionlint` for GitHub Actions workflows.
-6. XcodeGen and the companion-app build once those targets exist.
-
-Each local command stops on failure and preserves the failing tool's output.
-CI runs the same checks in separate jobs to provide parallel feedback. The
-script remains the single local entry point.
-
-## GitHub Actions
-
-All third-party actions are pinned to immutable commit SHAs. Workflows receive
-the minimum required permissions.
-
-### Continuous integration
-
-`ci.yml` runs on pull requests and pushes to `main` using a macOS runner. It
-checks formatting, runs SwiftLint, executes tests, and builds debug and release
-configurations. It also generates and builds the Xcode project after the native
-targets are introduced.
-
-### CodeQL
-
-`codeql.yml` uses [GitHub CodeQL][codeql-compiled] to analyse Swift on pull
-requests, pushes to `main`, a weekly schedule, and manual dispatch. It runs on
-macOS and uses an explicit manual Swift build so the analysed targets are
-deterministic. The workflow enables the `security-extended` query suite and
-publishes results through GitHub code scanning.
-
-### Pull-request titles
-
-`pr.yml` enforces `verb(area): description`. Accepted verbs follow the
-repository's release-please conventions. The same structure is used for local
-commits.
-
-### Automated review
-
-`.coderabbit.yaml` is the version-controlled source of truth for automated
-review. [CodeRabbit][coderabbit-config] uses British English and an assertive,
-high-signal profile. It prioritises correctness, security, maintainability, and
-idiomatic Swift. Review comments must identify a concrete consequence and must
-not request cosmetic churn, redundant comments, or documentation for
-self-explanatory implementation details.
-
-CodeRabbit reviews a pull request when it becomes ready for review. It does not
-review drafts or rerun automatically after each push. Maintainers batch fixes
-and request a manual follow-up review when the result justifies another review.
-Actionable findings use GitHub's request changes workflow.
-
-The walkthrough remains concise and collapsed. It omits poems, fortunes,
-sequence diagrams, effort estimates, suggested labels or reviewers, and prompts
-for automated code generation. Swift, tests, privileged code, workflows, shell
-scripts, and documentation receive focused path-specific instructions.
-
-The docstring coverage and generation features are disabled. The deterministic
-`pr.yml` check owns title syntax, so CodeRabbit does not duplicate it with a
-subjective title check. Description and linked-issue checks remain advisory.
-SwiftLint reads `.swiftlint.yml`, and ShellCheck remains enabled.
-
-### Releases and dependency updates
-
-`release.yml` uses release-please to create release pull requests and GitHub
-releases. Artifact upload remains output-gated until the CLI and DMG packaging
-work exists. Dependabot checks Swift Package Manager and GitHub Actions weekly
-and groups compatible routine updates.
-
-## Repository configuration
-
-The public repository has a clear description and relevant topics. Issues
-remain enabled. Unused wiki and project features are disabled. Merge
-configuration allows squash merges only and deletes merged branches
-automatically.
-
-Repository metadata includes:
-
-- `CODEOWNERS`, assigning the repository to `@LMLiam`.
-- A pull-request template.
-- Structured bug, feature, and security issue forms.
-- Contributing, security, and support policies in GitHub's recognised
- `.github` locations.
-- Consistent type, area, priority, and status labels.
-- An MVP milestone for the existing implementation issues.
-
-Private vulnerability reporting, Dependabot alerts, Dependabot security
-updates, secret scanning, and push protection are enabled where GitHub makes
-them available to the public repository.
-
-## Main branch ruleset
-
-The default-branch ruleset requires:
-
-- A pull request before merging.
-- One approving maintainer and code-owner review.
-- Dismissal of stale approvals after relevant changes.
-- Resolution of every review conversation.
-- Passing CI, CodeQL, pull-request-title, and GitGuardian checks.
-- Linear history.
-
-Force pushes and branch deletion are blocked. Repository administrators retain
-a bypass so Liam can deliberately admin-merge his own pull requests.
-
-The ruleset is enabled only after each required workflow has produced its
-stable check name. This prevents a configuration that requires a check GitHub
-has never observed and therefore cannot satisfy.
-
-## GitGuardian finding
-
-The current GitGuardian detection is a sudoers-policy test fixture containing a
-sample username and an allowed helper command. It is neither a password nor a
-credential.
-
-The individual incident is resolved with GitGuardian's `Skip: false positive`
-action. The implementation must not ignore the test directory, disable the
-detector, add a broad match exclusion, or rewrite Git history. Secret scanning
-and future detections remain fully active.
-
-## Completion criteria
-
-The quality pass is complete when:
-
-1. The source and tests follow the approved responsibility-based layout.
-2. `swift-format` and SwiftLint pass in strict mode with no unexplained
- suppressions.
-3. All tests and debug and release builds pass.
-4. CI and CodeQL complete successfully on the pull request.
-5. The GitGuardian false positive is resolved and its check passes.
-6. Repository metadata, security features, labels, merge settings, and the
- default-branch ruleset match this design.
-7. Existing architecture and implementation documentation matches the final
- layout and workflows.
-8. A final review finds no unresolved correctness, security, or maintainability
- issues.
-
-[codeql-compiled]: https://docs.github.com/en/code-security/concepts/code-scanning/codeql/codeql-for-compiled-languages
-[coderabbit-config]: https://docs.coderabbit.ai/reference/configuration
-[google-swift-style]: https://google.github.io/swift/
-[swiftlint]: https://github.com/realm/SwiftLint
diff --git a/docs/repository-quality-implementation-plan.md b/docs/repository-quality-implementation-plan.md
deleted file mode 100644
index d6b8e33..0000000
--- a/docs/repository-quality-implementation-plan.md
+++ /dev/null
@@ -1,1448 +0,0 @@
-# Repository Quality Implementation Plan
-
-**Goal:** Make `clamshellctl` an idiomatic, consistently enforced Swift project
-with a complete public GitHub repository, CodeQL analysis, release automation,
-and protected-main governance.
-
-**Architecture:** Preserve the current SwiftPM target boundaries and observable
-CLI behaviour. Organise each target by responsibility, keep operating-system
-interactions behind protocols, and give formatting, linting, testing, security,
-and repository policy separate enforceable owners.
-
-**Tech Stack:** Swift 6.3, Swift Package Manager, Swift Testing,
-`swift-format`, SwiftLint 0.65.0, GitHub Actions, CodeQL, GitGuardian,
-release-please v5, Dependabot, `actionlint`, and GitHub rulesets.
-
----
-
-## File map
-
-### Production source
-
-```text
-Sources/ClamshellCore/
-├── BuildVersion.swift
-├── Errors/ClamshellError.swift
-├── Power/
-│ ├── PowerMutation.swift
-│ ├── PowerSettingsClient.swift
-│ └── PowerSettingsParser.swift
-├── Privilege/
-│ ├── PrivilegedHelperClient.swift
-│ ├── PrivilegedPaths.swift
-│ ├── SudoersPolicy.swift
-│ └── Installation/
-│ ├── FoundationInstallationFileSystem.swift
-│ ├── InstallationFileSystem.swift
-│ ├── InstallationResult.swift
-│ ├── PrivilegedInstallation.swift
-│ └── UninstallationResult.swift
-├── Process/
-│ ├── FoundationProcessRunner.swift
-│ ├── ProcessOutputStream.swift
-│ ├── ProcessResult.swift
-│ └── ProcessRunning.swift
-└── State/
- ├── ClamshellService.swift
- ├── ClamshellState.swift
- ├── PowerStateReading.swift
- ├── PowerStateWriting.swift
- └── TransitionResult.swift
-
-Sources/ClamshellCLI/
-├── ClamshellCommand.swift
-├── CommandComposition.swift
-├── Console.swift
-├── OutputOptions.swift
-└── Commands/*.swift
-```
-
-`BuildVersion.swift` stays at the module root because it is package metadata,
-not a domain. No one-file `Versioning` directory is introduced. Issue #4 adds
-the `Timing` production and test directories when timed enablement exists; this
-plan does not commit empty directories.
-
-### Tests
-
-```text
-Tests/ClamshellCoreTests/
-├── BuildVersionTests.swift
-├── Power/*.swift
-├── Privilege/*.swift
-├── Privilege/Installation/*.swift
-├── Process/FoundationProcessRunnerTests.swift
-├── State/ClamshellServiceTests.swift
-└── Support/InstallationTestSupport.swift
-
-Tests/ClamshellCLITests/
-└── Commands/
- ├── SetupCommandTests.swift
- └── StatusCommandTests.swift
-```
-
-### Quality and repository files
-
-```text
-.editorconfig
-.gitattributes
-.swift-format
-.swiftlint.yml
-.github/CODEOWNERS
-.github/CODE_OF_CONDUCT.md
-.github/CONTRIBUTING.md
-.github/SECURITY.md
-.github/SUPPORT.md
-.github/dependabot.yml
-.github/pull_request_template.md
-.github/ISSUE_TEMPLATE/config.yml
-.github/workflows/ci.yml
-.github/workflows/codeql.yml
-.github/workflows/pr.yml
-.github/workflows/release.yml
-.release-please-manifest.json
-CHANGELOG.md
-release-please-config.json
-scripts/check.sh
-scripts/check-conventional-subject.sh
-```
-
-The untracked `.vscode/` directory is user-owned and must remain untouched.
-
-## Task 1: Record the behavioural baseline
-
-**Files:** None.
-
-- [ ] **Step 1: Confirm the protected worktree scope**
-
-Run:
-
-```bash
-git status --short --branch
-```
-
-Expected: branch `feat/mvp`; the only untracked path is `.vscode/`.
-
-- [ ] **Step 2: Run the current formatter check**
-
-Run:
-
-```bash
-swift format lint --recursive --strict Sources Tests Package.swift
-```
-
-Expected: exit 0 before the formatting configuration changes.
-
-- [ ] **Step 3: Run the current tests and builds**
-
-Run:
-
-```bash
-swift test
-swift build
-swift build -c release
-```
-
-Expected: 33 tests in 12 suites pass; both builds exit 0.
-
-## Task 2: Organise source and tests by responsibility
-
-**Files:**
-
-- Move: existing Swift files under `Sources/ClamshellCore/`
-- Move: `Sources/ClamshellCLI/ClamshellCommand.swift`
-- Move: `Sources/ClamshellCLI/Console.swift`
-- Move: existing Swift test files under `Tests/`
-
-- [ ] **Step 1: Create the responsibility directories**
-
-Run:
-
-```bash
-mkdir -p Sources/ClamshellCore/{Errors,Power,Privilege/Installation,Process,State}
-mkdir -p Tests/ClamshellCoreTests/{Power,Privilege/Installation,Process,State,Support}
-mkdir -p Tests/ClamshellCLITests/Commands
-```
-
-- [ ] **Step 2: Move each intact production file**
-
-Run:
-
-```bash
-git mv Sources/ClamshellCore/ClamshellError.swift Sources/ClamshellCore/Errors/
-git mv Sources/ClamshellCore/ClamshellService.swift Sources/ClamshellCore/State/
-git mv Sources/ClamshellCore/ClamshellState.swift Sources/ClamshellCore/State/
-git mv Sources/ClamshellCore/PowerSettingsClient.swift Sources/ClamshellCore/Power/
-git mv Sources/ClamshellCore/PowerSettingsParser.swift Sources/ClamshellCore/Power/
-git mv Sources/ClamshellCore/PrivilegedHelperClient.swift \
- Sources/ClamshellCore/Privilege/
-git mv Sources/ClamshellCore/PrivilegedInstallation.swift \
- Sources/ClamshellCore/Privilege/Installation/
-git mv Sources/ClamshellCore/ProcessRunner.swift Sources/ClamshellCore/Process/
-git mv Sources/ClamshellCore/SudoersPolicy.swift Sources/ClamshellCore/Privilege/
-```
-
-SwiftPM discovers target sources recursively, so `Package.swift` does not need
-path declarations.
-
-- [ ] **Step 3: Move each intact test file**
-
-Run:
-
-```bash
-git mv Tests/ClamshellCoreTests/ClamshellServiceTests.swift Tests/ClamshellCoreTests/State/
-git mv Tests/ClamshellCoreTests/PowerMutationTests.swift Tests/ClamshellCoreTests/Power/
-git mv Tests/ClamshellCoreTests/PowerSettingsClientTests.swift Tests/ClamshellCoreTests/Power/
-git mv Tests/ClamshellCoreTests/PowerSettingsParserTests.swift Tests/ClamshellCoreTests/Power/
-git mv Tests/ClamshellCoreTests/PrivilegedHelperClientTests.swift \
- Tests/ClamshellCoreTests/Privilege/
-git mv Tests/ClamshellCoreTests/PrivilegedInstallationTests.swift \
- Tests/ClamshellCoreTests/Privilege/Installation/
-git mv Tests/ClamshellCoreTests/SudoersPolicyTests.swift Tests/ClamshellCoreTests/Privilege/
-git mv Tests/ClamshellCLITests/SetupCommandTests.swift Tests/ClamshellCLITests/Commands/
-```
-
-- [ ] **Step 4: Verify the moves did not change behaviour**
-
-Run:
-
-```bash
-swift test
-swift build -c release
-```
-
-Expected: all 33 tests pass and the release build exits 0.
-
-- [ ] **Step 5: Commit the mechanical layout change**
-
-Run:
-
-```bash
-git add Sources Tests
-git commit -m "refactor(layout): organise code by responsibility"
-```
-
-## Task 3: Split mixed-responsibility production files
-
-**Files:**
-
-- Modify: `Sources/ClamshellCore/State/ClamshellService.swift`
-- Create: `Sources/ClamshellCore/State/PowerStateReading.swift`
-- Create: `Sources/ClamshellCore/State/PowerStateWriting.swift`
-- Create: `Sources/ClamshellCore/State/TransitionResult.swift`
-- Modify: `Sources/ClamshellCore/Power/PowerSettingsClient.swift`
-- Create: `Sources/ClamshellCore/Power/PowerMutation.swift`
-- Replace: `Sources/ClamshellCore/Process/ProcessRunner.swift`
-- Create: four focused files under `Sources/ClamshellCore/Process/`
-- Modify: `Sources/ClamshellCore/Privilege/SudoersPolicy.swift`
-- Create: `Sources/ClamshellCore/Privilege/PrivilegedPaths.swift`
-- Replace: `Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift`
-- Create: four supporting installation files
-- Modify: `Sources/ClamshellCLI/ClamshellCommand.swift`
-- Create: `Sources/ClamshellCLI/CommandComposition.swift`
-- Modify: `Sources/ClamshellCLI/Console.swift`
-- Create: `Sources/ClamshellCLI/OutputOptions.swift`
-
-- [ ] **Step 1: Separate state contracts and results**
-
-Move the two protocols and result value out of `ClamshellService.swift` without
-changing their names or signatures. The resulting files contain:
-
-```swift
-// PowerStateReading.swift
-public protocol PowerStateReading: Sendable {
- func currentState() throws -> ClamshellState
-}
-
-// PowerStateWriting.swift
-public protocol PowerStateWriting: Sendable {
- func setState(_ state: ClamshellState) throws
-}
-
-// TransitionResult.swift
-public struct TransitionResult: Sendable, Equatable {
- public let previous: ClamshellState
- public let current: ClamshellState
- public let didChange: Bool
-
- public init(previous: ClamshellState, current: ClamshellState, didChange: Bool) {
- self.previous = previous
- self.current = current
- self.didChange = didChange
- }
-}
-```
-
-`ClamshellService.swift` then contains only `ClamshellService`.
-
-- [ ] **Step 2: Separate power mutation arguments**
-
-Move `PowerMutation` unchanged from `PowerSettingsClient.swift` into
-`PowerMutation.swift`. Keep the client focused on reading and writing `pmset`.
-
-- [ ] **Step 3: Separate process values, protocol, and adapter**
-
-Delete `ProcessRunner.swift` after distributing its declarations exactly as
-follows:
-
-```text
-ProcessOutputStream.swift ProcessOutputStream
-ProcessResult.swift ProcessResult
-ProcessRunning.swift ProcessRunning
-FoundationProcessRunner.swift FoundationProcessRunner and private ProcessOutputCollector
-```
-
-The collector remains private beside its sole consumer. Do not change process
-launching, concurrent pipe collection, UTF-8 validation, or returned values.
-
-- [ ] **Step 4: Separate privileged paths from policy generation**
-
-Move `PrivilegedPaths` unchanged into `PrivilegedPaths.swift`.
-`SudoersPolicy.swift` then contains only username validation and exact policy
-generation.
-
-- [ ] **Step 5: Separate installation workflow and filesystem adaptation**
-
-Distribute the declarations from `PrivilegedInstallation.swift` as follows:
-
-```text
-InstallationFileSystem.swift InstallationFileSystem and InstalledFileAttributes
-FoundationInstallationFileSystem.swift FoundationInstallationFileSystem
-InstallationResult.swift InstallationResult
-UninstallationResult.swift UninstallationResult
-PrivilegedInstallation.swift PrivilegedInstallation
-```
-
-Keep `InstalledFileAttributes` beside the protocol whose method returns it.
-Retain every existing path, permission (`0755` and `0440`), ownership check,
-temporary-file cleanup, `visudo` validation, and idempotency branch. Stage both
-replacement files before validation, validate the staged policy, then replace
-the policy before the helper. This avoids changing either live path when
-staging or validation fails; the two separate filesystem renames are not a
-single transaction.
-
-- [ ] **Step 6: Separate CLI composition and output options**
-
-Move `CommandComposition` unchanged into `CommandComposition.swift` and
-`OutputOptions` unchanged into `OutputOptions.swift`. Leave
-`ClamshellCommand.swift` and `Console.swift` with one primary responsibility
-each.
-
-- [ ] **Step 7: Move the Foundation process tests to their domain**
-
-Move `FoundationProcessRunnerTests` from
-`Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift` into
-`Tests/ClamshellCoreTests/Process/FoundationProcessRunnerTests.swift`. Keep its
-temporary executable fixture and assertions unchanged.
-
-- [ ] **Step 8: Extract the large installation fixture**
-
-Move these test-only declarations from
-`Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift`
-to `Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift`:
-
-```text
-InstallationOperation
-InstallationOperationLog
-InstallationRecordingRunner
-RecordingInstallationFileSystem
-```
-
-Remove `private` only where cross-file test access requires internal access.
-Keep suite-specific constants and test methods in the suite file.
-
-- [ ] **Step 9: Verify the structural refactor**
-
-Run:
-
-```bash
-swift test
-swift build
-swift build -c release
-```
-
-Expected: all 33 tests pass; both builds exit 0; command output and exit codes
-are unchanged.
-
-- [ ] **Step 10: Commit the focused declarations**
-
-Run:
-
-```bash
-git add Sources Tests
-git commit -m "refactor(core): separate domain responsibilities"
-```
-
-## Task 4: Enforce Google Swift formatting
-
-**Files:**
-
-- Create: `.editorconfig`
-- Create: `.gitattributes`
-- Modify: `.swift-format`
-- Modify: `.gitignore`
-- Modify: every checked-in Swift file through deterministic formatting
-
-- [ ] **Step 1: Replace `.swift-format` with the explicit project policy**
-
-Use:
-
-```json
-{
- "indentation": {
- "spaces": 2
- },
- "lineLength": 100,
- "maximumBlankLines": 1,
- "multiElementCollectionTrailingCommas": true,
- "rules": {
- "NeverForceUnwrap": true,
- "NeverUseForceTry": true,
- "NeverUseImplicitlyUnwrappedOptionals": true,
- "OrderedImports": true
- },
- "version": 1
-}
-```
-
-- [ ] **Step 2: Add editor-neutral settings**
-
-Create `.editorconfig`:
-
-```ini
-root = true
-
-[*]
-charset = utf-8
-end_of_line = lf
-insert_final_newline = true
-trim_trailing_whitespace = true
-
-[*.swift]
-indent_style = space
-indent_size = 2
-max_line_length = 100
-
-[{*.json,*.yml,*.yaml}]
-indent_style = space
-indent_size = 2
-
-[*.md]
-trim_trailing_whitespace = false
-```
-
-Create `.gitattributes`:
-
-```gitattributes
-* text=auto eol=lf
-*.icns binary
-*.png binary
-*.dmg binary
-```
-
-Add these generated paths to `.gitignore` while preserving existing entries:
-
-```gitignore
-Clamshell.xcodeproj/
-DerivedData/
-```
-
-- [ ] **Step 3: Confirm the formatter configuration changes the result**
-
-Run:
-
-```bash
-swift format lint --recursive --strict Sources Tests Package.swift
-```
-
-Expected: non-zero with formatting diagnostics caused by the change from four
-spaces to two.
-
-- [ ] **Step 4: Apply formatting only**
-
-Run:
-
-```bash
-swift format --recursive --in-place Sources Tests Package.swift
-```
-
-Expected: every Swift file uses two-space indentation and Google-compatible
-layout. A second strict lint invocation exits 0.
-
-- [ ] **Step 5: Commit the deterministic formatting**
-
-Run:
-
-```bash
-git add .editorconfig .gitattributes .gitignore .swift-format Package.swift Sources Tests
-git commit -m "style(swift): adopt Google formatting"
-```
-
-## Task 5: Add strict SwiftLint and useful API documentation
-
-**Files:**
-
-- Modify: `Package.swift`
-- Modify: `.swift-format`
-- Create: `.swiftlint.yml`
-- Modify: public declarations under `Sources/`
-
-- [ ] **Step 1: Require standalone SwiftLint**
-
-Use SwiftLint 0.65.0 as a standalone tool. Do not add lint tools to the
-production package graph. The GitHub `macos-26` runner includes this version.
-Install it locally with Homebrew:
-
-```bash
-brew install swiftlint
-test "$(swiftlint version)" = "0.65.0"
-```
-
-Do not add SwiftLint or its plugins to `Package.swift`. Production builds do
-not need lint tools. CodeQL analysis does not need lint tools.
-
-The CLI test target imports ArgumentParser for command parsing, so it declares
-the `ArgumentParser` product directly as well as depending on `ClamshellCLI`.
-
-- [ ] **Step 2: Add a curated non-formatting SwiftLint policy**
-
-First extend the `rules` object in `.swift-format` with the documentation rules
-that become enforceable in this task:
-
-```json
-"BeginDocumentationCommentWithOneLineSummary": true,
-"ValidateDocumentationComments": true
-```
-
-Create `.swiftlint.yml`:
-
-```yaml
-strict: true
-check_for_updates: false
-allow_zero_lintable_files: false
-
-excluded:
- - .build
- - .swiftpm
- - Clamshell.xcodeproj
- - DerivedData
-
-disabled_rules:
- - closing_brace
- - colon
- - comma
- - leading_whitespace
- - line_length
- - opening_brace
- - function_name_whitespace
- - return_arrow_whitespace
- - statement_position
- - force_try
- - trailing_comma
- - trailing_newline
- - trailing_semicolon
- - trailing_whitespace
- - vertical_whitespace
-
-opt_in_rules:
- - array_init
- - closure_body_length
- - contains_over_filter_count
- - contains_over_filter_is_empty
- - discouraged_optional_boolean
- - empty_collection_literal
- - empty_count
- - empty_string
- - explicit_init
- - fatal_error_message
- - first_where
- - last_where
- - legacy_multiple
- - modifier_order
- - overridden_super_call
- - pattern_matching_keywords
- - reduce_into
- - redundant_nil_coalescing
- - sorted_first_last
- - toggle_bool
- - unavailable_function
-
-closure_body_length:
- warning: 40
- error: 60
-cyclomatic_complexity:
- warning: 10
- error: 15
-file_length:
- warning: 400
- error: 500
- ignore_comment_only_lines: true
-function_body_length:
- warning: 40
- error: 60
-function_parameter_count:
- warning: 6
- error: 8
-type_body_length:
- warning: 250
- error: 350
-
-reporter: xcode
-```
-
-- [ ] **Step 3: Run SwiftLint to expose concrete violations**
-
-Run:
-
-```bash
-swiftlint lint --strict
-```
-
-Expected: non-zero until every reported correctness, naming, and complexity
-violation is addressed. Do not create a baseline or disable a rule for the
-whole repository.
-
-- [ ] **Step 4: Document non-obvious public contracts**
-
-Add concise `///` documentation only where it records behaviour that a
-self-describing declaration does not communicate. Cover these contracts:
-
-```text
-PowerMutation Accept only one exact helper action.
-PowerSettingsParser Define missing and malformed pmset behaviour.
-PrivilegedHelperClient Restrict non-interactive helper invocation.
-SudoersPolicy Reject unsafe usernames and allow only exact commands.
-PrivilegedInstallation Preserve staging, validation, verification, and idempotency.
-ClamshellService Avoid redundant writes and verify state transitions.
-```
-
-Do not document obvious properties, trivial initialisers, or declarations whose
-names and types already state their complete contract. Do not add comments to
-private implementation details unless the decision is non-obvious.
-
-- [ ] **Step 5: Resolve every strict lint finding idiomatically**
-
-Prefer changing code over suppressing a finding. When a rule is inapplicable,
-use a line- or declaration-scoped disable with a reason immediately above it.
-Run after each group of fixes:
-
-```bash
-swift format --recursive --in-place Sources Tests Package.swift
-swift format lint --recursive --strict Sources Tests Package.swift
-swiftlint lint --strict
-swift test
-```
-
-Expected: both linters exit 0 and all 33 tests pass.
-
-- [ ] **Step 6: Commit linting and API documentation**
-
-Run:
-
-```bash
-git add .swift-format .swiftlint.yml Package.swift Sources Tests
-git commit -m "build(lint): enforce Swift conventions"
-```
-
-## Task 6: Add one local verification entry point
-
-**Files:**
-
-- Create: `scripts/check.sh`
-- Create: `scripts/check-conventional-subject.sh`
-- Modify: `.github/CONTRIBUTING.md` later in Task 9
-
-- [ ] **Step 1: Add conventional-subject validation**
-
-Create `scripts/check-conventional-subject.sh`:
-
-```bash
-#!/bin/bash
-set -euo pipefail
-
-readonly subject="${1:-}"
-readonly verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert'
-readonly pattern="^(${verbs})\([a-z0-9][a-z0-9-]*\): .+"
-
-if [[ ! "$subject" =~ $pattern ]]; then
- echo "Expected verb(area): description, received: $subject" >&2
- exit 1
-fi
-```
-
-Dependency updates use `build(deps): ...`; `deps` is not a separate accepted
-verb, so dependency commits stay inside the repository's standard vocabulary.
-
-Make it executable and verify both branches:
-
-```bash
-chmod +x scripts/check-conventional-subject.sh
-scripts/check-conventional-subject.sh "feat(cli): add status output"
-! scripts/check-conventional-subject.sh "feat: add status output"
-```
-
-Expected: the scoped subject passes and the unscoped subject fails.
-
-- [ ] **Step 2: Add the complete local check**
-
-Create executable `scripts/check.sh`:
-
-```bash
-#!/bin/bash
-set -euo pipefail
-
-repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-readonly repository_root
-cd "$repository_root"
-
-swift_paths=(Package.swift Sources Tests)
-if [[ -d App ]]; then
- swift_paths+=(App)
-fi
-
-swift format lint --recursive --strict "${swift_paths[@]}"
-swiftlint lint --strict
-swift test
-swift build
-swift build -c release
-
-if [[ -d .github/workflows ]]; then
- command -v actionlint >/dev/null || {
- echo "actionlint is required: brew install actionlint" >&2
- exit 1
- }
- actionlint
-fi
-
-if [[ -f project.yml ]]; then
- command -v xcodegen >/dev/null || {
- echo "XcodeGen is required: brew install xcodegen" >&2
- exit 1
- }
- xcodegen generate
- xcodebuild \
- -project Clamshell.xcodeproj \
- -scheme ClamshellApp \
- -configuration Debug \
- CODE_SIGNING_ALLOWED=NO \
- build
-fi
-```
-
-Run:
-
-```bash
-chmod +x scripts/check.sh
-brew list actionlint >/dev/null 2>&1 || brew install actionlint
-scripts/check.sh
-```
-
-Expected: both linters, all tests, both builds, and actionlint exit 0.
-
-- [ ] **Step 3: Commit the reusable checks**
-
-Run:
-
-```bash
-git add scripts
-git commit -m "build(checks): add local verification entry point"
-```
-
-## Task 7: Add deterministic CI and pull-request policy
-
-**Files:**
-
-- Create: `.github/workflows/ci.yml`
-- Create: `.github/workflows/pr.yml`
-
-- [ ] **Step 1: Add CI with stable job names**
-
-Create `ci.yml` with these jobs and exact names:
-
-```yaml
-name: CI
-
-on:
- pull_request:
- branches: [main]
- push:
- branches: [main]
- workflow_dispatch:
-
-concurrency:
- group: ci-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- quality:
- name: Quality
- runs-on: macos-26
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - run: swift format lint --recursive --strict Sources Tests Package.swift
- - run: swiftlint lint --strict
-
- tests:
- name: Tests
- runs-on: macos-26
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - run: swift test
-
- build:
- name: Build
- runs-on: macos-26
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - run: swift build
- - run: swift build -c release
-
- workflows:
- name: Workflow lint
- runs-on: ubuntu-latest
- timeout-minutes: 10
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0
- with:
- version: 1.7.12
-```
-
-- [ ] **Step 2: Add PR title and commit validation**
-
-Create `pr.yml` with two stable jobs, `Title` and `Commits`. The title job
-checks out the trusted base revision. The commit job checks out the pull
-request's head SHA with full history for the commit range, but extracts the
-validator from the trusted base revision before invoking it. The synthetic
-merge subject is never validated, and a pull request cannot alter the
-validator it uses. If the trusted base predates the validator, both jobs use a
-literal bootstrap fallback identical to the script being introduced; later
-pull requests use only the base-revision copy.
-
-The workflow uses the trusted-base validator materialization described above.
-Keep the bootstrap fallback literal identical to
-`scripts/check-conventional-subject.sh`; the abbreviated shape is:
-
-```yaml
-concurrency:
- group: pr-${{ github.event.pull_request.number }}
- cancel-in-progress: true
-
-jobs:
- title:
- steps:
- - uses: actions/checkout@... # pinned SHA
- with:
- ref: ${{ github.event.pull_request.base.sha }}
- - run: materialize trusted-base validator or the identical bootstrap rule
- - run: $RUNNER_TEMP/check-conventional-subject.sh "$PR_TITLE"
-
- commits:
- steps:
- - uses: actions/checkout@... # pinned SHA
- with:
- fetch-depth: 0
- ref: ${{ github.event.pull_request.head.sha }}
- - env:
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- run: materialize trusted-base validator or the identical bootstrap rule
- - run: apply the temporary validator to git log --format=%s "$BASE_SHA..HEAD"
-```
-
-- [ ] **Step 3: Validate and commit the workflows**
-
-Run:
-
-```bash
-actionlint
-scripts/check.sh
-git add .github/workflows/ci.yml .github/workflows/pr.yml
-git commit -m "ci(checks): enforce repository quality"
-```
-
-Expected: actionlint and the complete local check exit 0.
-
-## Task 8: Add CodeQL, Dependabot, and release-please
-
-**Files:**
-
-- Create: `.github/workflows/codeql.yml`
-- Create: `.github/workflows/release.yml`
-- Create: `.github/dependabot.yml`
-- Create: `release-please-config.json`
-- Create: `.release-please-manifest.json`
-- Create: `CHANGELOG.md`
-
-- [ ] **Step 1: Add Swift CodeQL analysis**
-
-Create `codeql.yml` using `github/codeql-action` commit
-`e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81` (`v4`):
-
-```yaml
-name: CodeQL
-
-on:
- pull_request:
- branches: [main]
- push:
- branches: [main]
- schedule:
- - cron: "23 4 * * 1"
- workflow_dispatch:
-
-concurrency:
- group: codeql-${{ github.event.pull_request.number || github.ref }}
- cancel-in-progress: true
-
-permissions:
- contents: read
-
-jobs:
- analyze:
- name: Analyze (swift)
- runs-on: macos-26
- timeout-minutes: 30
- permissions:
- contents: read
- # CodeQL must upload Swift analysis results to code scanning.
- security-events: write
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
- - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
- with:
- build-mode: manual
- languages: swift
- queries: security-extended
- - run: swift build
- - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
- with:
- category: /language:swift
-```
-
-- [ ] **Step 2: Add grouped dependency updates**
-
-Create `.github/dependabot.yml`:
-
-```yaml
-version: 2
-updates:
- - package-ecosystem: swift
- directory: /
- schedule:
- interval: weekly
- day: monday
- time: "06:00"
- timezone: Europe/London
- open-pull-requests-limit: 5
- groups:
- swift-development:
- patterns:
- - "*"
-
- - package-ecosystem: github-actions
- directory: /
- schedule:
- interval: weekly
- day: monday
- time: "06:00"
- timezone: Europe/London
- open-pull-requests-limit: 5
- groups:
- actions:
- patterns:
- - "*"
-```
-
-- [ ] **Step 3: Configure release-please v5**
-
-Create `.release-please-manifest.json`:
-
-```json
-{}
-```
-
-Set `initial-version` to `0.1.0` in the root package configuration. This value
-makes the first release `0.1.0`. After the first release, release-please uses
-Conventional Commits to select the next version.
-
-Create `CHANGELOG.md`:
-
-```markdown
-# Changelog
-
-Notable changes to `clamshellctl` are recorded here by release-please.
-```
-
-Create `release-please-config.json`:
-
-```json
-{
- "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
- "packages": {
- ".": {
- "release-type": "simple",
- "package-name": "clamshellctl",
- "include-v-in-tag": true,
- "include-component-in-tag": false,
- "bump-minor-pre-major": true,
- "bump-patch-for-minor-pre-major": false,
- "extra-files": [
- {
- "type": "generic",
- "path": "Sources/ClamshellCore/BuildVersion.swift"
- }
- ],
- "changelog-sections": [
- {"type": "feat", "section": "Features"},
- {"type": "fix", "section": "Fixes"},
- {"type": "perf", "section": "Performance"},
- {"type": "docs", "section": "Documentation"},
- {"type": "test", "section": "Tests", "hidden": true},
- {"type": "build", "section": "Build", "hidden": true},
- {"type": "ci", "section": "CI", "hidden": true},
- {"type": "refactor", "section": "Refactoring", "hidden": true},
- {"type": "style", "section": "Style", "hidden": true},
- {"type": "chore", "section": "Chores", "hidden": true}
- ]
- }
- }
-}
-```
-
-Create `release.yml` using
-`googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7`
-(`v5`):
-
-```yaml
-name: Release
-
-on:
- push:
- branches: [main]
- workflow_dispatch:
-
-concurrency:
- group: release-${{ github.ref }}
- cancel-in-progress: true
-
-permissions: {}
-
-jobs:
- release-please:
- name: Release Please
- runs-on: ubuntu-latest
- timeout-minutes: 10
- permissions:
- # release-please opens release PRs, updates release metadata, and creates releases.
- contents: write
- issues: write
- pull-requests: write
- steps:
- - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5
- with:
- token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
- config-file: release-please-config.json
- manifest-file: .release-please-manifest.json
-```
-
-Do not add DMG upload until issue #6 supplies the packaging script.
-
-- [ ] **Step 4: Validate and commit automation**
-
-Run:
-
-```bash
-actionlint
-python3 -m json.tool release-please-config.json >/dev/null
-python3 -m json.tool .release-please-manifest.json >/dev/null
-scripts/check.sh
-git add .github CHANGELOG.md release-please-config.json .release-please-manifest.json
-git commit -m "ci(release): add security and release automation"
-```
-
-Expected: workflow and JSON validation pass; the local quality gate passes.
-
-## Task 9: Complete public repository documentation
-
-**Files:**
-
-- Create: `.github/CODEOWNERS`
-- Create: `.github/CODE_OF_CONDUCT.md`
-- Create: `.github/CONTRIBUTING.md`
-- Create: `.github/SECURITY.md`
-- Create: `.github/SUPPORT.md`
-- Create: `.github/pull_request_template.md`
-- Create: `.github/ISSUE_TEMPLATE/config.yml`
-- Modify: `.github/ISSUE_TEMPLATE/bug.yml`
-- Modify: `.github/ISSUE_TEMPLATE/feature.yml`
-- Modify: `README.md`
-- Modify: `docs/clamshellctl-design.md`
-- Modify: `docs/implementation-plan.md`
-
-- [ ] **Step 1: Add ownership and contribution policy**
-
-Use this CODEOWNERS rule:
-
-```text
-* @LMLiam
-```
-
-`CONTRIBUTING.md` must contain exact sections for prerequisites, setup,
-`scripts/check.sh`, Google Swift style, the one-primary-responsibility file
-rule, `verb(area): description`, pull requests, behavioural testing, and
-security reporting. It must state that SwiftLint suppressions require a local
-reason and that contributors must not run privileged tests against live system
-paths.
-
-Use Contributor Covenant 2.1 unchanged except for replacing its enforcement
-contact placeholder with `https://github.com/LMLiam`. This intentional
-substitution avoids publishing a personal email address.
-
-- [ ] **Step 2: Add security and support policy**
-
-`SECURITY.md` must define supported versions as the latest release, direct
-vulnerabilities to GitHub private vulnerability reporting, prohibit public
-proofs containing secrets or destructive commands, promise acknowledgement
-within seven days, and describe the privileged helper, sudoers policy, and
-unsigned distribution as security-sensitive surfaces.
-
-`SUPPORT.md` must direct bugs to the bug form, feature proposals to the feature
-form, security reports to private vulnerability reporting, and macOS or
-Homebrew usage questions to a normal GitHub issue without promising individual
-support.
-
-- [ ] **Step 3: Add issue and pull-request guidance**
-
-Set `blank_issues_enabled: false`. Add contact links for private security
-reporting and support. Keep the current bug and feature forms, but make their
-required fields and labels consistent with `type: bug` and `type: feature`.
-
-The pull-request template must include summary, linked issues, behaviour and
-security impact, verification commands, screenshots only when UI changes, and
-a checklist covering `scripts/check.sh`, documentation, and scoped commit
-subjects. Start it with a single top-level `# Summary` heading.
-
-- [ ] **Step 4: Align public and internal documentation**
-
-Add CI and CodeQL badges and contributor links to `README.md`. Update the source
-trees in `clamshellctl-design.md` and `implementation-plan.md` to match the
-responsibility folders. Replace direct formatter-only verification commands
-with `scripts/check.sh` where the full quality gate is intended. Preserve issue
-#10's rule that the `LMLiam/LMLiam` profile README changes only after the first
-public release succeeds.
-
-- [ ] **Step 5: Verify and commit documentation**
-
-Run:
-
-```bash
-scripts/check.sh
-git diff --check
-git add .github README.md docs
-git commit -m "docs(community): complete repository guidance"
-```
-
-Expected: all checks pass and no generated or `.vscode/` file is staged.
-
-## Task 10: Run the complete local verification and push the branch
-
-**Files:** None beyond corrections required by the checks.
-
-- [ ] **Step 1: Run the quality gate from a clean build state**
-
-Run:
-
-```bash
-swift package clean
-scripts/check.sh
-git diff --check
-git status --short
-```
-
-Expected: all checks pass; only `.vscode/` remains untracked.
-
-- [ ] **Step 2: Inspect every commit subject**
-
-Run:
-
-```bash
-while IFS= read -r subject; do
- scripts/check-conventional-subject.sh "$subject"
-done < <(git log --format=%s origin/main..HEAD)
-```
-
-Expected: each subject exits 0. Do not rewrite the historical GitGuardian
-fixture commit.
-
-- [ ] **Step 3: Push the existing feature branch**
-
-Run:
-
-```bash
-git push origin feat/mvp
-```
-
-Expected: draft PR #11 updates and CI, PR, CodeQL, GitGuardian, and CodeRabbit
-checks appear. Keep the PR in draft while repository configuration is applied.
-
-## Task 11: Resolve GitGuardian and enable repository features
-
-**Files:** None.
-
-- [ ] **Step 1: Resolve only the false-positive incident**
-
-Open the failing `GitGuardian Security Checks` details for PR #11. Select the
-historical occurrence in `Tests/ClamshellCoreTests/SudoersPolicyTests.swift` and
-choose `Skip: false positive`. Do not select a path ignore, detector ignore,
-test-credential classification, or history rewrite.
-
-Verify:
-
-```bash
-gh pr checks 11 --repo LMLiam/clamshellctl
-```
-
-Expected: `GitGuardian Security Checks` is successful.
-
-- [ ] **Step 2: Apply repository merge and feature settings**
-
-Run:
-
-```bash
-gh repo edit LMLiam/clamshellctl \
- --description "Control battery clamshell mode on macOS" \
- --enable-issues=true \
- --enable-projects=false \
- --enable-wiki=false \
- --enable-merge-commit=false \
- --enable-rebase-merge=false \
- --enable-squash-merge=true \
- --delete-branch-on-merge \
- --add-topic battery \
- --add-topic clamshell-mode \
- --add-topic command-line-tool \
- --add-topic control-center \
- --add-topic macos \
- --add-topic swift \
- --add-topic widgetkit
-```
-
-- [ ] **Step 3: Enable available security features**
-
-Run:
-
-```bash
-gh api --method PUT repos/LMLiam/clamshellctl/vulnerability-alerts
-gh api --method PUT repos/LMLiam/clamshellctl/automated-security-fixes
-gh api --method PUT repos/LMLiam/clamshellctl/private-vulnerability-reporting
-gh api --method PATCH repos/LMLiam/clamshellctl \
- -f 'security_and_analysis[secret_scanning][status]=enabled' \
- -f 'security_and_analysis[secret_scanning_push_protection][status]=enabled'
-```
-
-Expected: each request succeeds. If GitHub reports that a public-repository
-feature is already enabled, verify its state and continue without weakening it.
-
-- [ ] **Step 4: Curate labels without duplicating existing taxonomy**
-
-Keep the current `area:*`, `type:*`, `good first issue`, `help wanted`, and
-`question` labels. Remove redundant default `bug`, `documentation`, and
-`enhancement` labels after confirming no issue uses them. Add:
-
-```text
-priority: high B60205
-priority: medium FBCA04
-priority: low 0E8A16
-status: blocked D93F0B
-status: ready 0E8A16
-status: in review 5319E7
-```
-
-Keep milestone `v0.1.0` and all current issue assignments. Mark issues #1-#3
-`status: in review`; do not close them before PR #11 merges. Issue #8 remains
-open because native-app and DMG release jobs depend on issues #5 and #6.
-
-## Task 12: Create the protected-main ruleset
-
-**Files:** None.
-
-- [ ] **Step 1: Wait for every required check name to exist**
-
-Run:
-
-```bash
-gh pr checks 11 --repo LMLiam/clamshellctl
-```
-
-Expected check names:
-
-```text
-Quality
-Tests
-Build
-Workflow lint
-Title
-Commits
-Analyze (swift)
-GitGuardian Security Checks
-```
-
-Do not create the ruleset while any name is absent.
-
-- [ ] **Step 2: Create the active default-branch ruleset**
-
-Use `gh api --method POST repos/LMLiam/clamshellctl/rulesets --input -` with a
-JSON body that contains:
-
-```json
-{
- "name": "Main",
- "target": "branch",
- "enforcement": "active",
- "bypass_actors": [
- {
- "actor_id": 5,
- "actor_type": "RepositoryRole",
- "bypass_mode": "always"
- }
- ],
- "conditions": {
- "ref_name": {
- "exclude": [],
- "include": ["~DEFAULT_BRANCH"]
- }
- },
- "rules": [
- {"type": "deletion"},
- {"type": "non_fast_forward"},
- {
- "type": "pull_request",
- "parameters": {
- "required_approving_review_count": 1,
- "dismiss_stale_reviews_on_push": true,
- "required_reviewers": [],
- "require_code_owner_review": true,
- "require_last_push_approval": false,
- "required_review_thread_resolution": true,
- "allowed_merge_methods": ["squash"]
- }
- },
- {
- "type": "code_scanning",
- "parameters": {
- "code_scanning_tools": [
- {
- "tool": "CodeQL",
- "security_alerts_threshold": "medium_or_higher",
- "alerts_threshold": "errors"
- }
- ]
- }
- },
- {
- "type": "required_status_checks",
- "parameters": {
- "strict_required_status_checks_policy": true,
- "do_not_enforce_on_create": false,
- "required_status_checks": [
- {"context": "Quality"},
- {"context": "Tests"},
- {"context": "Build"},
- {"context": "Workflow lint"},
- {"context": "Title"},
- {"context": "Commits"},
- {"context": "GitGuardian Security Checks"}
- ]
- }
- }
- ]
-}
-```
-
-This requires one maintainer/code-owner approval. Repository-role ID `5`
-retains the administrator bypass Liam chose for his own pull requests.
-
-- [ ] **Step 3: Verify repository state**
-
-Run:
-
-```bash
-gh api repos/LMLiam/clamshellctl/rulesets
-readonly repository_fields="deleteBranchOnMerge,hasProjectsEnabled,hasWikiEnabled"
-repository_fields+=",mergeCommitAllowed,rebaseMergeAllowed,squashMergeAllowed"
-repository_fields+=",repositoryTopics"
-gh repo view LMLiam/clamshellctl \
- --json "$repository_fields"
-```
-
-Expected: active `Main` ruleset, squash-only merging, automatic branch deletion,
-projects and wiki disabled, and all seven topics present.
-
-## Task 13: Final review and pull-request handoff
-
-**Files:** Any narrow correction required by review.
-
-- [ ] **Step 1: Run final verification**
-
-Run:
-
-```bash
-scripts/check.sh
-gh pr checks 11 --repo LMLiam/clamshellctl
-git status --short --branch
-```
-
-Expected: local checks and every required remote check pass; `.vscode/` remains
-the only untracked path.
-
-- [ ] **Step 2: Review the complete branch diff**
-
-Review:
-
-```bash
-git diff --stat origin/main...HEAD
-git diff --check origin/main...HEAD
-```
-
-Confirm that no user-visible CLI behaviour, privileged command, installation
-path, permission, or exit code changed accidentally. Run the configured code
-review workflow and resolve only evidence-backed findings.
-
-- [ ] **Step 3: Update the existing draft PR**
-
-Keep PR #11's valid title unless the final scope requires a more accurate
-`feat(area): description` title. Update its body with:
-
-```text
-- Google Swift style enforced by swift-format and SwiftLint
-- Responsibility-based source and test layout
-- CI, CodeQL, release-please, and Dependabot
-- Community health and repository security configuration
-- GitGuardian false positive resolved without weakening scanning
-```
-
-Keep `Closes #1`, `Closes #2`, and `Closes #3`. Do not add `Closes #8` because
-its native-app and DMG acceptance criteria remain outstanding. Leave the pull
-request in draft for Liam's final review; he can make it ready or use the
-administrator merge bypass after reviewing the complete result.
-
-- [ ] **Step 4: Report the handoff**
-
-Provide Liam with the PR URL, commit list, local and remote verification results,
-the ruleset URL, and the still-open issue list. State explicitly that the
-profile README remains assigned to issue #10 after the first public release.
diff --git a/project.yml b/project.yml
new file mode 100644
index 0000000..8a8ccbf
--- /dev/null
+++ b/project.yml
@@ -0,0 +1,165 @@
+name: Clamshell
+
+options:
+ deploymentTarget:
+ macOS: "26.0"
+
+packages:
+ ClamshellKit:
+ path: .
+
+settings:
+ base:
+ SWIFT_VERSION: "6.0"
+ CODE_SIGN_STYLE: Manual
+ CODE_SIGN_IDENTITY: "-"
+ DEVELOPMENT_TEAM: ""
+ ARCHS: "arm64 x86_64"
+ ONLY_ACTIVE_ARCH: NO
+ MARKETING_VERSION: "0.1.0"
+ CURRENT_PROJECT_VERSION: 1
+
+targets:
+ ClamshellApp:
+ type: application
+ platform: macOS
+ sources:
+ - App/ClamshellApp
+ info:
+ path: App/ClamshellApp/Info.plist
+ properties:
+ CFBundleDisplayName: Clamshell
+ CFBundleURLTypes:
+ - CFBundleTypeRole: Viewer
+ CFBundleURLName: uk.co.lmliam.clamshell.control-action
+ CFBundleURLSchemes:
+ - clamshellctl
+ LSApplicationCategoryType: public.app-category.utilities
+ LSUIElement: true
+ dependencies:
+ - target: ClamshellControlProtocol
+ link: true
+ embed: false
+ - target: ClamshellControlModel
+ link: true
+ embed: false
+ - package: ClamshellKit
+ product: ClamshellCore
+ - target: ClamshellControl
+ embed: true
+ settings:
+ base:
+ ENABLE_TESTABILITY: YES
+ PRODUCT_BUNDLE_IDENTIFIER: uk.co.lmliam.clamshell
+ PRODUCT_NAME: Clamshell
+
+ ClamshellControl:
+ type: app-extension
+ platform: macOS
+ sources:
+ - path: App/ClamshellControl
+ excludes:
+ - Intent
+ - Model
+ info:
+ path: App/ClamshellControl/Info.plist
+ properties:
+ CFBundleDisplayName: Clamshell Control
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.widgetkit-extension
+ dependencies:
+ - target: ClamshellControlIntent
+ link: true
+ embed: false
+ - target: ClamshellControlModel
+ link: true
+ embed: false
+ - package: ClamshellKit
+ product: ClamshellCore
+ settings:
+ base:
+ ENABLE_APP_SANDBOX: YES
+ PRODUCT_BUNDLE_IDENTIFIER: uk.co.lmliam.clamshell.control
+ PRODUCT_NAME: ClamshellControl
+
+ ClamshellControlModel:
+ type: framework.static
+ platform: macOS
+ sources:
+ - App/ClamshellControl/Model
+ dependencies:
+ - package: ClamshellKit
+ product: ClamshellCore
+ settings:
+ base:
+ GENERATE_INFOPLIST_FILE: YES
+ PRODUCT_BUNDLE_IDENTIFIER: uk.co.lmliam.clamshell.control-model
+
+ ClamshellControlProtocol:
+ type: framework.static
+ platform: macOS
+ sources:
+ - App/ClamshellControlProtocol
+ settings:
+ base:
+ GENERATE_INFOPLIST_FILE: YES
+ PRODUCT_BUNDLE_IDENTIFIER: uk.co.lmliam.clamshell.control-protocol
+
+ ClamshellControlIntent:
+ type: framework.static
+ platform: macOS
+ sources:
+ - App/ClamshellControl/Intent
+ dependencies:
+ - target: ClamshellControlModel
+ link: true
+ embed: false
+ - target: ClamshellControlProtocol
+ link: true
+ embed: false
+ settings:
+ base:
+ GENERATE_INFOPLIST_FILE: YES
+ PRODUCT_BUNDLE_IDENTIFIER: uk.co.lmliam.clamshell.control-intent
+
+ ClamshellAppTests:
+ type: bundle.unit-test
+ platform: macOS
+ sources:
+ - App/ClamshellAppTests
+ dependencies:
+ - target: ClamshellApp
+ - target: ClamshellControlProtocol
+ link: true
+ embed: false
+ - target: ClamshellControlModel
+ link: true
+ embed: false
+ - package: ClamshellKit
+ product: ClamshellCore
+ settings:
+ base:
+ BUNDLE_LOADER: "$(TEST_HOST)"
+ GENERATE_INFOPLIST_FILE: YES
+ TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Clamshell.app/Contents/MacOS/Clamshell"
+
+ ClamshellControlTests:
+ type: bundle.unit-test
+ platform: macOS
+ sources:
+ - App/ClamshellControlTests
+ dependencies:
+ - target: ClamshellControlProtocol
+ link: true
+ embed: false
+ - target: ClamshellControlIntent
+ link: true
+ embed: false
+ - target: ClamshellControlModel
+ link: true
+ embed: false
+ - package: ClamshellKit
+ product: ClamshellCore
+ settings:
+ base:
+ GENERATE_INFOPLIST_FILE: YES