feat: initial remindctl cli

This commit is contained in:
Peter Steinberger 2026-01-03 07:05:13 +01:00
parent e8a3d14f52
commit d8f9510112
43 changed files with 2498 additions and 32 deletions

26
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
jobs:
build:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Swift version
run: swift --version
- name: Install SwiftLint
run: brew install swiftlint
- name: Generate version files
run: scripts/generate-version.sh
- name: Swift format lint
run: swift format lint --recursive Sources Tests
- name: SwiftLint
run: swiftlint
- name: Swift test + coverage
run: scripts/check-coverage.sh
- name: Swift build
run: swift build -c release --product remindctl

38
.gitignore vendored
View File

@ -1,32 +1,6 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Editor/IDE
# .idea/
# .vscode/
.DS_Store
.build
.swiftpm
Packages
DerivedData
bin

58
.swift-format Normal file
View File

@ -0,0 +1,58 @@
{
"version": 1,
"lineLength": 120,
"indentation": {
"spaces": 2
},
"respectsExistingLineBreaks": true,
"lineBreakBeforeControlFlowKeywords": false,
"lineBreakBeforeEachArgument": false,
"lineBreakBeforeEachGenericRequirement": false,
"prioritizeKeepingFunctionOutputTogether": false,
"indentConditionalCompilationBlocks": true,
"lineBreakAroundMultilineExpressionChainComponents": false,
"fileScopedDeclarationPrivacy": {
"accessLevel": "private"
},
"rules": {
"AllPublicDeclarationsHaveDocumentation": false,
"AlwaysUseLiteralForEmptyCollectionInit": false,
"AlwaysUseLowerCamelCase": true,
"AmbiguousTrailingClosureOverload": true,
"BeginDocumentationCommentWithOneLineSummary": false,
"DoNotUseSemicolons": true,
"DontRepeatTypeInStaticProperties": true,
"FileScopedDeclarationPrivacy": true,
"FullyIndirectEnum": true,
"GroupNumericLiterals": true,
"IdentifiersMustBeASCII": true,
"NeverForceUnwrap": false,
"NeverUseForceTry": false,
"NeverUseImplicitlyUnwrappedOptionals": false,
"NoAccessLevelOnExtensionDeclaration": true,
"NoAssignmentInExpressions": true,
"NoBlockComments": true,
"NoCasesWithOnlyFallthrough": true,
"NoEmptyTrailingClosureParentheses": true,
"NoLabelsInCasePatterns": true,
"NoLeadingUnderscores": false,
"NoParensAroundConditions": true,
"NoPlaygroundLiterals": true,
"NoVoidReturnOnFunctionSignature": true,
"OmitExplicitReturns": false,
"OneCasePerLine": true,
"OneVariableDeclarationPerLine": true,
"OnlyOneTrailingClosureArgument": true,
"OrderedImports": true,
"ReplaceForEachWithForLoop": true,
"ReturnVoidInsteadOfEmptyTuple": true,
"UseEarlyExits": false,
"UseLetInEveryBoundCaseVariable": true,
"UseShorthandTypeNames": true,
"UseSingleLinePropertyGetter": true,
"UseSynthesizedInitializer": true,
"UseTripleSlashForDocumentationComments": true,
"UseWhereClausesInForLoops": false,
"ValidateDocumentationComments": false
}
}

93
.swiftlint.yml Normal file
View File

@ -0,0 +1,93 @@
included:
- Sources
- Tests
excluded:
- .build
- .swiftpm
- Packages
- .git
- DerivedData
- "**/Generated"
- "**/Resources"
opt_in_rules:
- array_init
- closure_spacing
- contains_over_first_not_nil
- empty_count
- empty_string
- explicit_init
- fallthrough
- fatal_error_message
- first_where
- joined_default_parameter
- last_where
- literal_expression_end_indentation
- multiline_arguments
- multiline_parameters
- operator_usage_whitespace
- overridden_super_call
- private_outlet
- redundant_nil_coalescing
- sorted_first_last
- switch_case_alignment
- unneeded_parentheses_in_closure_argument
- vertical_parameter_alignment_on_call
disabled_rules:
- explicit_self
- identifier_name
- file_header
- explicit_acl
- explicit_top_level_acl
- explicit_type_interface
- missing_docs
- required_deinit
- trailing_whitespace
- trailing_newline
- trailing_comma
- vertical_whitespace
- indentation_width
- sorted_imports
- file_name
force_cast: warning
force_try: warning
line_length:
warning: 120
error: 140
ignores_comments: true
ignores_urls: true
file_length:
warning: 450
error: 500
ignore_comment_only_lines: true
type_body_length:
warning: 250
error: 400
function_body_length:
warning: 80
error: 120
cyclomatic_complexity:
warning: 15
error: 25
nesting:
type_level:
warning: 4
error: 6
function_level:
warning: 5
error: 7
large_tuple:
warning: 4
error: 5
reporter: "xcode"

10
CHANGELOG.md Normal file
View File

@ -0,0 +1,10 @@
# Changelog
## 0.1.0 - 2026-01-03
- Reminders CLI with Commander-based command router
- Show reminders with filters (today/tomorrow/week/overdue/upcoming/completed/all/date)
- Manage lists (list, create, rename, delete)
- Add, edit, complete, and delete reminders
- 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

15
Package.resolved Normal file
View File

@ -0,0 +1,15 @@
{
"originHash" : "a669a19d7dad51d9569b07c55a81b6066ee617b2f82a0f83681682c9ff35bc10",
"pins" : [
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02",
"version" : "0.2.0"
}
}
],
"version" : 3
}

55
Package.swift Normal file
View File

@ -0,0 +1,55 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "remindctl",
platforms: [.macOS(.v14)],
products: [
.library(name: "RemindCore", targets: ["RemindCore"]),
.executable(name: "remindctl", targets: ["remindctl"]),
],
dependencies: [
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
],
targets: [
.target(
name: "RemindCore",
dependencies: [],
linkerSettings: [
.linkedFramework("EventKit"),
]
),
.executableTarget(
name: "remindctl",
dependencies: [
"RemindCore",
.product(name: "Commander", package: "Commander"),
],
exclude: [
"Resources/Info.plist",
],
linkerSettings: [
.unsafeFlags([
"-Xlinker", "-sectcreate",
"-Xlinker", "__TEXT",
"-Xlinker", "__info_plist",
"-Xlinker", "Sources/remindctl/Resources/Info.plist",
]),
]
),
.testTarget(
name: "RemindCoreTests",
dependencies: [
"RemindCore",
]
),
.testTarget(
name: "remindctlTests",
dependencies: [
"remindctl",
"RemindCore",
]
),
],
swiftLanguageModes: [.v6]
)

63
README.md Normal file
View File

@ -0,0 +1,63 @@
# remindctl
Fast CLI for Apple Reminders on macOS.
## Install
### Homebrew (Home Pro)
```bash
brew install steipete/tap/remindctl
```
### From source
```bash
pnpm install
pnpm build
# binary at ./bin/remindctl
```
## Requirements
- macOS 14+ (Sonoma or later)
- Swift 6.2+
- Reminders permission (System Settings → Privacy & Security → Reminders)
## Usage
```bash
remindctl # show today (default)
remindctl today # show today
remindctl tomorrow # show tomorrow
remindctl week # show this week
remindctl overdue # overdue
remindctl upcoming # upcoming
remindctl completed # completed
remindctl all # all reminders
remindctl 2026-01-03 # specific date
remindctl list # lists
remindctl list Work # show list
remindctl list Work --rename Office
remindctl list Work --delete
remindctl list Projects --create
remindctl add "Buy milk"
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
```
## Output formats
- `--json` emits JSON arrays/objects.
- `--plain` emits tab-separated lines.
- `--quiet` emits counts only.
## Date formats
Accepted by `--due` and filters:
- `today`, `tomorrow`, `yesterday`
- `YYYY-MM-DD`
- `YYYY-MM-DD HH:mm`
- 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.

View File

@ -0,0 +1,83 @@
import Foundation
public enum DateParsing {
public static func parseUserDate(
_ input: String,
now: Date = Date(),
calendar: Calendar = .current
) -> Date? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = trimmed.lowercased()
if let relative = parseRelativeDate(lower, now: now, calendar: calendar) {
return relative
}
let iso =
isoFormatter(withFraction: true).date(from: trimmed)
?? isoFormatter(withFraction: false).date(from: trimmed)
if let iso {
return iso
}
for formatter in dateFormatters() {
if let date = formatter.date(from: trimmed) {
return date
}
}
return nil
}
public static func formatDisplay(_ date: Date, calendar: Calendar = .current) -> String {
let formatter = DateFormatter()
formatter.locale = Locale.current
formatter.timeZone = calendar.timeZone
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter.string(from: date)
}
private static func parseRelativeDate(_ input: String, now: Date, calendar: Calendar) -> Date? {
switch input {
case "today":
return calendar.startOfDay(for: now)
case "tomorrow":
return calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))
case "yesterday":
return calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now))
case "now":
return now
default:
return nil
}
}
private static func isoFormatter(withFraction: Bool) -> ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions =
withFraction
? [.withInternetDateTime, .withFractionalSeconds]
: [.withInternetDateTime]
return formatter
}
private static func dateFormatters() -> [DateFormatter] {
let formats = [
"yyyy-MM-dd",
"yyyy-MM-dd HH:mm",
"yyyy-MM-dd HH:mm:ss",
"MM/dd/yyyy",
"MM/dd/yyyy HH:mm",
"dd-MM-yy",
"dd-MM-yyyy",
]
return formats.map { format in
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
formatter.dateFormat = format
return formatter
}
}
}

View File

@ -0,0 +1,36 @@
import Foundation
public enum RemindCoreError: LocalizedError, Sendable, Equatable {
case accessDenied
case writeOnlyAccess
case listNotFound(String)
case reminderNotFound(String)
case ambiguousIdentifier(String, matches: [String])
case invalidIdentifier(String)
case invalidDate(String)
case unsupported(String)
case operationFailed(String)
public var errorDescription: String? {
switch self {
case .accessDenied:
return "Access to Reminders denied. Grant access in System Settings > Privacy & Security > Reminders."
case .writeOnlyAccess:
return "Reminders access is write-only. Full access is required to read reminders."
case .listNotFound(let name):
return "List not found: \"\(name)\"."
case .reminderNotFound(let id):
return "Reminder not found: \"\(id)\"."
case .ambiguousIdentifier(let input, let matches):
return "Identifier \"\(input)\" matches multiple reminders: \(matches.joined(separator: ", "))."
case .invalidIdentifier(let input):
return "Invalid identifier: \"\(input)\"."
case .invalidDate(let input):
return "Invalid date: \"\(input)\"."
case .unsupported(let message):
return message
case .operationFailed(let message):
return message
}
}
}

View File

@ -0,0 +1,242 @@
import EventKit
import Foundation
public actor RemindersStore {
private let eventStore = EKEventStore()
private let calendar: Calendar
public init(calendar: Calendar = .current) {
self.calendar = calendar
}
public func requestAccess() async throws {
let status = EKEventStore.authorizationStatus(for: .reminder)
switch status {
case .notDetermined:
let granted = try await requestFullAccess()
if !granted {
throw RemindCoreError.accessDenied
}
case .denied, .restricted:
throw RemindCoreError.accessDenied
case .writeOnly:
throw RemindCoreError.writeOnlyAccess
case .fullAccess, .authorized:
break
@unknown default:
throw RemindCoreError.operationFailed("Unknown authorization status")
}
}
public func lists() async -> [ReminderList] {
eventStore.calendars(for: .reminder).map { calendar in
ReminderList(id: calendar.calendarIdentifier, title: calendar.title)
}
}
public func defaultListName() -> String? {
eventStore.defaultCalendarForNewReminders()?.title
}
public func reminders(in listName: String? = nil) async throws -> [ReminderItem] {
let calendars: [EKCalendar]
if let listName {
calendars = eventStore.calendars(for: .reminder).filter { $0.title == listName }
if calendars.isEmpty {
throw RemindCoreError.listNotFound(listName)
}
} else {
calendars = eventStore.calendars(for: .reminder)
}
return await fetchReminders(in: calendars)
}
public func createList(name: String) async throws -> ReminderList {
let list = EKCalendar(for: .reminder, eventStore: eventStore)
list.title = name
guard let source = eventStore.defaultCalendarForNewReminders()?.source else {
throw RemindCoreError.operationFailed("Unable to determine default reminder source")
}
list.source = source
try eventStore.saveCalendar(list, commit: true)
return ReminderList(id: list.calendarIdentifier, title: list.title)
}
public func renameList(oldName: String, newName: String) async throws {
let calendar = try calendar(named: oldName)
guard calendar.allowsContentModifications else {
throw RemindCoreError.operationFailed("Cannot modify system list")
}
calendar.title = newName
try eventStore.saveCalendar(calendar, commit: true)
}
public func deleteList(name: String) async throws {
let calendar = try calendar(named: name)
guard calendar.allowsContentModifications else {
throw RemindCoreError.operationFailed("Cannot delete system list")
}
try eventStore.removeCalendar(calendar, commit: true)
}
public func createReminder(_ draft: ReminderDraft, listName: String) async throws -> ReminderItem {
let calendar = try calendar(named: listName)
let reminder = EKReminder(eventStore: eventStore)
reminder.title = draft.title
reminder.notes = draft.notes
reminder.calendar = calendar
reminder.priority = draft.priority.eventKitValue
if let dueDate = draft.dueDate {
reminder.dueDateComponents = calendarComponents(from: dueDate)
}
try eventStore.save(reminder, commit: true)
return ReminderItem(
id: reminder.calendarItemIdentifier,
title: reminder.title ?? "",
notes: reminder.notes,
isCompleted: reminder.isCompleted,
completionDate: reminder.completionDate,
priority: ReminderPriority(eventKitValue: Int(reminder.priority)),
dueDate: date(from: reminder.dueDateComponents),
listID: reminder.calendar.calendarIdentifier,
listName: reminder.calendar.title
)
}
public func updateReminder(id: String, update: ReminderUpdate) async throws -> ReminderItem {
let reminder = try reminder(withID: id)
if let title = update.title {
reminder.title = title
}
if let notes = update.notes {
reminder.notes = notes
}
if let dueDateUpdate = update.dueDate {
if let dueDate = dueDateUpdate {
reminder.dueDateComponents = calendarComponents(from: dueDate)
} else {
reminder.dueDateComponents = nil
}
}
if let priority = update.priority {
reminder.priority = priority.eventKitValue
}
if let listName = update.listName {
reminder.calendar = try calendar(named: listName)
}
if let isCompleted = update.isCompleted {
reminder.isCompleted = isCompleted
}
try eventStore.save(reminder, commit: true)
return ReminderItem(
id: reminder.calendarItemIdentifier,
title: reminder.title ?? "",
notes: reminder.notes,
isCompleted: reminder.isCompleted,
completionDate: reminder.completionDate,
priority: ReminderPriority(eventKitValue: Int(reminder.priority)),
dueDate: date(from: reminder.dueDateComponents),
listID: reminder.calendar.calendarIdentifier,
listName: reminder.calendar.title
)
}
public func completeReminders(ids: [String]) async throws -> [ReminderItem] {
var updated: [ReminderItem] = []
for id in ids {
let reminder = try reminder(withID: id)
reminder.isCompleted = true
try eventStore.save(reminder, commit: true)
updated.append(
ReminderItem(
id: reminder.calendarItemIdentifier,
title: reminder.title ?? "",
notes: reminder.notes,
isCompleted: reminder.isCompleted,
completionDate: reminder.completionDate,
priority: ReminderPriority(eventKitValue: Int(reminder.priority)),
dueDate: date(from: reminder.dueDateComponents),
listID: reminder.calendar.calendarIdentifier,
listName: reminder.calendar.title
)
)
}
return updated
}
public func deleteReminders(ids: [String]) async throws -> Int {
var deleted = 0
for id in ids {
let reminder = try reminder(withID: id)
try eventStore.remove(reminder, commit: true)
deleted += 1
}
return deleted
}
private func requestFullAccess() async throws -> Bool {
try await withCheckedThrowingContinuation { continuation in
eventStore.requestFullAccessToReminders { granted, error in
if let error {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: granted)
}
}
}
private func fetchReminders(in calendars: [EKCalendar]) async -> [ReminderItem] {
await withCheckedContinuation { continuation in
let predicate = eventStore.predicateForReminders(in: calendars)
eventStore.fetchReminders(matching: predicate) { reminders in
let mapped = (reminders ?? []).map { reminder in
self.item(from: reminder)
}
continuation.resume(returning: mapped)
}
}
}
private func reminder(withID id: String) throws -> EKReminder {
guard let item = eventStore.calendarItem(withIdentifier: id) as? EKReminder else {
throw RemindCoreError.reminderNotFound(id)
}
return item
}
private func calendar(named name: String) throws -> EKCalendar {
let calendars = eventStore.calendars(for: .reminder).filter { $0.title == name }
guard let calendar = calendars.first else {
throw RemindCoreError.listNotFound(name)
}
return calendar
}
private func calendarComponents(from date: Date) -> DateComponents {
calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)
}
private func date(from components: DateComponents?) -> Date? {
guard let components else { return nil }
return calendar.date(from: components)
}
private func item(from reminder: EKReminder) -> ReminderItem {
ReminderItem(
id: reminder.calendarItemIdentifier,
title: reminder.title ?? "",
notes: reminder.notes,
isCompleted: reminder.isCompleted,
completionDate: reminder.completionDate,
priority: ReminderPriority(eventKitValue: Int(reminder.priority)),
dueDate: date(from: reminder.dueDateComponents),
listID: reminder.calendar.calendarIdentifier,
listName: reminder.calendar.title
)
}
}

View File

@ -0,0 +1,40 @@
import Foundation
public enum IDResolver {
public static let minimumPrefixLength = 4
public static func resolve(
_ inputs: [String],
from reminders: [ReminderItem]
) throws -> [ReminderItem] {
let sorted = ReminderFiltering.sort(reminders)
var resolved: [ReminderItem] = []
for input in inputs {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
if let index = Int(trimmed) {
let idx = index - 1
guard idx >= 0 && idx < sorted.count else {
throw RemindCoreError.invalidIdentifier(trimmed)
}
resolved.append(sorted[idx])
continue
}
if trimmed.count < minimumPrefixLength {
throw RemindCoreError.invalidIdentifier(trimmed)
}
let matches = sorted.filter { $0.id.lowercased().hasPrefix(trimmed.lowercased()) }
if matches.isEmpty {
throw RemindCoreError.reminderNotFound(trimmed)
}
if matches.count > 1 {
throw RemindCoreError.ambiguousIdentifier(trimmed, matches: matches.map { $0.id })
}
if let match = matches.first {
resolved.append(match)
}
}
return resolved
}
}

View File

@ -0,0 +1,117 @@
import Foundation
public enum ReminderPriority: String, Codable, CaseIterable, Sendable {
case none
case low
case medium
case high
public init(eventKitValue: Int) {
switch eventKitValue {
case 1...4:
self = .high
case 5:
self = .medium
case 6...9:
self = .low
default:
self = .none
}
}
public var eventKitValue: Int {
switch self {
case .none:
return 0
case .high:
return 1
case .medium:
return 5
case .low:
return 9
}
}
}
public struct ReminderList: Identifiable, Codable, Sendable, Equatable {
public let id: String
public let title: String
public init(id: String, title: String) {
self.id = id
self.title = title
}
}
public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
public let id: String
public let title: String
public let notes: String?
public let isCompleted: Bool
public let completionDate: Date?
public let priority: ReminderPriority
public let dueDate: Date?
public let listID: String
public let listName: String
public init(
id: String,
title: String,
notes: String?,
isCompleted: Bool,
completionDate: Date?,
priority: ReminderPriority,
dueDate: Date?,
listID: String,
listName: String
) {
self.id = id
self.title = title
self.notes = notes
self.isCompleted = isCompleted
self.completionDate = completionDate
self.priority = priority
self.dueDate = dueDate
self.listID = listID
self.listName = listName
}
}
public struct ReminderDraft: Sendable {
public let title: String
public let notes: String?
public let dueDate: Date?
public let priority: ReminderPriority
public init(title: String, notes: String?, dueDate: Date?, priority: ReminderPriority) {
self.title = title
self.notes = notes
self.dueDate = dueDate
self.priority = priority
}
}
public struct ReminderUpdate: Sendable {
public let title: String?
public let notes: String?
public let dueDate: Date??
public let priority: ReminderPriority?
public let listName: String?
public let isCompleted: Bool?
public init(
title: String? = nil,
notes: String? = nil,
dueDate: Date?? = nil,
priority: ReminderPriority? = nil,
listName: String? = nil,
isCompleted: Bool? = nil
) {
self.title = title
self.notes = notes
self.dueDate = dueDate
self.priority = priority
self.listName = listName
self.isCompleted = isCompleted
}
}

View File

@ -0,0 +1,108 @@
import Foundation
public enum ReminderFilter: Equatable, Sendable {
case today
case tomorrow
case week
case overdue
case upcoming
case completed
case date(Date)
case all
}
public enum ReminderFiltering {
public static func parse(_ input: String, now: Date = Date(), calendar: Calendar = .current) -> ReminderFilter? {
let token = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch token {
case "today", "tday":
return .today
case "tomorrow", "t":
return .tomorrow
case "week", "w":
return .week
case "overdue", "o":
return .overdue
case "upcoming", "u":
return .upcoming
case "completed", "done", "c":
return .completed
case "all", "a":
return .all
default:
if let date = DateParsing.parseUserDate(token, now: now, calendar: calendar) {
return .date(date)
}
return nil
}
}
public static func apply(
_ reminders: [ReminderItem],
filter: ReminderFilter,
now: Date = Date(),
calendar: Calendar = .current
) -> [ReminderItem] {
let startOfToday = calendar.startOfDay(for: now)
let startOfTomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? startOfToday
let startOfDayAfterTomorrow =
calendar.date(byAdding: .day, value: 2, to: startOfToday) ?? startOfTomorrow
switch filter {
case .today:
return reminders.filter { reminder in
let isToday = reminder.dueDate.map { $0 >= startOfToday && $0 < startOfTomorrow } ?? false
let isOverdue = reminder.dueDate.map { $0 < startOfToday } ?? false
return !reminder.isCompleted && (isToday || isOverdue)
}
case .tomorrow:
return reminders.filter { reminder in
let isTomorrow = reminder.dueDate.map { $0 >= startOfTomorrow && $0 < startOfDayAfterTomorrow } ?? false
return !reminder.isCompleted && isTomorrow
}
case .week:
let interval = calendar.dateInterval(of: .weekOfYear, for: now)
let start = interval?.start ?? startOfToday
let end = interval?.end ?? now
return reminders.filter { reminder in
let inWeek = reminder.dueDate.map { $0 >= start && $0 <= end } ?? false
return !reminder.isCompleted && inWeek
}
case .overdue:
return reminders.filter { reminder in
let isOverdue = reminder.dueDate.map { $0 < startOfToday } ?? false
return !reminder.isCompleted && isOverdue
}
case .upcoming:
return reminders.filter { reminder in
!reminder.isCompleted && reminder.dueDate != nil
}
case .completed:
return reminders.filter { $0.isCompleted }
case .date(let date):
return reminders.filter { reminder in
let matches = reminder.dueDate.map { calendar.isDate($0, inSameDayAs: date) } ?? false
return !reminder.isCompleted && matches
}
case .all:
return reminders
}
}
public static func sort(_ reminders: [ReminderItem]) -> [ReminderItem] {
reminders.sorted { lhs, rhs in
switch (lhs.dueDate, rhs.dueDate) {
case (nil, nil):
return lhs.title < rhs.title
case (nil, _?):
return false
case (_?, nil):
return true
case (let left?, let right?):
if left == right {
return lhs.title < rhs.title
}
return left < right
}
}
}
}

View File

@ -0,0 +1,26 @@
import Foundation
import RemindCore
enum CommandHelpers {
static func parsePriority(_ value: String) throws -> ReminderPriority {
switch value.lowercased() {
case "none":
return .none
case "low":
return .low
case "medium", "med":
return .medium
case "high":
return .high
default:
throw RemindCoreError.operationFailed("Invalid priority: \"\(value)\" (use none|low|medium|high)")
}
}
static func parseDueDate(_ value: String) throws -> Date {
guard let date = DateParsing.parseUserDate(value) else {
throw RemindCoreError.invalidDate(value)
}
return date
}
}

View File

@ -0,0 +1,153 @@
import Commander
import Foundation
import RemindCore
struct CommandRouter {
let rootName = "remindctl"
let version: String
let specs: [CommandSpec]
let program: Program
init() {
self.version = CommandRouter.resolveVersion()
self.specs = [
ShowCommand.spec,
ListCommand.spec,
AddCommand.spec,
EditCommand.spec,
CompleteCommand.spec,
DeleteCommand.spec,
]
let descriptor = CommandDescriptor(
name: rootName,
abstract: "Manage Apple Reminders from the terminal",
discussion: nil,
signature: CommandSignature(),
subcommands: specs.map { $0.descriptor },
defaultSubcommandName: "show"
)
self.program = Program(descriptors: [descriptor])
}
func run() async -> Int32 {
await run(argv: CommandLine.arguments)
}
func run(argv: [String]) async -> Int32 {
var argv = normalizeArguments(argv)
argv = applyAliases(argv)
if argv.contains("--version") || argv.contains("-V") {
Swift.print(version)
return 0
}
if argv.contains("--help") || argv.contains("-h") {
printHelp(for: argv)
return 0
}
argv = rewriteImplicitShow(argv)
do {
let invocation = try program.resolve(argv: argv)
guard let commandName = invocation.path.last,
let spec = specs.first(where: { $0.name == commandName })
else {
Console.printError("Unknown command")
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
return 1
}
let runtime = RuntimeOptions(parsedValues: invocation.parsedValues)
do {
try await spec.run(invocation.parsedValues, runtime)
return 0
} catch {
Console.printError(error.localizedDescription)
return 1
}
} catch let error as CommanderProgramError {
Console.printError(error.description)
if case .missingSubcommand = error {
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
}
return 1
} catch {
Console.printError(error.localizedDescription)
return 1
}
}
private func normalizeArguments(_ argv: [String]) -> [String] {
guard !argv.isEmpty else { return argv }
var copy = argv
copy[0] = URL(fileURLWithPath: argv[0]).lastPathComponent
return copy
}
private func applyAliases(_ argv: [String]) -> [String] {
guard argv.count >= 2 else { return argv }
var copy = argv
if copy[1] == "lists" || copy[1] == "ls" {
copy[1] = "list"
}
if copy[1] == "rm" {
copy[1] = "delete"
}
if copy[1] == "done" {
copy[1] = "complete"
}
return copy
}
private func rewriteImplicitShow(_ argv: [String]) -> [String] {
guard argv.count >= 2 else { return argv }
let token = argv[1]
if token.hasPrefix("-") {
return argv
}
let commandNames = Set(specs.map { $0.name })
if commandNames.contains(token) {
return argv
}
if ReminderFiltering.parse(token) != nil {
var copy = argv
copy.insert("show", at: 1)
return copy
}
return argv
}
private func printHelp(for argv: [String]) {
let path = helpPath(from: argv)
if path.count <= 1 {
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
return
}
if let spec = specs.first(where: { $0.name == path[1] }) {
HelpPrinter.printCommand(rootName: rootName, spec: spec)
} else {
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
}
}
private func helpPath(from argv: [String]) -> [String] {
var path: [String] = []
for token in argv {
if token == "--help" || token == "-h" { continue }
if token.hasPrefix("-") { break }
path.append(token)
}
return path
}
private static func resolveVersion() -> String {
if let envVersion = ProcessInfo.processInfo.environment["REMINDCTL_VERSION"], !envVersion.isEmpty {
return envVersion
}
return RemindctlVersion.current
}
}

View File

@ -0,0 +1,42 @@
import Commander
enum CommandSignatures {
static func runtimeFlags() -> [FlagDefinition] {
[
.make(
label: "jsonOutput",
names: [.short("j"), .long("json"), .aliasLong("json-output"), .aliasLong("jsonOutput")],
help: "Emit machine-readable JSON output"
),
.make(
label: "plainOutput",
names: [.long("plain")],
help: "Emit stable line-based output"
),
.make(
label: "quiet",
names: [.short("q"), .long("quiet")],
help: "Only emit minimal output"
),
.make(
label: "noColor",
names: [.long("no-color")],
help: "Disable colored output"
),
.make(
label: "noInput",
names: [.long("no-input")],
help: "Disable interactive prompts"
),
]
}
static func withRuntimeFlags(_ signature: CommandSignature) -> CommandSignature {
CommandSignature(
arguments: signature.arguments,
options: signature.options,
flags: signature.flags + runtimeFlags(),
optionGroups: signature.optionGroups
)
}
}

View File

@ -0,0 +1,19 @@
import Commander
struct CommandSpec: @unchecked Sendable {
let name: String
let abstract: String
let discussion: String?
let signature: CommandSignature
let usageExamples: [String]
let run: (ParsedValues, RuntimeOptions) async throws -> Void
var descriptor: CommandDescriptor {
CommandDescriptor(
name: name,
abstract: abstract,
discussion: discussion,
signature: signature
)
}
}

View File

@ -0,0 +1,81 @@
import Commander
import Foundation
import RemindCore
enum AddCommand {
static var spec: CommandSpec {
CommandSpec(
name: "add",
abstract: "Add a reminder",
discussion: "Provide a title as an argument or via --title.",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
arguments: [
.make(label: "title", help: "Reminder title", isOptional: true)
],
options: [
.make(label: "title", names: [.long("title")], help: "Reminder title", parsing: .singleValue),
.make(label: "list", names: [.short("l"), .long("list")], help: "List name", parsing: .singleValue),
.make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue),
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue),
.make(
label: "priority",
names: [.short("p"), .long("priority")],
help: "none|low|medium|high",
parsing: .singleValue
),
]
)
),
usageExamples: [
"remindctl add \"Buy milk\"",
"remindctl add --title \"Call mom\" --list Personal --due tomorrow",
"remindctl add \"Review docs\" --priority high",
]
) { values, runtime in
let titleOption = values.option("title")
let titleArg = values.argument(0)
if titleOption != nil && titleArg != nil {
throw RemindCoreError.operationFailed("Provide title either as argument or via --title")
}
var title = titleOption ?? titleArg
if title == nil {
if runtime.noInput || !Console.isTTY {
throw RemindCoreError.operationFailed("Missing title. Provide it as an argument or via --title.")
}
title = Console.readLine(prompt: "Title:")?.trimmingCharacters(in: .whitespacesAndNewlines)
if title?.isEmpty == true { title = nil }
}
guard let title else {
throw RemindCoreError.operationFailed("Missing title.")
}
let listName = values.option("list")
let notes = values.option("notes")
let dueValue = values.option("due")
let priorityValue = values.option("priority")
let dueDate = try dueValue.map(CommandHelpers.parseDueDate)
let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none
let store = RemindersStore()
try await store.requestAccess()
let targetList: String?
if let listName {
targetList = listName
} else {
targetList = await store.defaultListName()
}
guard let targetList else {
throw RemindCoreError.operationFailed("No default list found. Specify --list.")
}
let draft = ReminderDraft(title: title, notes: notes, dueDate: dueDate, priority: priority)
let reminder = try await store.createReminder(draft, listName: targetList)
OutputRenderer.printReminder(reminder, format: runtime.outputFormat)
}
}
}

View File

@ -0,0 +1,46 @@
import Commander
import Foundation
import RemindCore
enum CompleteCommand {
static var spec: CommandSpec {
CommandSpec(
name: "complete",
abstract: "Mark reminders complete",
discussion: "Use indexes or ID prefixes from show output.",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
arguments: [
.make(label: "ids", help: "Indexes or ID prefixes", isOptional: true)
],
flags: [
.make(label: "dryRun", names: [.short("n"), .long("dry-run")], help: "Preview without changes")
]
)
),
usageExamples: [
"remindctl complete 1",
"remindctl complete 1 2 3",
"remindctl complete 4A83",
]
) { values, runtime in
let inputs = values.positional
guard !inputs.isEmpty else {
throw ParsedValuesError.missingArgument("ids")
}
let store = RemindersStore()
try await store.requestAccess()
let reminders = try await store.reminders(in: nil)
let resolved = try IDResolver.resolve(inputs, from: reminders)
if values.flag("dryRun") {
OutputRenderer.printReminders(resolved, format: runtime.outputFormat)
return
}
let updated = try await store.completeReminders(ids: resolved.map { $0.id })
OutputRenderer.printReminders(updated, format: runtime.outputFormat)
}
}
}

View File

@ -0,0 +1,54 @@
import Commander
import Foundation
import RemindCore
enum DeleteCommand {
static var spec: CommandSpec {
CommandSpec(
name: "delete",
abstract: "Delete reminders",
discussion: "Use indexes or ID prefixes from show output.",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
arguments: [
.make(label: "ids", help: "Indexes or ID prefixes", isOptional: true)
],
flags: [
.make(label: "dryRun", names: [.short("n"), .long("dry-run")], help: "Preview without changes"),
.make(label: "force", names: [.short("f"), .long("force")], help: "Skip confirmation"),
]
)
),
usageExamples: [
"remindctl delete 1",
"remindctl delete 4A83",
"remindctl delete 1 2 3 --force",
]
) { values, runtime in
let inputs = values.positional
guard !inputs.isEmpty else {
throw ParsedValuesError.missingArgument("ids")
}
let store = RemindersStore()
try await store.requestAccess()
let reminders = try await store.reminders(in: nil)
let resolved = try IDResolver.resolve(inputs, from: reminders)
if values.flag("dryRun") {
OutputRenderer.printReminders(resolved, format: runtime.outputFormat)
return
}
if !values.flag("force") && !runtime.noInput && Console.isTTY {
let prompt = "Delete \(resolved.count) reminder(s)?"
if !Console.confirm(prompt, defaultValue: false) {
return
}
}
let count = try await store.deleteReminders(ids: resolved.map { $0.id })
OutputRenderer.printDeleteResult(count, format: runtime.outputFormat)
}
}
}

View File

@ -0,0 +1,98 @@
import Commander
import Foundation
import RemindCore
enum EditCommand {
static var spec: CommandSpec {
CommandSpec(
name: "edit",
abstract: "Edit a reminder",
discussion: "Use an index or ID prefix from the show output.",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
arguments: [
.make(label: "id", help: "Index or ID prefix", isOptional: false)
],
options: [
.make(label: "title", names: [.short("t"), .long("title")], help: "New title", parsing: .singleValue),
.make(label: "list", names: [.short("l"), .long("list")], help: "Move to list", parsing: .singleValue),
.make(label: "due", names: [.short("d"), .long("due")], help: "Set due date", parsing: .singleValue),
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Set notes", parsing: .singleValue),
.make(
label: "priority",
names: [.short("p"), .long("priority")],
help: "none|low|medium|high",
parsing: .singleValue
),
],
flags: [
.make(label: "clearDue", names: [.long("clear-due")], help: "Clear due date"),
.make(label: "complete", names: [.long("complete")], help: "Mark completed"),
.make(label: "incomplete", names: [.long("incomplete")], help: "Mark incomplete"),
]
)
),
usageExamples: [
"remindctl edit 1 --title \"New title\"",
"remindctl edit 4A83 --due tomorrow",
"remindctl edit 2 --priority high --notes \"Call before noon\"",
"remindctl edit 3 --clear-due",
]
) { values, runtime in
guard let input = values.argument(0) else {
throw ParsedValuesError.missingArgument("id")
}
let store = RemindersStore()
try await store.requestAccess()
let reminders = try await store.reminders(in: nil)
let resolved = try IDResolver.resolve([input], from: reminders)
guard let reminder = resolved.first else {
throw RemindCoreError.reminderNotFound(input)
}
let title = values.option("title")
let listName = values.option("list")
let notes = values.option("notes")
var dueUpdate: Date??
if let dueValue = values.option("due") {
dueUpdate = try CommandHelpers.parseDueDate(dueValue)
}
if values.flag("clearDue") {
if dueUpdate != nil {
throw RemindCoreError.operationFailed("Use either --due or --clear-due, not both")
}
dueUpdate = .some(nil)
}
var priority: ReminderPriority?
if let priorityValue = values.option("priority") {
priority = try CommandHelpers.parsePriority(priorityValue)
}
let completeFlag = values.flag("complete")
let incompleteFlag = values.flag("incomplete")
if completeFlag && incompleteFlag {
throw RemindCoreError.operationFailed("Use either --complete or --incomplete, not both")
}
let isCompleted: Bool? = completeFlag ? true : (incompleteFlag ? false : nil)
if title == nil && listName == nil && notes == nil && dueUpdate == nil && priority == nil && isCompleted == nil {
throw RemindCoreError.operationFailed("No changes specified")
}
let update = ReminderUpdate(
title: title,
notes: notes,
dueDate: dueUpdate,
priority: priority,
listName: listName,
isCompleted: isCompleted
)
let updated = try await store.updateReminder(id: reminder.id, update: update)
OutputRenderer.printReminder(updated, format: runtime.outputFormat)
}
}
}

View File

@ -0,0 +1,112 @@
import Commander
import Foundation
import RemindCore
enum ListCommand {
static var spec: CommandSpec {
CommandSpec(
name: "list",
abstract: "List reminder lists or show list contents",
discussion: "Without a name, shows all lists. With a name, shows reminders in that list.",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
arguments: [
.make(label: "name", help: "List name", isOptional: true)
],
options: [
.make(
label: "rename",
names: [.short("r"), .long("rename")],
help: "Rename the list",
parsing: .singleValue
)
],
flags: [
.make(label: "delete", names: [.short("d"), .long("delete")], help: "Delete the list"),
.make(label: "create", names: [.long("create")], help: "Create list if missing"),
.make(label: "force", names: [.short("f"), .long("force")], help: "Skip confirmation prompts"),
]
)
),
usageExamples: [
"remindctl list",
"remindctl list Work",
"remindctl list Work --rename Office",
"remindctl list Work --delete",
"remindctl list Projects --create",
]
) { values, runtime in
let name = values.argument(0)
let renameTo = values.option("rename")
let deleteList = values.flag("delete")
let createList = values.flag("create")
let force = values.flag("force")
let store = RemindersStore()
try await store.requestAccess()
if let name {
if deleteList {
if !force && !runtime.noInput && Console.isTTY {
if !Console.confirm("Delete list \"\(name)\"?", defaultValue: false) {
return
}
}
try await store.deleteList(name: name)
if runtime.outputFormat == .standard {
Swift.print("Deleted list \"\(name)\"")
}
return
}
if let renameTo {
try await store.renameList(oldName: name, newName: renameTo)
if runtime.outputFormat == .standard {
Swift.print("Renamed list \"\(name)\" -> \"\(renameTo)\"")
}
return
}
if createList {
let list = try await store.createList(name: name)
if runtime.outputFormat == .json {
OutputRenderer.printLists(
[ListSummary(id: list.id, title: list.title, reminderCount: 0, overdueCount: 0)],
format: runtime.outputFormat
)
} else if runtime.outputFormat == .standard {
Swift.print("Created list \"\(list.title)\"")
}
return
}
let reminders = try await store.reminders(in: name)
OutputRenderer.printReminders(reminders, format: runtime.outputFormat)
return
}
let lists = await store.lists()
let reminders = try await store.reminders(in: nil)
let startOfToday = Calendar.current.startOfDay(for: Date())
var counts: [String: (total: Int, overdue: Int)] = [:]
for reminder in reminders where !reminder.isCompleted {
let entry = counts[reminder.listID] ?? (0, 0)
let overdue = (reminder.dueDate.map { $0 < startOfToday } ?? false) ? 1 : 0
counts[reminder.listID] = (entry.total + 1, entry.overdue + overdue)
}
let summaries = lists.map { list in
let entry = counts[list.id] ?? (0, 0)
return ListSummary(
id: list.id,
title: list.title,
reminderCount: entry.total,
overdueCount: entry.overdue
)
}
OutputRenderer.printLists(summaries, format: runtime.outputFormat)
}
}
}

View File

@ -0,0 +1,58 @@
import Commander
import Foundation
import RemindCore
enum ShowCommand {
static var spec: CommandSpec {
CommandSpec(
name: "show",
abstract: "Show reminders",
discussion: "Filters: today, tomorrow, week, overdue, upcoming, completed, all, or a date string.",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
arguments: [
.make(
label: "filter",
help: "today|tomorrow|week|overdue|upcoming|completed|all|<date>",
isOptional: true
)
],
options: [
.make(
label: "list",
names: [.short("l"), .long("list")],
help: "Limit to a specific list",
parsing: .singleValue
)
]
)
),
usageExamples: [
"remindctl",
"remindctl today",
"remindctl show overdue",
"remindctl show 2026-01-04",
"remindctl show --list Work",
]
) { values, runtime in
let listName = values.option("list")
let filterToken = values.argument(0)
let filter: ReminderFilter
if let token = filterToken {
guard let parsed = ReminderFiltering.parse(token) else {
throw RemindCoreError.operationFailed("Unknown filter: \"\(token)\"")
}
filter = parsed
} else {
filter = .today
}
let store = RemindersStore()
try await store.requestAccess()
let reminders = try await store.reminders(in: listName)
let filtered = ReminderFiltering.apply(reminders, filter: filter)
OutputRenderer.printReminders(filtered, format: runtime.outputFormat)
}
}
}

View File

@ -0,0 +1,41 @@
import Foundation
struct Console {
static var isTTY: Bool {
isatty(STDIN_FILENO) != 0
}
static func readLine(prompt: String) -> String? {
Swift.print(prompt, terminator: " ")
return Swift.readLine()
}
static func confirm(_ prompt: String, defaultValue: Bool = false) -> Bool {
let suffix = defaultValue ? "[Y/n]" : "[y/N]"
guard let input = readLine(prompt: "\(prompt) \(suffix)")?.trimmingCharacters(in: .whitespacesAndNewlines),
!input.isEmpty
else {
return defaultValue
}
switch input.lowercased() {
case "y", "yes":
return true
case "n", "no":
return false
default:
return defaultValue
}
}
static func printError(_ message: String) {
var stderr = StandardErrorOutputStream()
Swift.print(message, to: &stderr)
}
}
struct StandardErrorOutputStream: TextOutputStream {
mutating func write(_ string: String) {
guard let data = string.data(using: .utf8) else { return }
FileHandle.standardError.write(data)
}
}

View File

@ -0,0 +1,108 @@
import Commander
import Foundation
struct HelpPrinter {
static func printRoot(version: String, rootName: String, commands: [CommandSpec]) {
for line in renderRoot(version: version, rootName: rootName, commands: commands) {
Swift.print(line)
}
}
static func printCommand(rootName: String, spec: CommandSpec) {
for line in renderCommand(rootName: rootName, spec: spec) {
Swift.print(line)
}
}
static func renderRoot(version: String, rootName: String, commands: [CommandSpec]) -> [String] {
var lines: [String] = []
lines.append("\(rootName) \(version)")
lines.append("Manage Apple Reminders from the terminal")
lines.append("")
lines.append("Usage:")
lines.append(" \(rootName) [command] [options]")
lines.append("")
lines.append("Commands:")
for command in commands {
lines.append(" \(command.name)\t\(command.abstract)")
}
lines.append("")
lines.append("Run '\(rootName) <command> --help' for details.")
return lines
}
static func renderCommand(rootName: String, spec: CommandSpec) -> [String] {
var lines: [String] = []
lines.append("\(rootName) \(spec.name)")
lines.append(spec.abstract)
if let discussion = spec.discussion, !discussion.isEmpty {
lines.append("\n\(discussion)")
}
lines.append("")
lines.append("Usage:")
lines.append(" \(rootName) \(spec.name) \(usageFragment(for: spec.signature))")
lines.append("")
if !spec.signature.arguments.isEmpty {
lines.append("Arguments:")
for arg in spec.signature.arguments {
let optionalMark = arg.isOptional ? "?" : ""
lines.append(" \(arg.label)\(optionalMark)\t\(arg.help ?? "")")
}
lines.append("")
}
let options = spec.signature.options
let flags = spec.signature.flags
if !options.isEmpty || !flags.isEmpty {
lines.append("Options:")
for option in options {
let names = formatNames(option.names, expectsValue: true)
lines.append(" \(names)\t\(option.help ?? "")")
}
for flag in flags {
let names = formatNames(flag.names, expectsValue: false)
lines.append(" \(names)\t\(flag.help ?? "")")
}
lines.append("")
}
if !spec.usageExamples.isEmpty {
lines.append("Examples:")
for example in spec.usageExamples {
lines.append(" \(example)")
}
}
return lines
}
private static func usageFragment(for signature: CommandSignature) -> String {
var parts: [String] = []
for argument in signature.arguments {
let token = argument.isOptional ? "[\(argument.label)]" : "<\(argument.label)>"
parts.append(token)
}
if !signature.options.isEmpty || !signature.flags.isEmpty {
parts.append("[options]")
}
return parts.joined(separator: " ")
}
private static func formatNames(_ names: [CommanderName], expectsValue: Bool) -> String {
let parts = names.map { name -> String in
switch name {
case .short(let char):
return "-\(char)"
case .long(let value):
return "--\(value)"
case .aliasShort(let char):
return "-\(char)"
case .aliasLong(let value):
return "--\(value)"
}
}
let suffix = expectsValue ? " <value>" : ""
return parts.joined(separator: ", ") + suffix
}
}

View File

@ -0,0 +1,142 @@
import Foundation
import RemindCore
enum OutputFormat {
case standard
case plain
case json
case quiet
}
struct ListSummary: Codable, Sendable, Equatable {
let id: String
let title: String
let reminderCount: Int
let overdueCount: Int
}
enum OutputRenderer {
static func printReminders(_ reminders: [ReminderItem], format: OutputFormat) {
switch format {
case .standard:
printRemindersStandard(reminders)
case .plain:
printRemindersPlain(reminders)
case .json:
printJSON(reminders)
case .quiet:
Swift.print(reminders.count)
}
}
static func printLists(_ summaries: [ListSummary], format: OutputFormat) {
switch format {
case .standard:
printListsStandard(summaries)
case .plain:
printListsPlain(summaries)
case .json:
printJSON(summaries)
case .quiet:
Swift.print(summaries.count)
}
}
static func printReminder(_ reminder: ReminderItem, format: OutputFormat) {
switch format {
case .standard:
let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date"
Swift.print("\(reminder.title) [\(reminder.listName)] — \(due)")
case .plain:
Swift.print(plainLine(for: reminder))
case .json:
printJSON(reminder)
case .quiet:
break
}
}
static func printDeleteResult(_ count: Int, format: OutputFormat) {
switch format {
case .standard:
Swift.print("Deleted \(count) reminder(s)")
case .plain:
Swift.print("\(count)")
case .json:
let payload = ["deleted": count]
printJSON(payload)
case .quiet:
break
}
}
private static func printRemindersStandard(_ reminders: [ReminderItem]) {
let sorted = ReminderFiltering.sort(reminders)
guard !sorted.isEmpty else {
Swift.print("No reminders found")
return
}
for (index, reminder) in sorted.enumerated() {
let status = reminder.isCompleted ? "x" : " "
let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date"
let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)"
Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)")
}
}
private static func printRemindersPlain(_ reminders: [ReminderItem]) {
let sorted = ReminderFiltering.sort(reminders)
for reminder in sorted {
Swift.print(plainLine(for: reminder))
}
}
private static func plainLine(for reminder: ReminderItem) -> String {
let due = reminder.dueDate.map { isoFormatter().string(from: $0) } ?? ""
return [
reminder.id,
reminder.listName,
reminder.isCompleted ? "1" : "0",
reminder.priority.rawValue,
due,
reminder.title,
].joined(separator: "\t")
}
private static func printListsStandard(_ summaries: [ListSummary]) {
guard !summaries.isEmpty else {
Swift.print("No reminder lists found")
return
}
for summary in summaries.sorted(by: { $0.title < $1.title }) {
let overdue = summary.overdueCount > 0 ? " (\(summary.overdueCount) overdue)" : ""
Swift.print("\(summary.title)\(summary.reminderCount) reminders\(overdue)")
}
}
private static func printListsPlain(_ summaries: [ListSummary]) {
for summary in summaries.sorted(by: { $0.title < $1.title }) {
Swift.print("\(summary.title)\t\(summary.reminderCount)\t\(summary.overdueCount)")
}
}
private static func printJSON<T: Encodable>(_ payload: T) {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
encoder.dateEncodingStrategy = .iso8601
do {
let data = try encoder.encode(payload)
if let json = String(data: data, encoding: .utf8) {
Swift.print(json)
}
} catch {
Swift.print("Failed to encode JSON: \(error)")
}
}
private static func isoFormatter() -> ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}
}

View File

@ -0,0 +1,49 @@
import Commander
import Foundation
enum ParsedValuesError: LocalizedError, CustomStringConvertible {
case missingOption(String)
case invalidOption(String)
case missingArgument(String)
var description: String {
switch self {
case .missingOption(let name):
return "Missing required option: --\(name)"
case .invalidOption(let name):
return "Invalid value for option: --\(name)"
case .missingArgument(let name):
return "Missing required argument: \(name)"
}
}
var errorDescription: String? {
description
}
}
extension ParsedValues {
func flag(_ label: String) -> Bool {
flags.contains(label)
}
func option(_ label: String) -> String? {
options[label]?.last
}
func optionValues(_ label: String) -> [String] {
options[label] ?? []
}
func optionRequired(_ label: String) throws -> String {
guard let value = option(label), !value.isEmpty else {
throw ParsedValuesError.missingOption(label)
}
return value
}
func argument(_ index: Int) -> String? {
guard positional.indices.contains(index) else { return nil }
return positional[index]
}
}

View File

@ -0,0 +1,9 @@
import Foundation
@main
enum RemindctlMain {
static func main() async {
let code = await CommandRouter().run()
exit(code)
}
}

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.steipete.remindctl</string>
<key>CFBundleName</key>
<string>remindctl</string>
<key>CFBundleExecutable</key>
<string>remindctl</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>NSRemindersUsageDescription</key>
<string>Manage your reminders from the terminal.</string>
</dict>
</plist>

View File

@ -0,0 +1,24 @@
import Commander
struct RuntimeOptions: Sendable {
let jsonOutput: Bool
let plainOutput: Bool
let quiet: Bool
let noColor: Bool
let noInput: Bool
init(parsedValues: ParsedValues) {
self.jsonOutput = parsedValues.flags.contains("jsonOutput")
self.plainOutput = parsedValues.flags.contains("plainOutput")
self.quiet = parsedValues.flags.contains("quiet")
self.noColor = parsedValues.flags.contains("noColor")
self.noInput = parsedValues.flags.contains("noInput")
}
var outputFormat: OutputFormat {
if jsonOutput { return .json }
if plainOutput { return .plain }
if quiet { return .quiet }
return .standard
}
}

View File

@ -0,0 +1,4 @@
// Generated by scripts/generate-version.sh. Do not edit.
enum RemindctlVersion {
static let current = "0.1.0"
}

View File

@ -0,0 +1,46 @@
import Foundation
import Testing
@testable import RemindCore
@MainActor
struct DateParsingTests {
private let calendar: Calendar = {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current
return calendar
}()
@Test("Relative date parsing")
func relativeDates() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let today = DateParsing.parseUserDate("today", now: now, calendar: calendar)
let tomorrow = DateParsing.parseUserDate("tomorrow", now: now, calendar: calendar)
let yesterday = DateParsing.parseUserDate("yesterday", now: now, calendar: calendar)
#expect(today == calendar.startOfDay(for: now))
#expect(tomorrow == calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)))
#expect(yesterday == calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now)))
}
@Test("ISO 8601 parsing")
func isoParsing() {
let input = "2026-01-03T12:34:56Z"
let parsed = DateParsing.parseUserDate(input)
#expect(parsed != nil)
}
@Test("Formatted date parsing")
func formattedParsing() {
let input = "2026-01-03 10:30"
let parsed = DateParsing.parseUserDate(input)
#expect(parsed != nil)
}
@Test("Format display output")
func displayFormatting() {
let date = Date(timeIntervalSince1970: 1_700_000_000)
let output = DateParsing.formatDisplay(date, calendar: calendar)
#expect(output.isEmpty == false)
}
}

View File

@ -0,0 +1,19 @@
import Testing
@testable import RemindCore
@MainActor
struct ErrorsTests {
@Test("Error descriptions")
func descriptions() {
#expect(RemindCoreError.accessDenied.localizedDescription.contains("Reminders"))
#expect(RemindCoreError.writeOnlyAccess.localizedDescription.contains("write-only"))
#expect(RemindCoreError.listNotFound("Work").localizedDescription.contains("Work"))
#expect(RemindCoreError.reminderNotFound("abc").localizedDescription.contains("abc"))
#expect(RemindCoreError.ambiguousIdentifier("a", matches: ["1", "2"]).localizedDescription.contains("matches"))
#expect(RemindCoreError.invalidIdentifier("x").localizedDescription.contains("Invalid identifier"))
#expect(RemindCoreError.invalidDate("bad").localizedDescription.contains("Invalid date"))
#expect(RemindCoreError.unsupported("nope").localizedDescription.contains("nope"))
#expect(RemindCoreError.operationFailed("fail").localizedDescription.contains("fail"))
}
}

View File

@ -0,0 +1,53 @@
import Foundation
import Testing
@testable import RemindCore
@MainActor
struct IDResolverTests {
private func sampleReminders() -> [ReminderItem] {
[
ReminderItem(
id: "abcd1234",
title: "First",
notes: nil,
isCompleted: false,
completionDate: nil,
priority: .none,
dueDate: Date(timeIntervalSince1970: 1_700_000_000),
listID: "list1",
listName: "Work"
),
ReminderItem(
id: "abce5678",
title: "Second",
notes: nil,
isCompleted: false,
completionDate: nil,
priority: .none,
dueDate: Date(timeIntervalSince1970: 1_700_000_100),
listID: "list1",
listName: "Work"
),
]
}
@Test("Resolve by index")
func resolveIndex() throws {
let resolved = try IDResolver.resolve(["1"], from: sampleReminders())
#expect(resolved.first?.title == "First")
}
@Test("Resolve by prefix")
func resolvePrefix() throws {
let resolved = try IDResolver.resolve(["abcd"], from: sampleReminders())
#expect(resolved.first?.title == "First")
}
@Test("Reject short prefix")
func rejectShortPrefix() {
#expect(throws: RemindCoreError.invalidIdentifier("ab")) {
_ = try IDResolver.resolve(["ab"], from: sampleReminders())
}
}
}

View File

@ -0,0 +1,17 @@
import Testing
@testable import RemindCore
@MainActor
struct PriorityTests {
@Test("EventKit priority mapping")
func mapping() {
#expect(ReminderPriority(eventKitValue: 0) == .none)
#expect(ReminderPriority(eventKitValue: 1) == .high)
#expect(ReminderPriority(eventKitValue: 5) == .medium)
#expect(ReminderPriority(eventKitValue: 9) == .low)
#expect(ReminderPriority.high.eventKitValue == 1)
#expect(ReminderPriority.medium.eventKitValue == 5)
#expect(ReminderPriority.low.eventKitValue == 9)
}
}

View File

@ -0,0 +1,22 @@
import Testing
@testable import RemindCore
@MainActor
struct ReminderFilterParseTests {
@Test("Parse filter aliases")
func parseAliases() {
#expect(ReminderFiltering.parse("t") == .tomorrow)
#expect(ReminderFiltering.parse("w") == .week)
#expect(ReminderFiltering.parse("o") == .overdue)
#expect(ReminderFiltering.parse("u") == .upcoming)
#expect(ReminderFiltering.parse("done") == .completed)
#expect(ReminderFiltering.parse("all") == .all)
}
@Test("Parse date filter")
func parseDate() {
let parsed = ReminderFiltering.parse("2026-01-03")
#expect(parsed != nil)
}
}

View File

@ -0,0 +1,146 @@
import Foundation
import Testing
@testable import RemindCore
@MainActor
struct ReminderFilteringTests {
private let calendar: Calendar = {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current
return calendar
}()
private func reminders(now: Date) -> [ReminderItem] {
let today = calendar.startOfDay(for: now)
let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? today
return [
ReminderItem(
id: "1",
title: "Overdue",
notes: nil,
isCompleted: false,
completionDate: nil,
priority: .none,
dueDate: yesterday,
listID: "a",
listName: "Home"
),
ReminderItem(
id: "2",
title: "Today",
notes: nil,
isCompleted: false,
completionDate: nil,
priority: .none,
dueDate: today,
listID: "a",
listName: "Home"
),
ReminderItem(
id: "3",
title: "Tomorrow",
notes: nil,
isCompleted: false,
completionDate: nil,
priority: .none,
dueDate: tomorrow,
listID: "a",
listName: "Home"
),
ReminderItem(
id: "5",
title: "No Due",
notes: nil,
isCompleted: false,
completionDate: nil,
priority: .none,
dueDate: nil,
listID: "a",
listName: "Home"
),
ReminderItem(
id: "4",
title: "Completed",
notes: nil,
isCompleted: true,
completionDate: now,
priority: .none,
dueDate: today,
listID: "a",
listName: "Home"
),
]
}
@Test("Today filter includes overdue")
func todayIncludesOverdue() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let items = reminders(now: now)
let result = ReminderFiltering.apply(items, filter: .today, now: now, calendar: calendar)
#expect(result.count == 2)
}
@Test("Tomorrow filter")
func tomorrowFilter() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let items = reminders(now: now)
let result = ReminderFiltering.apply(items, filter: .tomorrow, now: now, calendar: calendar)
#expect(result.count == 1)
#expect(result.first?.title == "Tomorrow")
}
@Test("Overdue filter")
func overdueFilter() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let items = reminders(now: now)
let result = ReminderFiltering.apply(items, filter: .overdue, now: now, calendar: calendar)
#expect(result.count == 1)
#expect(result.first?.title == "Overdue")
}
@Test("Upcoming filter ignores no due date")
func upcomingFilter() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let items = reminders(now: now)
let result = ReminderFiltering.apply(items, filter: .upcoming, now: now, calendar: calendar)
#expect(result.count == 3)
}
@Test("Date filter")
func dateFilter() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let items = reminders(now: now)
let today = calendar.startOfDay(for: now)
let result = ReminderFiltering.apply(items, filter: .date(today), now: now, calendar: calendar)
#expect(result.count == 1)
#expect(result.first?.title == "Today")
}
@Test("All filter includes completed")
func allFilter() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let items = reminders(now: now)
let result = ReminderFiltering.apply(items, filter: .all, now: now, calendar: calendar)
#expect(result.count == items.count)
}
@Test("Sort orders by due date then title")
func sortOrder() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let items = reminders(now: now)
let sorted = ReminderFiltering.sort(items)
#expect(sorted.first?.title == "Overdue")
#expect(sorted.last?.title == "No Due")
}
@Test("Completed filter")
func completedFilter() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let items = reminders(now: now)
let result = ReminderFiltering.apply(items, filter: .completed, now: now, calendar: calendar)
#expect(result.count == 1)
#expect(result.first?.title == "Completed")
}
}

View File

@ -0,0 +1,20 @@
import Testing
@testable import remindctl
@MainActor
struct HelpPrinterTests {
@Test("Root help includes commands")
func rootHelp() {
let specs = [
ShowCommand.spec,
ListCommand.spec,
AddCommand.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"))
}
}

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "remindctl",
"version": "0.1.0",
"private": true,
"scripts": {
"version:sync": "scripts/generate-version.sh",
"remindctl": "pnpm -s version:sync && 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",
"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"
}
}

84
scripts/check-coverage.sh Executable file
View File

@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
CACHE_PATH="${HOME}/Library/Caches/remindctl/swiftpm"
COVERAGE_BUILD_PATH="${ROOT_DIR}/.build/coverage"
mkdir -p "${CACHE_PATH}"
MIN_COVERAGE="${COVERAGE_MIN:-80}"
INCLUDE_REGEX="${COVERAGE_INCLUDE_REGEX:-/Sources/RemindCore/}"
EXCLUDE_REGEX="${COVERAGE_EXCLUDE_REGEX:-/Sources/RemindCore/EventKitStore.swift}"
echo "==> swift test --enable-code-coverage (isolated build dir)"
swift test --enable-code-coverage --build-path "${COVERAGE_BUILD_PATH}" --cache-path "${CACHE_PATH}" >/dev/null
REPORT_JSON="$(
find "${COVERAGE_BUILD_PATH}" -type f -path "*debug/codecov/remindctl.json" -print0 2>/dev/null \
| xargs -0 ls -t 2>/dev/null \
| head -n 1
)"
if [ -z "${REPORT_JSON}" ] || [ ! -f "${REPORT_JSON}" ]; then
echo "ERROR: Coverage report not found (expected .build/**/debug/codecov/remindctl.json)." >&2
exit 1
fi
python3 - "$REPORT_JSON" "$INCLUDE_REGEX" "$EXCLUDE_REGEX" "$MIN_COVERAGE" <<'PY'
import json
import os
import re
import sys
report_path, include_re, exclude_re, min_str = sys.argv[1:5]
min_coverage = float(min_str)
with open(report_path, "r", encoding="utf-8") as f:
obj = json.load(f)
files = obj["data"][0]["files"]
include = re.compile(include_re)
exclude = re.compile(exclude_re) if exclude_re else None
selected = []
for item in files:
filename = item["filename"]
if not include.search(filename):
continue
if exclude and exclude.search(filename):
continue
summary = item.get("summary", {}).get("lines", {})
count = int(summary.get("count", 0))
covered = int(summary.get("covered", 0))
selected.append((filename, covered, count))
if not selected:
print(f"ERROR: No files matched coverage include regex: {include_re}", file=sys.stderr)
print(f" exclude regex: {exclude_re or '(none)'}", file=sys.stderr)
sys.exit(1)
total_lines = sum(count for _, _, count in selected)
total_covered = sum(covered for _, covered, _ in selected)
percent = (total_covered / total_lines * 100.0) if total_lines else 0.0
repo_root = os.getcwd() + os.sep
def rel(path: str) -> str:
return path[len(repo_root):] if path.startswith(repo_root) else path
print(f"==> Coverage (lines): {percent:.1f}% ({total_covered}/{total_lines})")
print(f" Scope: include={include_re} exclude={exclude_re or '(none)'}")
print(f" Min: {min_coverage:.1f}%")
worst = sorted(selected, key=lambda t: (t[1] / t[2] if t[2] else 0.0, -t[2]))[:10]
print(" Lowest covered files:")
for filename, covered, count in worst:
p = (covered / count * 100.0) if count else 0.0
print(f" - {p:5.1f}% {covered:4d}/{count:4d} {rel(filename)}")
if percent + 1e-9 < min_coverage:
sys.exit(2)
PY

37
scripts/generate-version.sh Executable file
View File

@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
source "$ROOT/version.env"
OUTPUT="$ROOT/Sources/remindctl/Version.swift"
PLIST_OUTPUT="$ROOT/Sources/remindctl/Resources/Info.plist"
mkdir -p "$(dirname "$OUTPUT")"
mkdir -p "$(dirname "$PLIST_OUTPUT")"
cat > "$OUTPUT" <<SWIFT
// Generated by scripts/generate-version.sh. Do not edit.
enum RemindctlVersion {
static let current = "${MARKETING_VERSION}"
}
SWIFT
cat > "$PLIST_OUTPUT" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.steipete.remindctl</string>
<key>CFBundleName</key>
<string>remindctl</string>
<key>CFBundleExecutable</key>
<string>remindctl</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${MARKETING_VERSION}</string>
<key>CFBundleVersion</key>
<string>${MARKETING_VERSION}</string>
<key>NSRemindersUsageDescription</key>
<string>Manage your reminders from the terminal.</string>
</dict>
</plist>
PLIST

1
version.env Normal file
View File

@ -0,0 +1 @@
MARKETING_VERSION=0.1.0