Compare commits

...

23 Commits
v0.1.0 ... main

Author SHA1 Message Date
Peter Steinberger
883ae25791
fix: resolve reminder indexes from show view
Some checks failed
CI / build (push) Has been cancelled
2026-05-08 15:13:00 +01:00
Peter Steinberger
de80b37c45
ci: update homebrew tap on release
Some checks are pending
CI / build (push) Waiting to run
2026-05-07 03:56:52 +01:00
Dinakar Sarbada
74beefa96a
docs: integrate Homebrew tap update into release flow
Some checks failed
CI / build (push) Has been cancelled
Co-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com>
2026-05-04 09:04:27 +01:00
Wenyou Yi
f3cd4eb683
docs: add remindctl skill for agentic tools
Add an apple-reminders skill that documents the current remindctl command surface for agentic tools.

Refined before merge to match the 0.2.0 release: macOS/Homebrew requirements, JSON aliases, open filter, metadata fields, recurrence, alarms, location trigger constraints, permissions, and EventKit limitations.

Co-authored-by: Allen Yi <yiwenyou_allen@outlook.com>
2026-05-04 06:50:51 +01:00
Peter Steinberger
1aff71567c
ci: update actions to node 24 runtime 2026-05-04 06:25:32 +01:00
Peter Steinberger
568457e27e
ci: opt in to node 24 actions runtime 2026-05-04 06:23:45 +01:00
Peter Steinberger
cd0ab4c1f4
docs: rewrite README 2026-05-04 06:21:45 +01:00
Peter Steinberger
4b79188ab5
docs: refresh README feature guide 2026-05-04 06:13:23 +01:00
Peter Steinberger
85a589366e
feat: add location reminder triggers
Co-authored-by: Octavio Froid <froid@bohm.com>
2026-05-04 05:58:14 +01:00
Peter Steinberger
20e868d615
feat: expose reminder modification metadata
Co-authored-by: Allen Yi <yiwenyou_allen@outlook.com>
2026-05-04 05:55:33 +01:00
Peter Steinberger
09b25089e1
feat: add simple recurrence support
Co-authored-by: Weber Wei <weberwcwei@users.noreply.github.com>
2026-05-04 05:54:01 +01:00
Peter Steinberger
b7f1ac959f
feat: add EventKit alarm support
Co-authored-by: 杨林 <yanglin@M1.local>
2026-05-04 05:51:03 +01:00
Peter Steinberger
57a73aaae1
feat: expose reminder URL metadata 2026-05-04 05:44:15 +01:00
Peter Steinberger
50cc4f56ba
feat: expose reminder creation date
Co-authored-by: Travis Irby <travis.irby@gmail.com>
2026-05-04 05:42:54 +01:00
Peter Steinberger
0dcfe4cdf1
docs: note open filter 2026-05-04 05:27:59 +01:00
Peter Peirce
f948ec77ea
feat: add open filter to show all incomplete reminders
The existing filters don't cover incomplete reminders without due dates.
`upcoming` requires a due date, so undated items (e.g. grocery lists)
are only visible via `all` which includes completed items too.

`remindctl open` returns all uncompleted reminders regardless of
due date status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit c5019fd566da5861dafc932d4ca9792cf33b6e46)
2026-05-04 05:27:54 +01:00
Peter Steinberger
bc92d10262
docs: add Reminders prompt workaround 2026-05-04 05:27:06 +01:00
Peter Steinberger
0c1b48dad2
chore: bump version to 0.2.0 2026-05-04 05:24:29 +01:00
Peter Steinberger
3d8d1a9e01
fix: preserve reminder due date intent 2026-05-04 05:24:23 +01:00
Peter Steinberger
a2215b6e8b
chore: update Commander dependency 2026-05-04 02:04:22 +01:00
Peter Steinberger
49026a5d54 chore(release): v0.1.1 2026-01-11 10:22:21 +00:00
Peter Steinberger
ce8556df82
Merge pull request #3 from daveonkels/fix/swift6-concurrency-violation
Fix Swift 6 strict concurrency violation in fetchReminders
2026-01-11 10:19:33 +00:00
Dave Onkels
7c902d083b Fix Swift 6 strict concurrency violation in fetchReminders
This fixes the "Incorrect actor executor assumption" error that occurred
when running `remindctl list`.

## Problem
The `fetchReminders` method was calling the actor-isolated `item(from:)`
method from inside a non-isolated EventKit completion handler. Additionally,
`EKReminder` objects are not `Sendable`, which violates Swift 6's strict
concurrency checking.

## Solution
Restructured `fetchReminders` to:
1. Extract all needed data from `EKReminder` objects inside the callback
   (where it's safe to access them)
2. Store that data in a local `Sendable` struct (`ReminderData`)
3. Pass the Sendable data across the concurrency boundary
4. Convert to `ReminderItem` on the actor using the safe data

This ensures all actor-isolated code runs on the correct executor and
all data crossing concurrency boundaries is Sendable.

## Testing
- Built successfully with Swift 6.0
- All commands tested: list, show, status
- JSON output verified
2026-01-05 22:55:55 -08:00
38 changed files with 1336 additions and 152 deletions

View File

@ -9,7 +9,7 @@ jobs:
build:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- 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@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@ -56,7 +56,7 @@ jobs:
)
- name: Publish release assets
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: dist/remindctl-macos.zip
env:
@ -83,3 +83,57 @@ 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,5 +1,24 @@
# 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
## 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)

View File

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

View File

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

247
README.md
View File

@ -1,74 +1,237 @@
# remindctl
Forget the app, not the task ✅
Fast command-line access to Apple Reminders on macOS.
Fast CLI for Apple Reminders on macOS.
`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.
## Install
### Homebrew (Home Pro)
### Homebrew
```bash
brew install steipete/tap/remindctl
```
### From source
### From Source
```bash
pnpm install
pnpm build
# binary at ./bin/remindctl
```
## Development
```bash
make remindctl ARGS="status" # clean build + run
make check # lint + test + coverage gate
```
## Requirements
- macOS 14+ (Sonoma or later)
- Swift 6.2+
- Reminders permission (System Settings → Privacy & Security → Reminders)
- Swift 6.2+ when building from source
- Full Reminders access for the terminal app that runs `remindctl`
## Quick Start
## 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 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 edit 1 --title "New title" --due 2026-01-04
remindctl complete 1 2 3
remindctl delete 4A83 --force
remindctl status # permission status
remindctl authorize # request permissions
```
## Output formats
- `--json` emits JSON arrays/objects.
- `--plain` emits tab-separated lines.
- `--quiet` emits counts only.
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:
## 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`)
- 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
```
## Permissions
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.
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).

167
SKILL.md Normal file
View File

@ -0,0 +1,167 @@
---
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,11 +1,29 @@
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()
@ -17,37 +35,48 @@ public enum DateParsing {
isoFormatter(withFraction: true).date(from: trimmed)
?? isoFormatter(withFraction: false).date(from: trimmed)
if let iso {
return iso
return ParsedUserDate(date: iso, isDateOnly: false)
}
for formatter in dateFormatters() {
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() {
if let date = formatter.date(from: trimmed) {
return date
return ParsedUserDate(date: date, isDateOnly: isDateOnly)
}
}
return nil
}
public static func formatDisplay(_ date: Date, calendar: Calendar = .current) -> String {
public static func formatDisplay(_ date: Date, isDateOnly: Bool = false, calendar: Calendar = .current) -> String {
let formatter = DateFormatter()
formatter.locale = Locale.current
formatter.timeZone = calendar.timeZone
formatter.dateStyle = .medium
formatter.timeStyle = .short
formatter.timeStyle = isDateOnly ? .none : .short
return formatter.string(from: date)
}
private static func parseRelativeDate(_ input: String, now: Date, calendar: Calendar) -> Date? {
private static func parseRelativeDate(_ input: String, now: Date, calendar: Calendar) -> ParsedUserDate? {
switch input {
case "today":
return calendar.startOfDay(for: now)
return ParsedUserDate(date: calendar.startOfDay(for: now), isDateOnly: true)
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 now
return ParsedUserDate(date: now, isDateOnly: false)
default:
return nil
}
@ -62,22 +91,30 @@ public enum DateParsing {
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",
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),
]
return formats.map { format in
return formats.map { format, isDateOnly in
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
formatter.dateFormat = format
return formatter
return (formatter, isDateOnly)
}
}
}

View File

@ -18,6 +18,8 @@ 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,6 +1,12 @@
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
@ -103,18 +109,19 @@ 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 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 item(from: reminder)
}
public func updateReminder(id: String, update: ReminderUpdate) async throws -> ReminderItem {
@ -128,11 +135,21 @@ 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
}
@ -145,17 +162,7 @@ public actor RemindersStore {
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
)
return item(from: reminder)
}
public func completeReminders(ids: [String]) async throws -> [ReminderItem] {
@ -164,19 +171,7 @@ public actor RemindersStore {
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
)
)
updated.append(item(from: reminder))
}
return updated
}
@ -190,7 +185,9 @@ public actor RemindersStore {
}
return deleted
}
}
extension RemindersStore {
private func requestFullAccess() async throws -> Bool {
try await withCheckedThrowingContinuation { continuation in
eventStore.requestFullAccessToReminders { granted, error in
@ -204,15 +201,73 @@ public actor RemindersStore {
}
private func fetchReminders(in calendars: [EKCalendar]) async -> [ReminderItem] {
await withCheckedContinuation { continuation in
struct ReminderData: Sendable {
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
}
let reminderData = await withCheckedContinuation { (continuation: CheckedContinuation<[ReminderData], Never>) in
let predicate = eventStore.predicateForReminders(in: calendars)
eventStore.fetchReminders(matching: predicate) { reminders in
let mapped = (reminders ?? []).map { reminder in
self.item(from: reminder)
let data = (reminders ?? []).map { reminder in
let components = reminder.dueDateComponents
return 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),
listID: reminder.calendar.calendarIdentifier,
listName: reminder.calendar.title
)
}
continuation.resume(returning: mapped)
continuation.resume(returning: data)
}
}
return reminderData.map { data in
ReminderItem(
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
)
}
}
private func reminder(withID id: String) throws -> EKReminder {
@ -230,8 +285,15 @@ public actor RemindersStore {
return calendar
}
private func calendarComponents(from date: Date) -> DateComponents {
calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)
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 date(from components: DateComponents?) -> Date? {
@ -240,16 +302,141 @@ public actor RemindersStore {
}
private func item(from reminder: EKReminder) -> ReminderItem {
ReminderItem(
let components = reminder.dueDateComponents
return 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: reminder.dueDateComponents),
dueDate: date(from: components),
dueDateIsAllDay: isAllDay(components),
alarmDate: Self.alarmDate(from: reminder),
recurrenceRule: Self.recurrenceRule(from: reminder),
locationTrigger: Self.locationTrigger(from: reminder),
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,18 +5,20 @@ public enum IDResolver {
public static func resolve(
_ inputs: [String],
from reminders: [ReminderItem]
from reminders: [ReminderItem],
numericFrom numericReminders: [ReminderItem]? = nil
) 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 < sorted.count else {
guard idx >= 0 && idx < numericSorted.count else {
throw RemindCoreError.invalidIdentifier(trimmed)
}
resolved.append(sorted[idx])
resolved.append(numericSorted[idx])
continue
}

View File

@ -33,6 +33,37 @@ 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
@ -43,14 +74,48 @@ 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
@ -58,20 +123,34 @@ 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
}
@ -80,13 +159,27 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
public struct ReminderDraft: Sendable {
public let title: String
public let notes: String?
public let dueDate: Date?
public let dueDate: ParsedUserDate?
public let alarmDate: ParsedUserDate?
public let recurrenceRule: RecurrenceRule?
public let locationTrigger: LocationTrigger?
public let priority: ReminderPriority
public init(title: String, notes: String?, dueDate: Date?, priority: ReminderPriority) {
public init(
title: String,
notes: String?,
dueDate: ParsedUserDate?,
alarmDate: ParsedUserDate? = nil,
recurrenceRule: RecurrenceRule? = nil,
locationTrigger: LocationTrigger? = nil,
priority: ReminderPriority
) {
self.title = title
self.notes = notes
self.dueDate = dueDate
self.alarmDate = alarmDate
self.recurrenceRule = recurrenceRule
self.locationTrigger = locationTrigger
self.priority = priority
}
}
@ -94,7 +187,9 @@ public struct ReminderDraft: Sendable {
public struct ReminderUpdate: Sendable {
public let title: String?
public let notes: String?
public let dueDate: Date??
public let dueDate: ParsedUserDate??
public let alarmDate: ParsedUserDate??
public let recurrenceRule: RecurrenceRule??
public let priority: ReminderPriority?
public let listName: String?
public let isCompleted: Bool?
@ -102,7 +197,9 @@ public struct ReminderUpdate: Sendable {
public init(
title: String? = nil,
notes: String? = nil,
dueDate: Date?? = nil,
dueDate: ParsedUserDate?? = nil,
alarmDate: ParsedUserDate?? = nil,
recurrenceRule: RecurrenceRule?? = nil,
priority: ReminderPriority? = nil,
listName: String? = nil,
isCompleted: Bool? = nil
@ -110,6 +207,8 @@ 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,6 +6,7 @@ public enum ReminderFilter: Equatable, Sendable {
case week
case overdue
case upcoming
case open
case completed
case date(Date)
case all
@ -25,6 +26,8 @@ public enum ReminderFiltering {
return .overdue
case "upcoming", "u":
return .upcoming
case "open":
return .open
case "completed", "done", "c":
return .completed
case "all", "a":
@ -76,6 +79,8 @@ 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,10 +17,64 @@ enum CommandHelpers {
}
}
static func parseDueDate(_ value: String) throws -> Date {
guard let date = DateParsing.parseUserDate(value) else {
static func parseDueDate(_ value: String) throws -> ParsedUserDate {
guard let parsed = DateParsing.parseUserDateWithMetadata(value) else {
throw RemindCoreError.invalidDate(value)
}
return date
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")
"""
)
}
}

View File

@ -17,19 +17,44 @@ 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
@ -55,9 +80,20 @@ 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()
@ -73,9 +109,44 @@ enum AddCommand {
throw RemindCoreError.operationFailed("No default list found. Specify --list.")
}
let draft = ReminderDraft(title: title, notes: notes, dueDate: dueDate, priority: priority)
let draft = ReminderDraft(
title: title,
notes: notes,
dueDate: dueDate,
alarmDate: alarmDate,
recurrenceRule: recurrenceRule,
locationTrigger: locationTrigger,
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 IDResolver.resolve(inputs, from: reminders)
let resolved = try CommandHelpers.resolveShowIdentifiers(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 IDResolver.resolve(inputs, from: reminders)
let resolved = try CommandHelpers.resolveShowIdentifiers(inputs, from: reminders)
if values.flag("dryRun") {
OutputRenderer.printReminders(resolved, format: runtime.outputFormat)

View File

@ -17,7 +17,14 @@ 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")],
@ -27,6 +34,8 @@ 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"),
]
@ -35,8 +44,10 @@ 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",
"remindctl edit 3 --clear-due --clear-alarm --no-repeat",
]
) { values, runtime in
guard let input = values.argument(0) else {
@ -46,7 +57,7 @@ enum EditCommand {
let store = RemindersStore()
try await store.requestAccess()
let reminders = try await store.reminders(in: nil)
let resolved = try IDResolver.resolve([input], from: reminders)
let resolved = try CommandHelpers.resolveShowIdentifiers([input], from: reminders)
guard let reminder = resolved.first else {
throw RemindCoreError.reminderNotFound(input)
}
@ -54,8 +65,10 @@ 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: Date??
var dueUpdate: ParsedUserDate??
if let dueValue = values.option("due") {
dueUpdate = try CommandHelpers.parseDueDate(dueValue)
}
@ -66,6 +79,28 @@ 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)
@ -78,7 +113,10 @@ enum EditCommand {
}
let isCompleted: Bool? = completeFlag ? true : (incompleteFlag ? false : nil)
if title == nil && listName == nil && notes == nil && dueUpdate == nil && priority == nil && isCompleted == nil {
let hasChanges =
title != nil || listName != nil || notes != nil || dueUpdate != nil || alarmUpdate != nil || priority != nil
|| recurrenceUpdate != nil || isCompleted != nil
if !hasChanges {
throw RemindCoreError.operationFailed("No changes specified")
}
@ -86,6 +124,8 @@ 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 a name, shows reminders in that list.",
discussion: "Without a name, shows all lists. With one or more names, shows reminders in those lists.",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
arguments: [
.make(label: "name", help: "List name", isOptional: true)
.make(label: "name", help: "List name(s)", isOptional: true)
],
options: [
.make(
@ -31,12 +31,13 @@ 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 name = values.argument(0)
let names = values.positional
let renameTo = values.option("rename")
let deleteList = values.flag("delete")
let createList = values.flag("create")
@ -45,7 +46,11 @@ enum ListCommand {
let store = RemindersStore()
try await store.requestAccess()
if let name {
if !names.isEmpty {
let name = try singleListName(
names,
forMutation: deleteList || renameTo != nil || createList
)
if deleteList {
if !force && !runtime.noInput && Console.isTTY {
if !Console.confirm("Delete list \"\(name)\"?", defaultValue: false) {
@ -80,7 +85,7 @@ enum ListCommand {
return
}
let reminders = try await store.reminders(in: name)
let reminders = try await reminders(in: names, store: store)
OutputRenderer.printReminders(reminders, format: runtime.outputFormat)
return
}
@ -109,4 +114,23 @@ 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, completed, all, or a date string.",
discussion: "Filters: today, tomorrow, week, overdue, upcoming, open, completed, all, or a date string.",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
arguments: [
.make(
label: "filter",
help: "today|tomorrow|week|overdue|upcoming|completed|all|<date>",
help: "today|tomorrow|week|overdue|upcoming|open|completed|all|<date>",
isOptional: true
)
],

View File

@ -50,8 +50,12 @@ enum OutputRenderer {
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)")
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)")
case .plain:
Swift.print(plainLine(for: reminder))
case .json:
@ -96,9 +100,14 @@ enum OutputRenderer {
}
for (index, reminder) in sorted.enumerated() {
let status = reminder.isCompleted ? "x" : " "
let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date"
let due =
reminder.dueDate.map {
DateParsing.formatDisplay($0, isDateOnly: reminder.dueDateIsAllDay)
} ?? "no due date"
let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)"
Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)")
let recurrence = recurrenceSuffix(for: reminder)
Swift.print(
"[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)\(recurrence)")
}
}
@ -110,7 +119,15 @@ enum OutputRenderer {
}
private static func plainLine(for reminder: ReminderItem) -> String {
let due = reminder.dueDate.map { isoFormatter().string(from: $0) } ?? ""
let due: String
if let dueDate = reminder.dueDate {
due =
reminder.dueDateIsAllDay
? dateOnlyFormatter().string(from: dueDate)
: isoFormatter().string(from: dueDate)
} else {
due = ""
}
return [
reminder.id,
reminder.listName,
@ -157,4 +174,16 @@ 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,6 +2,7 @@ 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 {
@ -15,6 +16,7 @@ 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.1.0</string>
<string>0.2.0</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<string>0.2.0</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.1.0"
static let current = "0.2.0"
}

View File

@ -30,6 +30,14 @@ 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"
@ -43,4 +51,28 @@ 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,6 +38,13 @@ 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,6 +10,7 @@ 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,6 +108,16 @@ 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

@ -0,0 +1,45 @@
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,4 +21,19 @@ 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

@ -0,0 +1,19 @@
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

@ -0,0 +1,14 @@
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

@ -0,0 +1,34 @@
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.1.0 - YYYY-MM-DD` with final notes.
- Update `version.env` to `0.1.0` (already set for the first release).
- Ensure `CHANGELOG.md` has `## 0.2.0 - YYYY-MM-DD` with final notes.
- Update `version.env` to `0.2.0`.
- 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.1.0 -m "v0.1.0"`
- `git push origin v0.1.0`
- `git tag -a v0.2.0 -m "v0.2.0"`
- `git push origin v0.2.0`
- Extract release notes:
```sh
version=0.1.0
version=0.2.0
notes_file=/tmp/release-notes.txt
awk -v v="$version" '
$0 ~ ("^## " v "($|[[:space:]]-)") { in_section=1; next }
@ -28,10 +28,11 @@
```
- Create GitHub release:
```sh
gh release create v0.1.0 /tmp/remindctl-macos.zip -t "v0.1.0" -F /tmp/release-notes.txt
gh release create v0.2.0 /tmp/remindctl-macos.zip -t "v0.2.0" -F /tmp/release-notes.txt
```
5. Homebrew tap
- Update `../homebrew-tap/Formula/remindctl.rb` to point at the GitHub release asset.
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`.
## 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`, `completed`, `all`
- show filters: `today`, `tomorrow`, `week`, `overdue`, `upcoming`, `open`, `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.1.0",
"version": "0.2.0",
"private": true,
"scripts": {
"version:sync": "scripts/generate-version.sh",

50
scripts/update-homebrew.sh Executable file
View File

@ -0,0 +1,50 @@
#!/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.1.0
MARKETING_VERSION=0.2.0