feat: add permissions commands and dev tasks

This commit is contained in:
Peter Steinberger 2026-01-03 13:20:56 +01:00
parent beb10cebcd
commit 5582c26016
14 changed files with 275 additions and 11 deletions

View File

@ -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

45
Makefile Normal file
View File

@ -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

View File

@ -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.

View File

@ -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):

View File

@ -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
}
}

View File

@ -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"
}
}
}

View File

@ -17,6 +17,8 @@ struct CommandRouter {
EditCommand.spec,
CompleteCommand.spec,
DeleteCommand.spec,
StatusCommand.spec,
AuthorizeCommand.spec,
]
let descriptor = CommandDescriptor(
name: rootName,

View File

@ -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
}
}
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -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 {

View File

@ -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.",
]
}
}
}

View File

@ -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")
}
}

View File

@ -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"))
}
}

View File

@ -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"
}
}