Compare commits

..

No commits in common. "main" and "v0.1.1" have entirely different histories.
main ... v0.1.1

38 changed files with 153 additions and 1298 deletions

View File

@ -9,7 +9,7 @@ jobs:
build:
runs-on: macos-15
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Swift version
run: swift --version
- name: Install SwiftLint

View File

@ -16,7 +16,7 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
@ -56,7 +56,7 @@ jobs:
)
- name: Publish release assets
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
files: dist/remindctl-macos.zip
env:
@ -83,57 +83,3 @@ jobs:
fi
gh release edit "$TAG" --notes-file "$notes_file"
update-homebrew-tap:
runs-on: ubuntu-latest
needs: release
steps:
- name: Resolve release tag
run: echo "RELEASE_TAG=${{ inputs.tag }}" >> "$GITHUB_ENV"
- name: Dispatch tap formula update
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::Set HOMEBREW_TAP_TOKEN with workflow access to steipete/homebrew-tap"
exit 1
fi
request_id="remindctl-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
expected_title="Update remindctl for ${RELEASE_TAG} (${request_id})"
gh workflow run update-formula.yml \
--repo steipete/homebrew-tap \
--ref main \
-f formula=remindctl \
-f tag="$RELEASE_TAG" \
-f repository=steipete/remindctl \
-f macos_artifact=remindctl-macos.zip \
-f request_id="$request_id"
run_id=""
for _ in {1..30}; do
run_id=$(gh run list \
--repo steipete/homebrew-tap \
--workflow update-formula.yml \
--branch main \
--event workflow_dispatch \
--limit 20 \
--json databaseId,displayTitle \
--jq ".[] | select(.displayTitle == \"$expected_title\") | .databaseId" | head -n1)
if [ -n "$run_id" ]; then
break
fi
sleep 5
done
if [ -z "$run_id" ]; then
echo "::error::Could not find tap workflow run with title: $expected_title"
exit 1
fi
gh run watch "$run_id" \
--repo steipete/homebrew-tap \
--exit-status \
--interval 10

View File

@ -1,21 +1,5 @@
# Changelog
## Unreleased
- Resolve numeric edit/complete/delete indexes against the default `show` view instead of unrelated completed reminders.
- Add a release helper for Homebrew tap updates; thanks @dinakars777.
## 0.2.0 - 2026-05-04
- Add location-based reminder triggers via `--location`, `--leaving`, and `--radius`
- Add simple recurrence support via `--repeat` and `--no-repeat`
- Add EventKit alarm support via `--alarm` and `--clear-alarm`
- Add reminder `url` to JSON output when EventKit exposes one
- Add `lastModifiedDate` to reminder JSON output
- Add `creationDate` to reminder JSON output
- Add `open` filter for all incomplete reminders
- Accept local ISO 8601 due dates without a timezone suffix
- Preserve date-only due inputs as all-day reminders instead of midnight reminders
- Allow `list` to show reminders from multiple list names in one command
## 0.1.1 - 2026-01-11
- Fix Swift 6 strict concurrency crash when fetching reminders

View File

@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b",
"version" : "0.2.2"
"revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02",
"version" : "0.2.0"
}
}
],

View File

@ -16,7 +16,6 @@ let package = Package(
name: "RemindCore",
dependencies: [],
linkerSettings: [
.linkedFramework("CoreLocation"),
.linkedFramework("EventKit"),
]
),

253
README.md
View File

@ -1,237 +1,74 @@
# remindctl
Fast command-line access to Apple Reminders on macOS.
Forget the app, not the task ✅
`remindctl` is for scripts, agents, and terminal workflows that need to read and update the same reminders you see in Reminders.app. It uses Apple's public EventKit APIs, so reminders keep syncing through the normal system/iCloud path.
Fast CLI for Apple Reminders on macOS.
## Install
### Homebrew
### Homebrew (Home Pro)
```bash
brew install steipete/tap/remindctl
```
### From Source
### From source
```bash
pnpm install
pnpm build
# binary at ./bin/remindctl
```
## Requirements
- macOS 14+ (Sonoma or later)
- Swift 6.2+ when building from source
- Full Reminders access for the terminal app that runs `remindctl`
## Quick Start
## Development
```bash
make remindctl ARGS="status" # clean build + run
make check # lint + test + coverage gate
```
## 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 "Call mom" --list Personal --due tomorrow
remindctl add "Meeting" --due "2026-01-03 09:00" --alarm "2026-01-03 08:55"
remindctl today
remindctl overdue
remindctl open
remindctl list Work Errands
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
```
Indexes such as `1` come from the default reminder listing. Most commands also accept an ID prefix such as `4A83`.
## Commands
| Command | Purpose |
| --- | --- |
| `remindctl` / `remindctl today` | Show today's reminders |
| `remindctl show <filter>` | Show reminders by filter or date |
| `remindctl list` | Show reminder lists |
| `remindctl list <name...>` | Show reminders from one or more lists |
| `remindctl add <title>` | Create a reminder |
| `remindctl edit <id>` | Edit a reminder by index or ID prefix |
| `remindctl complete <id...>` | Mark reminders complete |
| `remindctl delete <id...>` | Delete reminders |
| `remindctl status` | Show Reminders permission status |
| `remindctl authorize` | Request Reminders permission when macOS allows it |
Run `remindctl <command> --help` for the full option list.
## Showing Reminders
Common filters:
```bash
remindctl today
remindctl tomorrow
remindctl week
remindctl overdue
remindctl upcoming
remindctl open
remindctl completed
remindctl all
remindctl 2026-01-03
```
Limit a view to one list:
```bash
remindctl show overdue --list Work
```
Show multiple lists together:
```bash
remindctl list Work Errands
```
## Lists
```bash
remindctl list
remindctl list Work
remindctl list Projects --create
remindctl list Work --rename Office
remindctl list OldList --delete --force
```
Mutating list operations accept one list name. Read-only list views can accept multiple names.
## Dates And Due Times
Accepted by `--due` and date filters:
## 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 with timezone, such as `2026-01-03T12:34:56Z`
- Local ISO 8601 without timezone, such as `2026-01-03T12:34:56`
Date-only due values create all-day reminders. Date-time values create timed reminders.
## Alarms
Timed due reminders automatically get an EventKit notification alarm at the due time. Use `--alarm` to choose a different alarm time.
```bash
remindctl add "Meeting" --due "2026-01-03 09:00" --alarm "2026-01-03 08:55"
remindctl edit 4A83 --alarm "2026-01-03 08:55"
remindctl edit 4A83 --clear-alarm
```
This is public EventKit alarm support. Apple's private Reminders "Urgent" toggle is not exposed by EventKit.
## Repeat
Use `--repeat` with `add` or `edit` for simple recurrence:
```bash
remindctl add "Take vitamins" --due tomorrow --repeat daily
remindctl add "Water filter" --due "2026-09-13" --repeat "every 6 months"
remindctl edit 4A83 --repeat weekly
remindctl edit 4A83 --no-repeat
```
Supported repeat values:
- `daily`, `weekly`, `biweekly`, `monthly`, `yearly`
- `every N days/weeks/months/years`
## Location Triggers
Use `--location` on `add` to create an arriving geofence trigger. Add `--leaving` to trigger when leaving, and `--radius` to customize the geofence radius in meters.
```bash
remindctl add "Check mailbox" --location "1 Apple Park Way, Cupertino, CA"
remindctl add "Lock up" --location "Home" --leaving
remindctl add "Get groceries" --location "123 Main St" --radius 200
```
Location triggers use EventKit and CoreLocation geocoding. They may depend on system location services and network availability.
## Output
Global output flags:
- `--json` emits machine-readable JSON.
- `--plain` emits stable tab-separated lines.
- `--quiet` emits minimal output, usually counts or nothing.
- `--no-color` disables colored output.
- `--no-input` disables interactive prompts.
JSON includes public EventKit metadata when available:
- `creationDate`
- `lastModifiedDate`
- `url`
- `alarmDate`
- `locationTrigger`
- `recurrenceRule`
Example:
```bash
remindctl all --json
remindctl list --json
remindctl status --json
```
- ISO 8601 (`2026-01-03T12:34:56Z`)
## Permissions
Check access:
```bash
remindctl status
```
Request access:
```bash
remindctl authorize
```
If macOS reports access as denied, enable the terminal app in:
```text
System Settings > Privacy & Security > Reminders
```
If no prompt appears, run this once from the same terminal app:
```bash
osascript -e 'tell application "Reminders" to get name of reminders'
```
Then allow access and rerun:
```bash
remindctl status
```
When running over SSH, grant access on the Mac that actually runs `remindctl`.
## EventKit Limits
`remindctl` intentionally sticks to public EventKit APIs. These Reminders.app features are not exposed through EventKit today:
- Native Reminders sections
- Native Reminders tags and smart lists
- File/image attachments
- Apple's private "Urgent" toggle
Supporting those would require Apple to expose new public APIs or a separate non-EventKit backend.
## Development
```bash
make remindctl ARGS="status" # clean build + run
make check # lint + tests + coverage gate
pnpm build # release build into ./bin/remindctl
```
Release steps live in [docs/RELEASING.md](docs/RELEASING.md).
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.

167
SKILL.md
View File

@ -1,167 +0,0 @@
---
name: apple-reminders
description: Use remindctl to inspect and manage Apple Reminders on macOS 14+, including show/list/add/edit/complete/delete/status/authorize workflows, due dates, alarms, recurrence, and location triggers.
homepage: https://github.com/steipete/remindctl
---
# Apple Reminders
Use `remindctl` for Apple Reminders on macOS. It uses Apple's public EventKit APIs, so changes sync through the normal Reminders/iCloud path.
## Prerequisites
- macOS 14+ with Reminders.app
- `remindctl` installed and available on `PATH`
- Install with `brew install steipete/tap/remindctl`
- Reminders access for the terminal app that runs `remindctl`
- Use `remindctl status` to check permission state before mutating data
## Use This Skill When
- The user wants to manage Apple Reminders from the terminal
- The user asks for reminders, reminder lists, due dates, completion, deletion, or permission status
- The user wants reminders that appear on iPhone, iPad, or Mac via Apple Reminders
## Do Not Use This Skill When
- The user wants a non-Reminders agent alert, cron job, or timed chatbot reminder
- The user wants calendar events instead of reminders
- The user needs native Reminders sections, tags, smart lists, attachments, or Apple's private "Urgent" toggle
## Current Command Model
- `remindctl` defaults to `show today`
- `show` accepts filters: `today`, `tomorrow`, `week`, `overdue`, `upcoming`, `open`, `completed`, `all`, or a date string
- `show --list <name>` limits a view to one list
- `list` shows all lists with no arguments, or reminders in one or more named lists
- `add` creates a reminder
- `edit` updates a reminder by index or ID prefix
- `complete` marks reminders complete
- `delete` removes reminders
- `status` reports Reminders authorization without prompting
- `authorize` requests permission when possible
## Helpful Aliases
- `lists` and `ls` map to `list`
- `rm` maps to `delete`
- `done` maps to `complete`
## Output And Flags
- `--json`, `-j`, `--json-output`, and `--jsonOutput` emit JSON
- `--plain` emits stable tab-separated output
- `--quiet` emits minimal output
- `--no-color` disables colored output
- `--no-input` disables interactive prompts
Prefer `--json` when another step needs machine-readable data. JSON can include EventKit metadata such as `creationDate`, `lastModifiedDate`, `url`, `alarmDate`, `locationTrigger`, and `recurrenceRule`.
## Reading Reminder Data
- Use `remindctl today --json` or `remindctl show --json` to inspect reminders
- Use `remindctl open --json` for all incomplete reminders, including reminders without due dates
- Use `remindctl list --json` to inspect lists
- Use `remindctl list Work Errands --json` to inspect multiple lists together
- Use `remindctl status --json` to inspect authorization state
- Reminder IDs and display indexes come from `show` output; `complete`, `delete`, and `edit` accept either an index or an ID prefix
## Creating Reminders
`add` accepts the title as a positional argument or via `--title`, but not both.
Important options:
- `--list <name>` choose the target list
- `--due <date>` set the due date
- `--alarm <date>` set the alarm date
- `--notes <text>` add notes
- `--repeat <rule>` set simple recurrence
- `--priority <none|low|medium|high>` set priority
- `--location <address>` create a location trigger
- `--radius <meters>` adjust geofence radius
- `--leaving` trigger on leaving instead of arriving
If no list is provided, `remindctl` uses the Reminders app's default list. Do not assume the default is a specific list name. If the system has no default reminder list, specify `--list`.
Use `--location` whenever using `--radius` or `--leaving`; those flags are invalid on their own.
## Editing Reminders
`edit` can update:
- `--title`
- `--list`
- `--due` or `--clear-due`
- `--alarm` or `--clear-alarm`
- `--notes`
- `--repeat` or `--no-repeat`
- `--priority`
- `--complete` or `--incomplete`
Reject conflicting combinations such as `--due` with `--clear-due`, `--alarm` with `--clear-alarm`, `--repeat` with `--no-repeat`, or `--complete` with `--incomplete`.
Use `remindctl edit <id> --list <new-list>` to move a reminder between lists. Do not tell the user to delete and recreate the reminder for a move; the command already supports moving it directly.
## Dates
Accepted date inputs:
- `today`, `tomorrow`, `yesterday`
- `YYYY-MM-DD`
- `YYYY-MM-DD HH:mm`
- ISO 8601 with timezone, such as `2026-01-03T12:34:56Z`
- Local ISO 8601 without timezone, such as `2026-01-03T12:34:56`
Rules:
- Date-only inputs create all-day reminders
- Date-time inputs create timed reminders
- Timed due reminders get a notification alarm at the due time unless `--alarm` overrides it
If the user provides only a calendar date, prefer a date-only value instead of inventing a time.
Supported repeat values:
- `daily`, `weekly`, `biweekly`, `monthly`, `yearly`
- `every N days/weeks/months/years`
## Lists
- `remindctl list` prints all lists with reminder and overdue counts
- `remindctl list <name...>` prints reminders in one or more lists
- `remindctl list <name> --create` creates the list if missing
- `remindctl list <name> --delete` deletes the list
- `remindctl list <name> --rename <new-name>` renames the list
- `remindctl list <name> --force` skips confirmation for destructive list deletion
- Create, delete, and rename accept one list name only
## Completion And Deletion
- `complete` and `delete` require one or more IDs or indexes
- `delete` prompts for confirmation unless `--force` or `--no-input` suppresses it
- `complete` supports `--dry-run`
- `delete` supports `--dry-run`
## Permissions
Use `remindctl status` first when permission state matters.
- `status` never prompts
- `authorize` triggers the system prompt when the state is `notDetermined`
- If access is denied, direct the user to `System Settings > Privacy & Security > Reminders`
- If the prompt does not appear, the current workaround is to run:
```bash
osascript -e 'tell application "Reminders" to get name of reminders'
```
When running over SSH, grant access on the Mac that actually runs `remindctl`.
## Response Discipline
- Confirm the reminder title, list, and due date before creating it if any of them are ambiguous
- Use the exact command syntax shown by the current implementation
- Do not reuse stale guidance about deleting and recreating reminders to move them between lists
- Do not assume a fixed default list name
- Do not promise unsupported private Reminders.app features; `remindctl` intentionally stays on public EventKit APIs

View File

@ -1,29 +1,11 @@
import Foundation
public struct ParsedUserDate: Equatable, Sendable {
public let date: Date
public let isDateOnly: Bool
public init(date: Date, isDateOnly: Bool) {
self.date = date
self.isDateOnly = isDateOnly
}
}
public enum DateParsing {
public static func parseUserDate(
_ input: String,
now: Date = Date(),
calendar: Calendar = .current
) -> Date? {
parseUserDateWithMetadata(input, now: now, calendar: calendar)?.date
}
public static func parseUserDateWithMetadata(
_ input: String,
now: Date = Date(),
calendar: Calendar = .current
) -> ParsedUserDate? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = trimmed.lowercased()
@ -35,48 +17,37 @@ public enum DateParsing {
isoFormatter(withFraction: true).date(from: trimmed)
?? isoFormatter(withFraction: false).date(from: trimmed)
if let iso {
return ParsedUserDate(date: iso, isDateOnly: false)
return iso
}
let localISO =
localISOFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSSSSS").date(from: trimmed)
?? localISOFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSS").date(from: trimmed)
?? localISOFormatter(format: "yyyy-MM-dd'T'HH:mm:ss").date(from: trimmed)
?? localISOFormatter(format: "yyyy-MM-dd'T'HH:mm").date(from: trimmed)
if let localISO {
return ParsedUserDate(date: localISO, isDateOnly: false)
}
for (formatter, isDateOnly) in dateFormatters() {
for formatter in dateFormatters() {
if let date = formatter.date(from: trimmed) {
return ParsedUserDate(date: date, isDateOnly: isDateOnly)
return date
}
}
return nil
}
public static func formatDisplay(_ date: Date, isDateOnly: Bool = false, calendar: Calendar = .current) -> String {
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 = isDateOnly ? .none : .short
formatter.timeStyle = .short
return formatter.string(from: date)
}
private static func parseRelativeDate(_ input: String, now: Date, calendar: Calendar) -> ParsedUserDate? {
private static func parseRelativeDate(_ input: String, now: Date, calendar: Calendar) -> Date? {
switch input {
case "today":
return ParsedUserDate(date: calendar.startOfDay(for: now), isDateOnly: true)
return calendar.startOfDay(for: now)
case "tomorrow":
return calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))
.map { ParsedUserDate(date: $0, isDateOnly: true) }
case "yesterday":
return calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now))
.map { ParsedUserDate(date: $0, isDateOnly: true) }
case "now":
return ParsedUserDate(date: now, isDateOnly: false)
return now
default:
return nil
}
@ -91,30 +62,22 @@ public enum DateParsing {
return formatter
}
private static func localISOFormatter(format: String) -> DateFormatter {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
formatter.dateFormat = format
return formatter
}
private static func dateFormatters() -> [(DateFormatter, Bool)] {
let formats: [(String, Bool)] = [
("yyyy-MM-dd", true),
("yyyy-MM-dd HH:mm", false),
("yyyy-MM-dd HH:mm:ss", false),
("MM/dd/yyyy", true),
("MM/dd/yyyy HH:mm", false),
("dd-MM-yy", true),
("dd-MM-yyyy", true),
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, isDateOnly in
return formats.map { format in
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
formatter.dateFormat = format
return (formatter, isDateOnly)
return formatter
}
}
}

View File

@ -18,8 +18,6 @@ public enum RemindCoreError: LocalizedError, Sendable, Equatable {
"Reminders access denied.",
"Run `remindctl authorize` to trigger the prompt, then allow Terminal (or remindctl)",
"in System Settings > Privacy & Security > Reminders.",
"If no prompt appears, run `osascript -e 'tell application \"Reminders\" to get name of reminders'`",
"once from the same terminal app.",
"If running over SSH, grant access on the Mac that runs the command.",
].joined(separator: " ")
case .writeOnlyAccess:

View File

@ -1,12 +1,6 @@
import CoreLocation
import EventKit
import Foundation
private func isAllDay(_ components: DateComponents?) -> Bool {
guard let components else { return false }
return components.hour == nil && components.minute == nil && components.second == nil
}
public actor RemindersStore {
private let eventStore = EKEventStore()
private let calendar: Calendar
@ -109,19 +103,18 @@ public actor RemindersStore {
if let dueDate = draft.dueDate {
reminder.dueDateComponents = calendarComponents(from: dueDate)
}
if let alarmDate = draft.alarmDate {
reminder.addAlarm(EKAlarm(absoluteDate: alarmDate.date))
} else if let dueDate = draft.dueDate, !dueDate.isDateOnly {
reminder.addAlarm(EKAlarm(absoluteDate: dueDate.date))
}
if let recurrenceRule = draft.recurrenceRule {
replaceRecurrence(on: reminder, with: recurrenceRule)
}
if let locationTrigger = draft.locationTrigger {
reminder.addAlarm(try await locationAlarm(from: locationTrigger))
}
try eventStore.save(reminder, commit: true)
return item(from: reminder)
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 {
@ -135,21 +128,11 @@ public actor RemindersStore {
}
if let dueDateUpdate = update.dueDate {
if let dueDate = dueDateUpdate {
reminder.dueDateComponents = nil
reminder.dueDateComponents = calendarComponents(from: dueDate)
if update.alarmDate == nil && !dueDate.isDateOnly {
replaceAlarms(on: reminder, with: dueDate.date)
}
} else {
reminder.dueDateComponents = nil
}
}
if let alarmDateUpdate = update.alarmDate {
replaceAlarms(on: reminder, with: alarmDateUpdate?.date)
}
if let recurrenceUpdate = update.recurrenceRule {
replaceRecurrence(on: reminder, with: recurrenceUpdate)
}
if let priority = update.priority {
reminder.priority = priority.eventKitValue
}
@ -162,7 +145,17 @@ public actor RemindersStore {
try eventStore.save(reminder, commit: true)
return item(from: reminder)
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] {
@ -171,7 +164,19 @@ public actor RemindersStore {
let reminder = try reminder(withID: id)
reminder.isCompleted = true
try eventStore.save(reminder, commit: true)
updated.append(item(from: reminder))
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
}
@ -185,9 +190,7 @@ public actor RemindersStore {
}
return deleted
}
}
extension RemindersStore {
private func requestFullAccess() async throws -> Bool {
try await withCheckedThrowingContinuation { continuation in
eventStore.requestFullAccessToReminders { granted, error in
@ -205,17 +208,10 @@ extension RemindersStore {
let id: String
let title: String
let notes: String?
let url: URL?
let isCompleted: Bool
let completionDate: Date?
let creationDate: Date?
let lastModifiedDate: Date?
let priority: Int
let dueDateComponents: DateComponents?
let dueDateIsAllDay: Bool
let alarmDate: Date?
let recurrenceRule: RecurrenceRule?
let locationTrigger: LocationTrigger?
let listID: String
let listName: String
}
@ -224,22 +220,14 @@ extension RemindersStore {
let predicate = eventStore.predicateForReminders(in: calendars)
eventStore.fetchReminders(matching: predicate) { reminders in
let data = (reminders ?? []).map { reminder in
let components = reminder.dueDateComponents
return ReminderData(
ReminderData(
id: reminder.calendarItemIdentifier,
title: reminder.title ?? "",
notes: reminder.notes,
url: reminder.url,
isCompleted: reminder.isCompleted,
completionDate: reminder.completionDate,
creationDate: reminder.creationDate,
lastModifiedDate: reminder.lastModifiedDate,
priority: Int(reminder.priority),
dueDateComponents: components,
dueDateIsAllDay: isAllDay(components),
alarmDate: Self.alarmDate(from: reminder),
recurrenceRule: Self.recurrenceRule(from: reminder),
locationTrigger: Self.locationTrigger(from: reminder),
dueDateComponents: reminder.dueDateComponents,
listID: reminder.calendar.calendarIdentifier,
listName: reminder.calendar.title
)
@ -253,17 +241,10 @@ extension RemindersStore {
id: data.id,
title: data.title,
notes: data.notes,
url: data.url,
isCompleted: data.isCompleted,
completionDate: data.completionDate,
creationDate: data.creationDate,
lastModifiedDate: data.lastModifiedDate,
priority: ReminderPriority(eventKitValue: data.priority),
dueDate: date(from: data.dueDateComponents),
dueDateIsAllDay: data.dueDateIsAllDay,
alarmDate: data.alarmDate,
recurrenceRule: data.recurrenceRule,
locationTrigger: data.locationTrigger,
listID: data.listID,
listName: data.listName
)
@ -285,15 +266,8 @@ extension RemindersStore {
return calendar
}
private func calendarComponents(from parsed: ParsedUserDate) -> DateComponents {
let components: Set<Calendar.Component> =
parsed.isDateOnly
? [.year, .month, .day]
: [.year, .month, .day, .hour, .minute, .second]
var result = calendar.dateComponents(components, from: parsed.date)
result.calendar = calendar
result.timeZone = calendar.timeZone
return result
private func calendarComponents(from date: Date) -> DateComponents {
calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)
}
private func date(from components: DateComponents?) -> Date? {
@ -302,141 +276,16 @@ extension RemindersStore {
}
private func item(from reminder: EKReminder) -> ReminderItem {
let components = reminder.dueDateComponents
return ReminderItem(
ReminderItem(
id: reminder.calendarItemIdentifier,
title: reminder.title ?? "",
notes: reminder.notes,
url: reminder.url,
isCompleted: reminder.isCompleted,
completionDate: reminder.completionDate,
creationDate: reminder.creationDate,
lastModifiedDate: reminder.lastModifiedDate,
priority: ReminderPriority(eventKitValue: Int(reminder.priority)),
dueDate: date(from: components),
dueDateIsAllDay: isAllDay(components),
alarmDate: Self.alarmDate(from: reminder),
recurrenceRule: Self.recurrenceRule(from: reminder),
locationTrigger: Self.locationTrigger(from: reminder),
dueDate: date(from: reminder.dueDateComponents),
listID: reminder.calendar.calendarIdentifier,
listName: reminder.calendar.title
)
}
private func replaceAlarms(on reminder: EKReminder, with date: Date?) {
for alarm in reminder.alarms ?? [] {
reminder.removeAlarm(alarm)
}
if let date {
reminder.addAlarm(EKAlarm(absoluteDate: date))
}
}
private static func alarmDate(from reminder: EKReminder) -> Date? {
reminder.alarms?
.compactMap(\.absoluteDate)
.min()
}
private func replaceRecurrence(on reminder: EKReminder, with rule: RecurrenceRule?) {
for existing in reminder.recurrenceRules ?? [] {
reminder.removeRecurrenceRule(existing)
}
guard let rule else { return }
reminder.addRecurrenceRule(
EKRecurrenceRule(recurrenceWith: rule.eventKitFrequency, interval: rule.interval, end: nil))
}
private static func recurrenceRule(from reminder: EKReminder) -> RecurrenceRule? {
guard let rule = reminder.recurrenceRules?.first else { return nil }
guard let frequency = RecurrenceFrequency(eventKitFrequency: rule.frequency) else { return nil }
return RecurrenceRule(frequency: frequency, interval: rule.interval)
}
private func locationAlarm(from trigger: LocationTrigger) async throws -> EKAlarm {
let structuredLocation = EKStructuredLocation(title: trigger.address)
let location: CLLocation
if let latitude = trigger.latitude, let longitude = trigger.longitude {
location = CLLocation(latitude: latitude, longitude: longitude)
} else {
let placemarks = try await CLGeocoder().geocodeAddressString(trigger.address)
guard let geocodedLocation = placemarks.first?.location else {
throw RemindCoreError.operationFailed("Could not geocode location: \(trigger.address)")
}
location = geocodedLocation
}
structuredLocation.geoLocation = location
structuredLocation.radius = trigger.radius
let alarm = EKAlarm()
alarm.structuredLocation = structuredLocation
alarm.proximity = trigger.proximity == .arriving ? .enter : .leave
return alarm
}
private static func locationTrigger(from reminder: EKReminder) -> LocationTrigger? {
guard let alarm = reminder.alarms?.first(where: { $0.structuredLocation != nil }),
let structuredLocation = alarm.structuredLocation,
let proximity = LocationProximity(eventKitProximity: alarm.proximity)
else { return nil }
let coordinate = structuredLocation.geoLocation?.coordinate
return LocationTrigger(
address: structuredLocation.title ?? "",
latitude: coordinate?.latitude,
longitude: coordinate?.longitude,
radius: structuredLocation.radius,
proximity: proximity
)
}
}
extension RecurrenceFrequency {
fileprivate init?(eventKitFrequency: EKRecurrenceFrequency) {
switch eventKitFrequency {
case .daily:
self = .daily
case .weekly:
self = .weekly
case .monthly:
self = .monthly
case .yearly:
self = .yearly
@unknown default:
return nil
}
}
fileprivate var eventKitFrequency: EKRecurrenceFrequency {
switch self {
case .daily:
return .daily
case .weekly:
return .weekly
case .monthly:
return .monthly
case .yearly:
return .yearly
}
}
}
extension RecurrenceRule {
fileprivate var eventKitFrequency: EKRecurrenceFrequency {
frequency.eventKitFrequency
}
}
extension LocationProximity {
fileprivate init?(eventKitProximity: EKAlarmProximity) {
switch eventKitProximity {
case .enter:
self = .arriving
case .leave:
self = .leaving
default:
return nil
}
}
}

View File

@ -5,20 +5,18 @@ public enum IDResolver {
public static func resolve(
_ inputs: [String],
from reminders: [ReminderItem],
numericFrom numericReminders: [ReminderItem]? = nil
from reminders: [ReminderItem]
) throws -> [ReminderItem] {
let sorted = ReminderFiltering.sort(reminders)
let numericSorted = ReminderFiltering.sort(numericReminders ?? 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 < numericSorted.count else {
guard idx >= 0 && idx < sorted.count else {
throw RemindCoreError.invalidIdentifier(trimmed)
}
resolved.append(numericSorted[idx])
resolved.append(sorted[idx])
continue
}

View File

@ -33,37 +33,6 @@ public enum ReminderPriority: String, Codable, CaseIterable, Sendable {
}
}
public enum RecurrenceFrequency: String, Codable, CaseIterable, Sendable {
case daily
case weekly
case monthly
case yearly
}
public struct RecurrenceRule: Codable, Sendable, Equatable {
public let frequency: RecurrenceFrequency
public let interval: Int
public init(frequency: RecurrenceFrequency, interval: Int = 1) {
self.frequency = frequency
self.interval = interval
}
public var displayString: String {
if interval == 1 {
return frequency.rawValue
}
let unit =
switch frequency {
case .daily: "days"
case .weekly: "weeks"
case .monthly: "months"
case .yearly: "years"
}
return "every \(interval) \(unit)"
}
}
public struct ReminderList: Identifiable, Codable, Sendable, Equatable {
public let id: String
public let title: String
@ -74,48 +43,14 @@ public struct ReminderList: Identifiable, Codable, Sendable, Equatable {
}
}
public enum LocationProximity: String, Codable, CaseIterable, Sendable {
case arriving
case leaving
}
public struct LocationTrigger: Codable, Sendable, Equatable {
public let address: String
public let latitude: Double?
public let longitude: Double?
public let radius: Double
public let proximity: LocationProximity
public init(
address: String,
latitude: Double? = nil,
longitude: Double? = nil,
radius: Double = 100,
proximity: LocationProximity = .arriving
) {
self.address = address
self.latitude = latitude
self.longitude = longitude
self.radius = radius
self.proximity = proximity
}
}
public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
public let id: String
public let title: String
public let notes: String?
public let url: URL?
public let isCompleted: Bool
public let completionDate: Date?
public let creationDate: Date?
public let lastModifiedDate: Date?
public let priority: ReminderPriority
public let dueDate: Date?
public let dueDateIsAllDay: Bool
public let alarmDate: Date?
public let recurrenceRule: RecurrenceRule?
public let locationTrigger: LocationTrigger?
public let listID: String
public let listName: String
@ -123,34 +58,20 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
id: String,
title: String,
notes: String?,
url: URL? = nil,
isCompleted: Bool,
completionDate: Date?,
creationDate: Date? = nil,
lastModifiedDate: Date? = nil,
priority: ReminderPriority,
dueDate: Date?,
dueDateIsAllDay: Bool = false,
alarmDate: Date? = nil,
recurrenceRule: RecurrenceRule? = nil,
locationTrigger: LocationTrigger? = nil,
listID: String,
listName: String
) {
self.id = id
self.title = title
self.notes = notes
self.url = url
self.isCompleted = isCompleted
self.completionDate = completionDate
self.creationDate = creationDate
self.lastModifiedDate = lastModifiedDate
self.priority = priority
self.dueDate = dueDate
self.dueDateIsAllDay = dueDateIsAllDay
self.alarmDate = alarmDate
self.recurrenceRule = recurrenceRule
self.locationTrigger = locationTrigger
self.listID = listID
self.listName = listName
}
@ -159,27 +80,13 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
public struct ReminderDraft: Sendable {
public let title: String
public let notes: String?
public let dueDate: ParsedUserDate?
public let alarmDate: ParsedUserDate?
public let recurrenceRule: RecurrenceRule?
public let locationTrigger: LocationTrigger?
public let dueDate: Date?
public let priority: ReminderPriority
public init(
title: String,
notes: String?,
dueDate: ParsedUserDate?,
alarmDate: ParsedUserDate? = nil,
recurrenceRule: RecurrenceRule? = nil,
locationTrigger: LocationTrigger? = nil,
priority: ReminderPriority
) {
public init(title: String, notes: String?, dueDate: Date?, priority: ReminderPriority) {
self.title = title
self.notes = notes
self.dueDate = dueDate
self.alarmDate = alarmDate
self.recurrenceRule = recurrenceRule
self.locationTrigger = locationTrigger
self.priority = priority
}
}
@ -187,9 +94,7 @@ public struct ReminderDraft: Sendable {
public struct ReminderUpdate: Sendable {
public let title: String?
public let notes: String?
public let dueDate: ParsedUserDate??
public let alarmDate: ParsedUserDate??
public let recurrenceRule: RecurrenceRule??
public let dueDate: Date??
public let priority: ReminderPriority?
public let listName: String?
public let isCompleted: Bool?
@ -197,9 +102,7 @@ public struct ReminderUpdate: Sendable {
public init(
title: String? = nil,
notes: String? = nil,
dueDate: ParsedUserDate?? = nil,
alarmDate: ParsedUserDate?? = nil,
recurrenceRule: RecurrenceRule?? = nil,
dueDate: Date?? = nil,
priority: ReminderPriority? = nil,
listName: String? = nil,
isCompleted: Bool? = nil
@ -207,8 +110,6 @@ public struct ReminderUpdate: Sendable {
self.title = title
self.notes = notes
self.dueDate = dueDate
self.alarmDate = alarmDate
self.recurrenceRule = recurrenceRule
self.priority = priority
self.listName = listName
self.isCompleted = isCompleted

View File

@ -6,7 +6,6 @@ public enum ReminderFilter: Equatable, Sendable {
case week
case overdue
case upcoming
case open
case completed
case date(Date)
case all
@ -26,8 +25,6 @@ public enum ReminderFiltering {
return .overdue
case "upcoming", "u":
return .upcoming
case "open":
return .open
case "completed", "done", "c":
return .completed
case "all", "a":
@ -79,8 +76,6 @@ public enum ReminderFiltering {
return reminders.filter { reminder in
!reminder.isCompleted && reminder.dueDate != nil
}
case .open:
return reminders.filter { !$0.isCompleted }
case .completed:
return reminders.filter { $0.isCompleted }
case .date(let date):

View File

@ -17,64 +17,10 @@ enum CommandHelpers {
}
}
static func parseDueDate(_ value: String) throws -> ParsedUserDate {
guard let parsed = DateParsing.parseUserDateWithMetadata(value) else {
static func parseDueDate(_ value: String) throws -> Date {
guard let date = DateParsing.parseUserDate(value) else {
throw RemindCoreError.invalidDate(value)
}
return parsed
}
static func parseRecurrence(_ value: String) throws -> RecurrenceRule {
let normalized = value.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
switch normalized {
case "daily":
return RecurrenceRule(frequency: .daily)
case "weekly":
return RecurrenceRule(frequency: .weekly)
case "biweekly":
return RecurrenceRule(frequency: .weekly, interval: 2)
case "monthly":
return RecurrenceRule(frequency: .monthly)
case "yearly", "annually":
return RecurrenceRule(frequency: .yearly)
default:
return try parseCustomRecurrence(normalized, original: value)
}
}
static func resolveShowIdentifiers(_ inputs: [String], from reminders: [ReminderItem]) throws -> [ReminderItem] {
let defaultShowReminders = ReminderFiltering.apply(reminders, filter: .today)
return try IDResolver.resolve(inputs, from: reminders, numericFrom: defaultShowReminders)
}
private static func parseCustomRecurrence(_ normalized: String, original: String) throws -> RecurrenceRule {
let parts = normalized.split(separator: " ")
guard parts.count == 3, parts[0] == "every", let interval = Int(parts[1]), interval > 0 else {
throw invalidRecurrence(original)
}
let frequency: RecurrenceFrequency
switch parts[2] {
case "day", "days":
frequency = .daily
case "week", "weeks":
frequency = .weekly
case "month", "months":
frequency = .monthly
case "year", "years":
frequency = .yearly
default:
throw invalidRecurrence(original)
}
return RecurrenceRule(frequency: frequency, interval: interval)
}
private static func invalidRecurrence(_ value: String) -> RemindCoreError {
RemindCoreError.operationFailed(
"""
Invalid repeat value: "\(value)" \
(use daily|weekly|biweekly|monthly|yearly or "every N days/weeks/months/years")
"""
)
return date
}
}

View File

@ -17,44 +17,19 @@ enum AddCommand {
.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: "alarm", names: [.short("a"), .long("alarm")], help: "Alarm date", parsing: .singleValue),
.make(
label: "location",
names: [.long("location")],
help: "Location address for geofence trigger",
parsing: .singleValue
),
.make(
label: "radius",
names: [.long("radius")],
help: "Geofence radius in meters (default: 100)",
parsing: .singleValue
),
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue),
.make(
label: "repeat",
names: [.short("r"), .long("repeat")],
help: "daily|weekly|biweekly|monthly|yearly|every N days/weeks/months/years",
parsing: .singleValue
),
.make(
label: "priority",
names: [.short("p"), .long("priority")],
help: "none|low|medium|high",
parsing: .singleValue
),
],
flags: [
.make(label: "leaving", names: [.long("leaving")], help: "Trigger when leaving location")
]
)
),
usageExamples: [
"remindctl add \"Buy milk\"",
"remindctl add --title \"Call mom\" --list Personal --due tomorrow",
"remindctl add \"Call mom\" --due \"2026-01-03 09:00\" --alarm \"2026-01-03 08:55\"",
"remindctl add \"Check mailbox\" --location \"1 Apple Park Way, Cupertino, CA\"",
"remindctl add \"Take vitamins\" --due tomorrow --repeat daily",
"remindctl add \"Review docs\" --priority high",
]
) { values, runtime in
@ -80,20 +55,9 @@ enum AddCommand {
let listName = values.option("list")
let notes = values.option("notes")
let dueValue = values.option("due")
let alarmValue = values.option("alarm")
let locationValue = values.option("location")
let radiusValue = values.option("radius")
let repeatValue = values.option("repeat")
let priorityValue = values.option("priority")
let dueDate = try dueValue.map(CommandHelpers.parseDueDate)
let alarmDate = try alarmValue.map(CommandHelpers.parseDueDate)
let locationTrigger = try makeLocationTrigger(
location: locationValue,
radius: radiusValue,
leaving: values.flag("leaving")
)
let recurrenceRule = try repeatValue.map(CommandHelpers.parseRecurrence)
let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none
let store = RemindersStore()
@ -109,44 +73,9 @@ enum AddCommand {
throw RemindCoreError.operationFailed("No default list found. Specify --list.")
}
let draft = ReminderDraft(
title: title,
notes: notes,
dueDate: dueDate,
alarmDate: alarmDate,
recurrenceRule: recurrenceRule,
locationTrigger: locationTrigger,
priority: priority
)
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)
}
}
private static func makeLocationTrigger(
location: String?,
radius: String?,
leaving: Bool
) throws -> LocationTrigger? {
if location == nil {
if radius != nil || leaving {
throw RemindCoreError.operationFailed("Use --location with --radius or --leaving")
}
return nil
}
guard let location else { return nil }
let radius = try radius.map(parseRadius) ?? 100
return LocationTrigger(
address: location,
radius: radius,
proximity: leaving ? .leaving : .arriving
)
}
private static func parseRadius(_ value: String) throws -> Double {
guard let radius = Double(value), radius > 0 else {
throw RemindCoreError.operationFailed("Invalid radius: \"\(value)\"")
}
return radius
}
}

View File

@ -32,7 +32,7 @@ enum CompleteCommand {
let store = RemindersStore()
try await store.requestAccess()
let reminders = try await store.reminders(in: nil)
let resolved = try CommandHelpers.resolveShowIdentifiers(inputs, from: reminders)
let resolved = try IDResolver.resolve(inputs, from: reminders)
if values.flag("dryRun") {
OutputRenderer.printReminders(resolved, format: runtime.outputFormat)

View File

@ -33,7 +33,7 @@ enum DeleteCommand {
let store = RemindersStore()
try await store.requestAccess()
let reminders = try await store.reminders(in: nil)
let resolved = try CommandHelpers.resolveShowIdentifiers(inputs, from: reminders)
let resolved = try IDResolver.resolve(inputs, from: reminders)
if values.flag("dryRun") {
OutputRenderer.printReminders(resolved, format: runtime.outputFormat)

View File

@ -17,14 +17,7 @@ enum EditCommand {
.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: "alarm", names: [.short("a"), .long("alarm")], help: "Set alarm date", parsing: .singleValue),
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Set notes", parsing: .singleValue),
.make(
label: "repeat",
names: [.short("r"), .long("repeat")],
help: "daily|weekly|biweekly|monthly|yearly|every N days/weeks/months/years",
parsing: .singleValue
),
.make(
label: "priority",
names: [.short("p"), .long("priority")],
@ -34,8 +27,6 @@ enum EditCommand {
],
flags: [
.make(label: "clearDue", names: [.long("clear-due")], help: "Clear due date"),
.make(label: "clearAlarm", names: [.long("clear-alarm")], help: "Clear alarm"),
.make(label: "noRepeat", names: [.long("no-repeat")], help: "Remove recurrence"),
.make(label: "complete", names: [.long("complete")], help: "Mark completed"),
.make(label: "incomplete", names: [.long("incomplete")], help: "Mark incomplete"),
]
@ -44,10 +35,8 @@ enum EditCommand {
usageExamples: [
"remindctl edit 1 --title \"New title\"",
"remindctl edit 4A83 --due tomorrow",
"remindctl edit 4A83 --alarm \"2026-01-03 08:55\"",
"remindctl edit 4A83 --repeat weekly",
"remindctl edit 2 --priority high --notes \"Call before noon\"",
"remindctl edit 3 --clear-due --clear-alarm --no-repeat",
"remindctl edit 3 --clear-due",
]
) { values, runtime in
guard let input = values.argument(0) else {
@ -57,7 +46,7 @@ enum EditCommand {
let store = RemindersStore()
try await store.requestAccess()
let reminders = try await store.reminders(in: nil)
let resolved = try CommandHelpers.resolveShowIdentifiers([input], from: reminders)
let resolved = try IDResolver.resolve([input], from: reminders)
guard let reminder = resolved.first else {
throw RemindCoreError.reminderNotFound(input)
}
@ -65,10 +54,8 @@ enum EditCommand {
let title = values.option("title")
let listName = values.option("list")
let notes = values.option("notes")
let alarmValue = values.option("alarm")
let repeatValue = values.option("repeat")
var dueUpdate: ParsedUserDate??
var dueUpdate: Date??
if let dueValue = values.option("due") {
dueUpdate = try CommandHelpers.parseDueDate(dueValue)
}
@ -79,28 +66,6 @@ enum EditCommand {
dueUpdate = .some(nil)
}
var alarmUpdate: ParsedUserDate??
if let alarmValue {
alarmUpdate = try CommandHelpers.parseDueDate(alarmValue)
}
if values.flag("clearAlarm") {
if alarmUpdate != nil {
throw RemindCoreError.operationFailed("Use either --alarm or --clear-alarm, not both")
}
alarmUpdate = .some(nil)
}
var recurrenceUpdate: RecurrenceRule??
if let repeatValue {
recurrenceUpdate = try CommandHelpers.parseRecurrence(repeatValue)
}
if values.flag("noRepeat") {
if recurrenceUpdate != nil {
throw RemindCoreError.operationFailed("Use either --repeat or --no-repeat, not both")
}
recurrenceUpdate = .some(nil)
}
var priority: ReminderPriority?
if let priorityValue = values.option("priority") {
priority = try CommandHelpers.parsePriority(priorityValue)
@ -113,10 +78,7 @@ enum EditCommand {
}
let isCompleted: Bool? = completeFlag ? true : (incompleteFlag ? false : nil)
let hasChanges =
title != nil || listName != nil || notes != nil || dueUpdate != nil || alarmUpdate != nil || priority != nil
|| recurrenceUpdate != nil || isCompleted != nil
if !hasChanges {
if title == nil && listName == nil && notes == nil && dueUpdate == nil && priority == nil && isCompleted == nil {
throw RemindCoreError.operationFailed("No changes specified")
}
@ -124,8 +86,6 @@ enum EditCommand {
title: title,
notes: notes,
dueDate: dueUpdate,
alarmDate: alarmUpdate,
recurrenceRule: recurrenceUpdate,
priority: priority,
listName: listName,
isCompleted: isCompleted

View File

@ -7,11 +7,11 @@ enum ListCommand {
CommandSpec(
name: "list",
abstract: "List reminder lists or show list contents",
discussion: "Without a name, shows all lists. With one or more names, shows reminders in those lists.",
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(s)", isOptional: true)
.make(label: "name", help: "List name", isOptional: true)
],
options: [
.make(
@ -31,13 +31,12 @@ enum ListCommand {
usageExamples: [
"remindctl list",
"remindctl list Work",
"remindctl list Work Errands",
"remindctl list Work --rename Office",
"remindctl list Work --delete",
"remindctl list Projects --create",
]
) { values, runtime in
let names = values.positional
let name = values.argument(0)
let renameTo = values.option("rename")
let deleteList = values.flag("delete")
let createList = values.flag("create")
@ -46,11 +45,7 @@ enum ListCommand {
let store = RemindersStore()
try await store.requestAccess()
if !names.isEmpty {
let name = try singleListName(
names,
forMutation: deleteList || renameTo != nil || createList
)
if let name {
if deleteList {
if !force && !runtime.noInput && Console.isTTY {
if !Console.confirm("Delete list \"\(name)\"?", defaultValue: false) {
@ -85,7 +80,7 @@ enum ListCommand {
return
}
let reminders = try await reminders(in: names, store: store)
let reminders = try await store.reminders(in: name)
OutputRenderer.printReminders(reminders, format: runtime.outputFormat)
return
}
@ -114,23 +109,4 @@ enum ListCommand {
OutputRenderer.printLists(summaries, format: runtime.outputFormat)
}
}
static func singleListName(_ names: [String], forMutation: Bool) throws -> String {
guard let name = names.first else {
throw ParsedValuesError.missingArgument("name")
}
if forMutation && names.count > 1 {
throw RemindCoreError.operationFailed("Only one list name can be used with create, delete, or rename")
}
return name
}
private static func reminders(in names: [String], store: RemindersStore) async throws -> [ReminderItem] {
var reminders: [ReminderItem] = []
var seen = Set<String>()
for name in names where seen.insert(name).inserted {
reminders.append(contentsOf: try await store.reminders(in: name))
}
return reminders
}
}

View File

@ -7,13 +7,13 @@ enum ShowCommand {
CommandSpec(
name: "show",
abstract: "Show reminders",
discussion: "Filters: today, tomorrow, week, overdue, upcoming, open, completed, all, or a date string.",
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|open|completed|all|<date>",
help: "today|tomorrow|week|overdue|upcoming|completed|all|<date>",
isOptional: true
)
],

View File

@ -50,12 +50,8 @@ enum OutputRenderer {
static func printReminder(_ reminder: ReminderItem, format: OutputFormat) {
switch format {
case .standard:
let due =
reminder.dueDate.map {
DateParsing.formatDisplay($0, isDateOnly: reminder.dueDateIsAllDay)
} ?? "no due date"
let recurrence = recurrenceSuffix(for: reminder)
Swift.print("\(reminder.title) [\(reminder.listName)] — \(due)\(recurrence)")
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:
@ -100,14 +96,9 @@ enum OutputRenderer {
}
for (index, reminder) in sorted.enumerated() {
let status = reminder.isCompleted ? "x" : " "
let due =
reminder.dueDate.map {
DateParsing.formatDisplay($0, isDateOnly: reminder.dueDateIsAllDay)
} ?? "no due date"
let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date"
let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)"
let recurrence = recurrenceSuffix(for: reminder)
Swift.print(
"[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)\(recurrence)")
Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)")
}
}
@ -119,15 +110,7 @@ enum OutputRenderer {
}
private static func plainLine(for reminder: ReminderItem) -> String {
let due: String
if let dueDate = reminder.dueDate {
due =
reminder.dueDateIsAllDay
? dateOnlyFormatter().string(from: dueDate)
: isoFormatter().string(from: dueDate)
} else {
due = ""
}
let due = reminder.dueDate.map { isoFormatter().string(from: $0) } ?? ""
return [
reminder.id,
reminder.listName,
@ -174,16 +157,4 @@ enum OutputRenderer {
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}
private static func dateOnlyFormatter() -> DateFormatter {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}
private static func recurrenceSuffix(for reminder: ReminderItem) -> String {
reminder.recurrenceRule.map { " repeat=\($0.displayString)" } ?? ""
}
}

View File

@ -2,7 +2,6 @@ import RemindCore
enum PermissionsHelp {
static let settingsPath = "System Settings > Privacy & Security > Reminders"
static let promptWorkaround = #"osascript -e 'tell application "Reminders" to get name of reminders'"#
static func guidanceLines(for status: RemindersAuthorizationStatus) -> [String] {
switch status {
@ -16,7 +15,6 @@ enum PermissionsHelp {
case .denied, .restricted:
return [
"Grant access in \(settingsPath) for Terminal (or remindctl).",
"If no prompt appears, run `\(promptWorkaround)` once from the same terminal app.",
"If running over SSH, grant access on the Mac that runs the command.",
]
case .writeOnly:

View File

@ -11,9 +11,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.0</string>
<string>0.1.1</string>
<key>CFBundleVersion</key>
<string>0.2.0</string>
<string>0.1.1</string>
<key>NSRemindersUsageDescription</key>
<string>Manage your reminders from the terminal.</string>
</dict>

View File

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

View File

@ -30,14 +30,6 @@ struct DateParsingTests {
#expect(parsed != nil)
}
@Test("Local ISO 8601 without timezone parsing")
func localISOParsing() {
let input = "2026-01-03T12:34:56"
let parsed = DateParsing.parseUserDateWithMetadata(input)
#expect(parsed != nil)
#expect(parsed?.isDateOnly == false)
}
@Test("Formatted date parsing")
func formattedParsing() {
let input = "2026-01-03 10:30"
@ -51,28 +43,4 @@ struct DateParsingTests {
let output = DateParsing.formatDisplay(date, calendar: calendar)
#expect(output.isEmpty == false)
}
@Test("Date-only inputs carry metadata")
func dateOnlyMetadata() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let today = DateParsing.parseUserDateWithMetadata("today", now: now, calendar: calendar)
#expect(today?.date == calendar.startOfDay(for: now))
#expect(today?.isDateOnly == true)
let dateOnly = DateParsing.parseUserDateWithMetadata("2026-01-03")
#expect(dateOnly?.isDateOnly == true)
let dateTime = DateParsing.parseUserDateWithMetadata("2026-01-03 10:30")
#expect(dateTime?.isDateOnly == false)
}
@Test("Display can omit time for all-day reminders")
func displayFormattingDateOnly() {
let date = Date(timeIntervalSince1970: 1_700_000_000)
let timed = DateParsing.formatDisplay(date, calendar: calendar)
let dateOnly = DateParsing.formatDisplay(date, isDateOnly: true, calendar: calendar)
#expect(dateOnly.isEmpty == false)
#expect(timed.count > dateOnly.count)
}
}

View File

@ -38,13 +38,6 @@ struct IDResolverTests {
#expect(resolved.first?.title == "First")
}
@Test("Resolve numeric indexes from filtered show output")
func resolveIndexFromFilteredShowOutput() throws {
let all = sampleReminders()
let resolved = try IDResolver.resolve(["1"], from: all, numericFrom: [all[1]])
#expect(resolved.first?.title == "Second")
}
@Test("Resolve by prefix")
func resolvePrefix() throws {
let resolved = try IDResolver.resolve(["abcd"], from: sampleReminders())

View File

@ -10,7 +10,6 @@ struct ReminderFilterParseTests {
#expect(ReminderFiltering.parse("w") == .week)
#expect(ReminderFiltering.parse("o") == .overdue)
#expect(ReminderFiltering.parse("u") == .upcoming)
#expect(ReminderFiltering.parse("open") == .open)
#expect(ReminderFiltering.parse("done") == .completed)
#expect(ReminderFiltering.parse("all") == .all)
}

View File

@ -108,16 +108,6 @@ struct ReminderFilteringTests {
#expect(result.count == 3)
}
@Test("Open filter includes no due date and excludes completed")
func openFilter() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let items = reminders(now: now)
let result = ReminderFiltering.apply(items, filter: .open, now: now, calendar: calendar)
#expect(result.count == 4)
#expect(result.contains(where: { $0.title == "No Due" }))
#expect(result.allSatisfy { !$0.isCompleted })
}
@Test("Date filter")
func dateFilter() {
let now = Date(timeIntervalSince1970: 1_700_000_000)

View File

@ -1,45 +0,0 @@
import Foundation
import Testing
@testable import RemindCore
@MainActor
struct ReminderItemCodingTests {
@Test("JSON includes EventKit metadata")
func jsonIncludesEventKitMetadata() throws {
let item = ReminderItem(
id: "abc",
title: "Created",
notes: nil,
url: URL(string: "https://example.com"),
isCompleted: false,
completionDate: nil,
creationDate: Date(timeIntervalSince1970: 1_700_000_000),
lastModifiedDate: Date(timeIntervalSince1970: 1_700_000_100),
priority: .none,
dueDate: nil,
alarmDate: Date(timeIntervalSince1970: 1_700_000_300),
recurrenceRule: RecurrenceRule(frequency: .weekly, interval: 2),
locationTrigger: LocationTrigger(
address: "1 Apple Park Way",
latitude: 37.3349,
longitude: -122.0090,
radius: 100,
proximity: .arriving
),
listID: "list",
listName: "Inbox"
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(item)
let json = try #require(String(data: data, encoding: .utf8))
#expect(json.contains(#""creationDate""#))
#expect(json.contains(#""lastModifiedDate""#))
#expect(json.contains(#""url":"https:\/\/example.com""#))
#expect(json.contains(#""alarmDate""#))
#expect(json.contains(#""recurrenceRule""#))
#expect(json.contains(#""locationTrigger""#))
}
}

View File

@ -21,19 +21,4 @@ struct HelpPrinterTests {
#expect(joined.contains("status"))
#expect(joined.contains("authorize"))
}
@Test("Add and edit help include alarm, location, and repeat options")
func alarmLocationAndRepeatHelp() {
let addHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: AddCommand.spec).joined(separator: "\n")
let editHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: EditCommand.spec).joined(separator: "\n")
#expect(addHelp.contains("--alarm"))
#expect(addHelp.contains("--location"))
#expect(addHelp.contains("--leaving"))
#expect(addHelp.contains("--repeat"))
#expect(editHelp.contains("--alarm"))
#expect(editHelp.contains("--clear-alarm"))
#expect(editHelp.contains("--repeat"))
#expect(editHelp.contains("--no-repeat"))
}
}

View File

@ -1,19 +0,0 @@
import Testing
@testable import remindctl
@MainActor
struct ListCommandTests {
@Test("Multiple list names are allowed for read-only listing")
func multipleNamesAllowedForListing() throws {
let name = try ListCommand.singleListName(["Work", "Home"], forMutation: false)
#expect(name == "Work")
}
@Test("Multiple list names are rejected for mutations")
func multipleNamesRejectedForMutations() {
#expect(throws: Error.self) {
_ = try ListCommand.singleListName(["Work", "Home"], forMutation: true)
}
}
}

View File

@ -1,14 +0,0 @@
import Testing
@testable import RemindCore
@testable import remindctl
@MainActor
struct PermissionsHelpTests {
@Test("Denied guidance includes terminal prompt workaround")
func deniedGuidanceIncludesPromptWorkaround() {
let guidance = PermissionsHelp.guidanceLines(for: .denied).joined(separator: "\n")
#expect(guidance.contains("osascript"))
#expect(guidance.contains("Reminders"))
}
}

View File

@ -1,34 +0,0 @@
import Testing
@testable import RemindCore
@testable import remindctl
@MainActor
struct RecurrenceParsingTests {
@Test("Parse repeat presets")
func parsePresets() throws {
#expect(try CommandHelpers.parseRecurrence("daily") == RecurrenceRule(frequency: .daily))
#expect(try CommandHelpers.parseRecurrence("weekly") == RecurrenceRule(frequency: .weekly))
#expect(try CommandHelpers.parseRecurrence("biweekly") == RecurrenceRule(frequency: .weekly, interval: 2))
#expect(try CommandHelpers.parseRecurrence("monthly") == RecurrenceRule(frequency: .monthly))
#expect(try CommandHelpers.parseRecurrence("yearly") == RecurrenceRule(frequency: .yearly))
}
@Test("Parse custom repeat interval")
func parseCustomInterval() throws {
#expect(try CommandHelpers.parseRecurrence("every 3 days") == RecurrenceRule(frequency: .daily, interval: 3))
#expect(try CommandHelpers.parseRecurrence("every 4 weeks") == RecurrenceRule(frequency: .weekly, interval: 4))
#expect(try CommandHelpers.parseRecurrence("every 6 months") == RecurrenceRule(frequency: .monthly, interval: 6))
#expect(try CommandHelpers.parseRecurrence("every 2 years") == RecurrenceRule(frequency: .yearly, interval: 2))
}
@Test("Reject invalid repeat interval")
func rejectInvalidInterval() {
#expect(throws: (any Error).self) {
try CommandHelpers.parseRecurrence("every 0 weeks")
}
#expect(throws: (any Error).self) {
try CommandHelpers.parseRecurrence("weekdaily")
}
}
}

View File

@ -5,8 +5,8 @@
## Steps
1. Update changelog and version
- Ensure `CHANGELOG.md` has `## 0.2.0 - YYYY-MM-DD` with final notes.
- Update `version.env` to `0.2.0`.
- Ensure `CHANGELOG.md` has `## 0.1.0 - YYYY-MM-DD` with final notes.
- Update `version.env` to `0.1.0` (already set for the first release).
- Run `scripts/generate-version.sh` (refreshes `Sources/remindctl/Version.swift` + embedded Info.plist).
2. Ensure checks are green
- `make check`
@ -14,11 +14,11 @@
- Requires `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`.
- `scripts/sign-and-notarize.sh` (outputs `/tmp/remindctl-macos.zip` by default).
4. Tag, push, and publish
- `git tag -a v0.2.0 -m "v0.2.0"`
- `git push origin v0.2.0`
- `git tag -a v0.1.0 -m "v0.1.0"`
- `git push origin v0.1.0`
- Extract release notes:
```sh
version=0.2.0
version=0.1.0
notes_file=/tmp/release-notes.txt
awk -v v="$version" '
$0 ~ ("^## " v "($|[[:space:]]-)") { in_section=1; next }
@ -28,11 +28,10 @@
```
- Create GitHub release:
```sh
gh release create v0.2.0 /tmp/remindctl-macos.zip -t "v0.2.0" -F /tmp/release-notes.txt
gh release create v0.1.0 /tmp/remindctl-macos.zip -t "v0.1.0" -F /tmp/release-notes.txt
```
5. Update Homebrew tap
- Run `scripts/update-homebrew.sh vX.Y.Z` to trigger and watch the centralized formula updater.
- Requires a GitHub token with workflow dispatch access to `steipete/homebrew-tap`.
5. Homebrew tap
- Update `../homebrew-tap/Formula/remindctl.rb` to point at the GitHub release asset.
## What happens in CI
- Release signing + notarization are done locally via `scripts/sign-and-notarize.sh`.

View File

@ -16,7 +16,7 @@ Run on a local GUI session (not SSH-only) so the Reminders permission prompt can
- list lists: `remindctl list`
- list list contents: `remindctl list "remindctl-manual-YYYYMMDD"`
- add reminders (3 variants)
- show filters: `today`, `tomorrow`, `week`, `overdue`, `upcoming`, `open`, `completed`, `all`
- show filters: `today`, `tomorrow`, `week`, `overdue`, `upcoming`, `completed`, `all`
- edit: update title/notes/priority/due date
- complete: mark one reminder complete
- delete: remove reminders, then delete list

View File

@ -1,6 +1,6 @@
{
"name": "remindctl",
"version": "0.2.0",
"version": "0.1.0",
"private": true,
"scripts": {
"version:sync": "scripts/generate-version.sh",

View File

@ -1,50 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <release-tag>" >&2
exit 1
fi
TAG="$1"
TAP_REPO="steipete/homebrew-tap"
WORKFLOW="update-formula.yml"
SAFE_TAG=$(printf '%s' "$TAG" | tr -c 'A-Za-z0-9._-' '-')
REQUEST_ID="remindctl-${SAFE_TAG}-$(date -u +%Y%m%dT%H%M%SZ)-$$"
gh workflow run "$WORKFLOW" \
--repo "$TAP_REPO" \
--ref main \
-f formula=remindctl \
-f tag="$TAG" \
-f repository=steipete/remindctl \
-f macos_artifact="remindctl-macos.zip" \
-f request_id="$REQUEST_ID"
echo "Homebrew tap update dispatched: $REQUEST_ID"
RUN_ID=""
for _ in {1..30}; do
RUN_ID=$(gh run list \
--repo "$TAP_REPO" \
--workflow "$WORKFLOW" \
--branch main \
--limit 20 \
--json databaseId,displayTitle \
--jq ".[] | select(.displayTitle | contains(\"($REQUEST_ID)\")) | .databaseId" \
| head -n 1)
if [[ -n "$RUN_ID" ]]; then
break
fi
sleep 2
done
if [[ -z "$RUN_ID" ]]; then
echo "Timed out waiting for Homebrew tap workflow run: $REQUEST_ID" >&2
echo "Monitor: https://github.com/$TAP_REPO/actions/workflows/$WORKFLOW" >&2
exit 1
fi
gh run watch "$RUN_ID" --repo "$TAP_REPO" --exit-status

View File

@ -1 +1 @@
MARKETING_VERSION=0.2.0
MARKETING_VERSION=0.1.1