diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e4074..18cddb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Show reminders with filters (today/tomorrow/week/overdue/upcoming/completed/all/date) - Manage lists (list, create, rename, delete) - Add, edit, complete, and delete reminders +- Authorization status and permission prompt command - JSON and plain output modes for scripting - Flexible date parsing (relative, ISO 8601, and common formats) - GitHub Actions CI with lint, tests, and coverage gate diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..25b111e --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +SHELL := /bin/bash + +.PHONY: help format lint test check build remindctl clean + +help: + @printf "%s\n" \ + "make format - swift format in-place" \ + "make lint - swift format lint + swiftlint" \ + "make test - sync version + swift test (coverage enabled)" \ + "make check - lint + test + coverage gate" \ + "make build - release build into bin/ (codesigned)" \ + "make remindctl - clean rebuild + run debug binary (ARGS=...)" \ + "make clean - swift package clean" + +format: + swift format --in-place --recursive Sources Tests + +lint: + swift format lint --recursive Sources Tests + swiftlint + +test: + scripts/generate-version.sh + swift test --enable-code-coverage + +check: + $(MAKE) lint + $(MAKE) test + scripts/check-coverage.sh + +build: + scripts/generate-version.sh + mkdir -p bin + swift build -c release --product remindctl + cp .build/release/remindctl bin/remindctl + codesign --force --sign - --identifier com.steipete.remindctl bin/remindctl + +remindctl: + scripts/generate-version.sh + swift package clean + swift build -c debug --product remindctl + ./.build/debug/remindctl $(ARGS) + +clean: + swift package clean diff --git a/README.md b/README.md index 0f563f7..e59e914 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,12 @@ pnpm build # binary at ./bin/remindctl ``` +## Development +```bash +make remindctl ARGS="status" # clean build + run +make check # lint + test + coverage gate +``` + ## Requirements - macOS 14+ (Sonoma or later) - Swift 6.2+ @@ -44,6 +50,8 @@ remindctl add --title "Call mom" --list Personal --due tomorrow remindctl edit 1 --title "New title" --due 2026-01-04 remindctl complete 1 2 3 remindctl delete 4A83 --force +remindctl status # permission status +remindctl authorize # request permissions ``` ## Output formats @@ -59,5 +67,6 @@ Accepted by `--due` and filters: - ISO 8601 (`2026-01-03T12:34:56Z`) ## Permissions -On first run, macOS will prompt for Reminders access. You can manage access in: -System Settings → Privacy & Security → Reminders. +Run `remindctl authorize` to trigger the system prompt. If access is denied, enable +Terminal (or remindctl) in System Settings → Privacy & Security → Reminders. +If running over SSH, grant access on the Mac that runs the command. diff --git a/Sources/RemindCore/Errors.swift b/Sources/RemindCore/Errors.swift index cbd6911..db2cdb4 100644 --- a/Sources/RemindCore/Errors.swift +++ b/Sources/RemindCore/Errors.swift @@ -14,9 +14,17 @@ public enum RemindCoreError: LocalizedError, Sendable, Equatable { public var errorDescription: String? { switch self { case .accessDenied: - return "Access to Reminders denied. Grant access in System Settings > Privacy & Security > Reminders." + return [ + "Reminders access denied.", + "Run `remindctl authorize` to trigger the prompt, then allow Terminal (or remindctl)", + "in System Settings > Privacy & Security > Reminders.", + "If running over SSH, grant access on the Mac that runs the command.", + ].joined(separator: " ") case .writeOnlyAccess: - return "Reminders access is write-only. Full access is required to read reminders." + return [ + "Reminders access is write-only.", + "Switch to Full Access in System Settings > Privacy & Security > Reminders.", + ].joined(separator: " ") case .listNotFound(let name): return "List not found: \"\(name)\"." case .reminderNotFound(let id): diff --git a/Sources/RemindCore/EventKitStore.swift b/Sources/RemindCore/EventKitStore.swift index 08b3cee..50dd79c 100644 --- a/Sources/RemindCore/EventKitStore.swift +++ b/Sources/RemindCore/EventKitStore.swift @@ -10,21 +10,34 @@ public actor RemindersStore { } public func requestAccess() async throws { - let status = EKEventStore.authorizationStatus(for: .reminder) + let status = Self.authorizationStatus() switch status { case .notDetermined: - let granted = try await requestFullAccess() - if !granted { + let updated = try await requestAuthorization() + if updated != .fullAccess { throw RemindCoreError.accessDenied } case .denied, .restricted: throw RemindCoreError.accessDenied case .writeOnly: throw RemindCoreError.writeOnlyAccess - case .fullAccess, .authorized: + case .fullAccess: break - @unknown default: - throw RemindCoreError.operationFailed("Unknown authorization status") + } + } + + public static func authorizationStatus() -> RemindersAuthorizationStatus { + RemindersAuthorizationStatus(eventKitStatus: EKEventStore.authorizationStatus(for: .reminder)) + } + + public func requestAuthorization() async throws -> RemindersAuthorizationStatus { + let status = Self.authorizationStatus() + switch status { + case .notDetermined: + let granted = try await requestFullAccess() + return granted ? .fullAccess : .denied + default: + return status } } diff --git a/Sources/RemindCore/RemindersAuthorization.swift b/Sources/RemindCore/RemindersAuthorization.swift new file mode 100644 index 0000000..76db681 --- /dev/null +++ b/Sources/RemindCore/RemindersAuthorization.swift @@ -0,0 +1,46 @@ +import EventKit +import Foundation + +public enum RemindersAuthorizationStatus: String, Codable, Sendable, Equatable { + case notDetermined = "not-determined" + case restricted = "restricted" + case denied = "denied" + case writeOnly = "write-only" + case fullAccess = "full-access" + + public init(eventKitStatus: EKAuthorizationStatus) { + switch eventKitStatus { + case .notDetermined: + self = .notDetermined + case .restricted: + self = .restricted + case .denied: + self = .denied + case .writeOnly: + self = .writeOnly + case .fullAccess, .authorized: + self = .fullAccess + @unknown default: + self = .denied + } + } + + public var isAuthorized: Bool { + self == .fullAccess + } + + public var displayName: String { + switch self { + case .notDetermined: + return "Not determined" + case .restricted: + return "Restricted" + case .denied: + return "Denied" + case .writeOnly: + return "Write-only" + case .fullAccess: + return "Full access" + } + } +} diff --git a/Sources/remindctl/CommandRouter.swift b/Sources/remindctl/CommandRouter.swift index 0e80b2e..7117ec2 100644 --- a/Sources/remindctl/CommandRouter.swift +++ b/Sources/remindctl/CommandRouter.swift @@ -17,6 +17,8 @@ struct CommandRouter { EditCommand.spec, CompleteCommand.spec, DeleteCommand.spec, + StatusCommand.spec, + AuthorizeCommand.spec, ] let descriptor = CommandDescriptor( name: rootName, diff --git a/Sources/remindctl/Commands/AuthorizeCommand.swift b/Sources/remindctl/Commands/AuthorizeCommand.swift new file mode 100644 index 0000000..b66838d --- /dev/null +++ b/Sources/remindctl/Commands/AuthorizeCommand.swift @@ -0,0 +1,39 @@ +import Commander +import Foundation +import RemindCore + +enum AuthorizeCommand { + static var spec: CommandSpec { + CommandSpec( + name: "authorize", + abstract: "Request Reminders access", + discussion: "Triggers the Reminders permission prompt when available.", + signature: CommandSignatures.withRuntimeFlags(CommandSignature()), + usageExamples: [ + "remindctl authorize", + "remindctl authorize --json", + "remindctl authorize --quiet", + ] + ) { _, runtime in + let store = RemindersStore() + let current = RemindersStore.authorizationStatus() + let status: RemindersAuthorizationStatus + if current == .notDetermined { + status = try await store.requestAuthorization() + } else { + status = current + } + + OutputRenderer.printAuthorizationStatus(status, format: runtime.outputFormat) + + switch status { + case .fullAccess: + return + case .writeOnly: + throw RemindCoreError.writeOnlyAccess + case .notDetermined, .denied, .restricted: + throw RemindCoreError.accessDenied + } + } + } +} diff --git a/Sources/remindctl/Commands/StatusCommand.swift b/Sources/remindctl/Commands/StatusCommand.swift new file mode 100644 index 0000000..c88fabf --- /dev/null +++ b/Sources/remindctl/Commands/StatusCommand.swift @@ -0,0 +1,27 @@ +import Commander +import Foundation +import RemindCore + +enum StatusCommand { + static var spec: CommandSpec { + CommandSpec( + name: "status", + abstract: "Show Reminders authorization status", + discussion: "Reports the current Reminders permission state without prompting.", + signature: CommandSignatures.withRuntimeFlags(CommandSignature()), + usageExamples: [ + "remindctl status", + "remindctl status --json", + "remindctl status --plain", + ] + ) { _, runtime in + let status = RemindersStore.authorizationStatus() + OutputRenderer.printAuthorizationStatus(status, format: runtime.outputFormat) + if runtime.outputFormat == .standard, !status.isAuthorized { + for line in PermissionsHelp.guidanceLines(for: status) { + Swift.print(line) + } + } + } + } +} diff --git a/Sources/remindctl/OutputFormatting.swift b/Sources/remindctl/OutputFormatting.swift index 318c5d1..ee85c61 100644 --- a/Sources/remindctl/OutputFormatting.swift +++ b/Sources/remindctl/OutputFormatting.swift @@ -15,6 +15,11 @@ struct ListSummary: Codable, Sendable, Equatable { let overdueCount: Int } +struct AuthorizationSummary: Codable, Sendable, Equatable { + let status: String + let authorized: Bool +} + enum OutputRenderer { static func printReminders(_ reminders: [ReminderItem], format: OutputFormat) { switch format { @@ -70,6 +75,19 @@ enum OutputRenderer { } } + static func printAuthorizationStatus(_ status: RemindersAuthorizationStatus, format: OutputFormat) { + switch format { + case .standard: + Swift.print("Reminders access: \(status.displayName)") + case .plain: + Swift.print(status.rawValue) + case .json: + printJSON(AuthorizationSummary(status: status.rawValue, authorized: status.isAuthorized)) + case .quiet: + Swift.print(status.isAuthorized ? "1" : "0") + } + } + private static func printRemindersStandard(_ reminders: [ReminderItem]) { let sorted = ReminderFiltering.sort(reminders) guard !sorted.isEmpty else { diff --git a/Sources/remindctl/PermissionsHelp.swift b/Sources/remindctl/PermissionsHelp.swift new file mode 100644 index 0000000..d43c9c4 --- /dev/null +++ b/Sources/remindctl/PermissionsHelp.swift @@ -0,0 +1,27 @@ +import RemindCore + +enum PermissionsHelp { + static let settingsPath = "System Settings > Privacy & Security > Reminders" + + static func guidanceLines(for status: RemindersAuthorizationStatus) -> [String] { + switch status { + case .fullAccess: + return [] + case .notDetermined: + return [ + "Run `remindctl authorize` to trigger the system prompt.", + "If needed, open \(settingsPath) and allow Terminal (or remindctl).", + ] + case .denied, .restricted: + return [ + "Grant access in \(settingsPath) for Terminal (or remindctl).", + "If running over SSH, grant access on the Mac that runs the command.", + ] + case .writeOnly: + return [ + "Switch to Full Access in \(settingsPath).", + "If running over SSH, grant access on the Mac that runs the command.", + ] + } + } +} diff --git a/Tests/RemindCoreTests/AuthorizationStatusTests.swift b/Tests/RemindCoreTests/AuthorizationStatusTests.swift new file mode 100644 index 0000000..7142f3a --- /dev/null +++ b/Tests/RemindCoreTests/AuthorizationStatusTests.swift @@ -0,0 +1,23 @@ +import EventKit +import Testing + +@testable import RemindCore + +@MainActor +struct AuthorizationStatusTests { + @Test("Authorization status mapping") + func mapping() { + #expect(RemindersAuthorizationStatus(eventKitStatus: .fullAccess) == .fullAccess) + #expect(RemindersAuthorizationStatus(eventKitStatus: .writeOnly) == .writeOnly) + #expect(RemindersAuthorizationStatus(eventKitStatus: .denied) == .denied) + #expect(RemindersAuthorizationStatus(eventKitStatus: .restricted) == .restricted) + #expect(RemindersAuthorizationStatus(eventKitStatus: .notDetermined) == .notDetermined) + } + + @Test("Authorization display names") + func displayNames() { + #expect(RemindersAuthorizationStatus.fullAccess.displayName == "Full access") + #expect(RemindersAuthorizationStatus.writeOnly.displayName == "Write-only") + #expect(RemindersAuthorizationStatus.denied.displayName == "Denied") + } +} diff --git a/Tests/remindctlTests/HelpPrinterTests.swift b/Tests/remindctlTests/HelpPrinterTests.swift index 35c4323..7c8a924 100644 --- a/Tests/remindctlTests/HelpPrinterTests.swift +++ b/Tests/remindctlTests/HelpPrinterTests.swift @@ -10,11 +10,15 @@ struct HelpPrinterTests { ShowCommand.spec, ListCommand.spec, AddCommand.spec, + StatusCommand.spec, + AuthorizeCommand.spec, ] let lines = HelpPrinter.renderRoot(version: "0.0.0", rootName: "remindctl", commands: specs) let joined = lines.joined(separator: "\n") #expect(joined.contains("show")) #expect(joined.contains("list")) #expect(joined.contains("add")) + #expect(joined.contains("status")) + #expect(joined.contains("authorize")) } } diff --git a/package.json b/package.json index e6663f9..28b5027 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,14 @@ "private": true, "scripts": { "version:sync": "scripts/generate-version.sh", - "remindctl": "pnpm -s version:sync && swift run remindctl", + "remindctl": "pnpm -s version:sync && swift package clean && swift run remindctl", "start": "pnpm -s remindctl", "format": "swift format --in-place --recursive Sources Tests", "lint": "swift format lint --recursive Sources Tests && swiftlint", "test": "pnpm -s version:sync && swift test --enable-code-coverage", "coverage": "scripts/check-coverage.sh", + "check": "pnpm -s lint && pnpm -s test && pnpm -s coverage", + "clean": "swift package clean", "build": "pnpm -s version:sync && mkdir -p bin && swift build -c release --product remindctl && cp .build/release/remindctl bin/remindctl && codesign --force --sign - --identifier com.steipete.remindctl bin/remindctl" } }