
Apple Reminders
- 2 installs
- 319 repo stars
- Updated August 2, 2026
- steipete/remindctl
Helps with ai & agent building tasks during AI-assisted development.
About
apple-reminders is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- apple-reminders
- AI & Agent Building
- AI-coding skill
Apple Reminders by the numbers
- 2 all-time installs (skills.sh)
- +1 installs in the week ending Jul 11, 2026 (Skillselion tracking)
- Ranked #13,957 of 16,556 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/steipete/remindctl --skill apple-remindersAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 319 |
| Last updated | August 2, 2026 |
| Repository | steipete/remindctl ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
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
remindctlinstalled and available onPATH- Install with
brew install steipete/tap/remindctl - Reminders access for the terminal app that runs
remindctl - Use
remindctl statusto 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
remindctldefaults toshow todayshowaccepts filters:today,tomorrow,week,overdue,upcoming,open,completed,all, or a date stringshow --list <name>limits a view to one listlistshows all lists with no arguments, or reminders in one or more named listsaddcreates a remindereditupdates a reminder by index or ID prefixcompletemarks reminders completedeleteremoves remindersstatusreports Reminders authorization without promptingauthorizerequests permission when possible
Helpful Aliases
listsandlsmap tolistrmmaps todeletedonemaps tocomplete
Output And Flags
--json,-j,--json-output, and--jsonOutputemit JSON--plainemits stable tab-separated output--quietemits minimal output--no-colordisables colored output--no-inputdisables 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 --jsonorremindctl show --jsonto inspect reminders - Use
remindctl open --jsonfor all incomplete reminders, including reminders without due dates - Use
remindctl list --jsonto inspect lists - Use
remindctl list Work Errands --jsonto inspect multiple lists together - Use
remindctl status --jsonto inspect authorization state - Reminder IDs and display indexes come from
showoutput;complete,delete, andeditaccept 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--url <url>set the dedicated URL field--repeat <rule>set simple recurrence--priority <none|low|medium|high>set priority--location <address>create a location trigger--radius <meters>adjust geofence radius--leavingtrigger 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--dueor--clear-due--alarmor--clear-alarm--notes--urlor--clear-url--repeator--no-repeat--priority--completeor--incomplete
Reject conflicting combinations such as --due with --clear-due, --alarm with --clear-alarm, --url with --clear-url, --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,yesterdayYYYY-MM-DDYYYY-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
--alarmoverrides 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,yearlyevery N days/weeks/months/years
Lists
remindctl listprints all lists with reminder and overdue countsremindctl list <name...>prints reminders in one or more listsremindctl list <name> --createcreates the list if missingremindctl list <name> --deletedeletes the listremindctl list <name> --rename <new-name>renames the listremindctl list <name> --forceskips confirmation for destructive list deletion- Create, delete, and rename accept one list name only
Completion And Deletion
completeanddeleterequire one or more IDs or indexesdeleteprompts for confirmation unless--forceor--no-inputsuppresses itcompletesupports--dry-rundeletesupports--dry-run
Permissions
Use remindctl status first when permission state matters.
statusnever promptsauthorizetriggers the system prompt when the state isnotDetermined- 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:
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;
remindctlintentionally stays on public EventKit APIs
name: CI
on:
push:
branches: [ main ]
pull_request:
jobs:
build:
runs-on: macos-15
steps:
- uses: actions/checkout@v6
- name: Swift version
run: swift --version
- name: Install SwiftLint
run: brew install swiftlint
- name: Generate version files
run: scripts/generate-version.sh
- name: Swift format lint
run: swift format lint --recursive Sources Tests
- name: SwiftLint
run: swiftlint --strict
- name: Swift test + coverage
run: scripts/check-coverage.sh
- name: Swift build
run: swift build -c release --product remindctl
name: pages
on:
push:
branches:
- main
paths:
- "docs/**"
- "scripts/build-docs-site.mjs"
- "Makefile"
- "package.json"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
name: Deploy docs
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Check out
uses: actions/checkout@v6
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Build docs site
run: make docs-site
- name: Configure Pages
uses: actions/configure-pages@v6
with:
enablement: true
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
path: dist/docs-site
- name: Deploy
id: deployment
uses: actions/deploy-pages@v5
name: release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to (re)release (e.g. v0.1.0)"
required: true
type: string
permissions:
contents: write
jobs:
release:
runs-on: macos-15
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Determine tag
id: tag
shell: bash
env:
INPUT_TAG: ${{ inputs.tag }}
run: |
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
printf 'tag=%s\n' "$INPUT_TAG" >> "$GITHUB_OUTPUT"
else
printf 'tag=%s\n' "$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
fi
- name: Checkout release tag
if: ${{ github.event_name == 'workflow_dispatch' }}
env:
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
run: git checkout --detach "$RELEASE_TAG"
- name: Resolve packages
run: swift package resolve
- name: Sync version
run: scripts/generate-version.sh
- name: Build
run: swift build -c release --product remindctl
- name: Codesign
run: codesign --force --sign - --identifier com.steipete.remindctl .build/release/remindctl
- name: Package artifact
run: |
mkdir -p dist
cp .build/release/remindctl dist/remindctl
(
cd dist
zip -r remindctl-macos.zip remindctl
)
- name: Extract release notes from CHANGELOG
shell: bash
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
version="${TAG#v}"
notes_file="/tmp/release-notes.md"
awk -v v="$version" '
$0 ~ ("^## " v "($|[[:space:]]-)") { in_section=1; next }
in_section && $0 ~ "^## " { exit }
in_section { print }
' CHANGELOG.md > "$notes_file"
if ! grep -q '[^[:space:]]' "$notes_file"; then
echo "No CHANGELOG.md section found for version $version" >&2
exit 1
fi
- name: Publish release assets
uses: softprops/action-gh-release@v3
with:
files: dist/remindctl-macos.zip
tag_name: ${{ steps.tag.outputs.tag }}
body_path: /tmp/release-notes.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-homebrew-tap:
runs-on: ubuntu-latest
needs: release
steps:
- name: Resolve release tag
env:
RELEASE_TAG: ${{ needs.release.outputs.tag }}
run: printf 'RELEASE_TAG=%s\n' "$RELEASE_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
.DS_Store
.build
.swiftpm
Packages
DerivedData
bin
dist
{
"version": 1,
"lineLength": 120,
"indentation": {
"spaces": 2
},
"respectsExistingLineBreaks": true,
"lineBreakBeforeControlFlowKeywords": false,
"lineBreakBeforeEachArgument": false,
"lineBreakBeforeEachGenericRequirement": false,
"prioritizeKeepingFunctionOutputTogether": false,
"indentConditionalCompilationBlocks": true,
"lineBreakAroundMultilineExpressionChainComponents": false,
"fileScopedDeclarationPrivacy": {
"accessLevel": "private"
},
"rules": {
"AllPublicDeclarationsHaveDocumentation": false,
"AlwaysUseLiteralForEmptyCollectionInit": false,
"AlwaysUseLowerCamelCase": true,
"AmbiguousTrailingClosureOverload": true,
"BeginDocumentationCommentWithOneLineSummary": false,
"DoNotUseSemicolons": true,
"DontRepeatTypeInStaticProperties": true,
"FileScopedDeclarationPrivacy": true,
"FullyIndirectEnum": true,
"GroupNumericLiterals": true,
"IdentifiersMustBeASCII": true,
"NeverForceUnwrap": false,
"NeverUseForceTry": false,
"NeverUseImplicitlyUnwrappedOptionals": false,
"NoAccessLevelOnExtensionDeclaration": true,
"NoAssignmentInExpressions": true,
"NoBlockComments": true,
"NoCasesWithOnlyFallthrough": true,
"NoEmptyTrailingClosureParentheses": true,
"NoLabelsInCasePatterns": true,
"NoLeadingUnderscores": false,
"NoParensAroundConditions": true,
"NoPlaygroundLiterals": true,
"NoVoidReturnOnFunctionSignature": true,
"OmitExplicitReturns": false,
"OneCasePerLine": true,
"OneVariableDeclarationPerLine": true,
"OnlyOneTrailingClosureArgument": true,
"OrderedImports": true,
"ReplaceForEachWithForLoop": true,
"ReturnVoidInsteadOfEmptyTuple": true,
"UseEarlyExits": false,
"UseLetInEveryBoundCaseVariable": true,
"UseShorthandTypeNames": true,
"UseSingleLinePropertyGetter": true,
"UseSynthesizedInitializer": true,
"UseTripleSlashForDocumentationComments": true,
"UseWhereClausesInForLoops": false,
"ValidateDocumentationComments": false
}
}
included:
- Sources
- Tests
excluded:
- .build
- .swiftpm
- Packages
- .git
- DerivedData
- "**/Generated"
- "**/Resources"
opt_in_rules:
- array_init
- closure_spacing
- contains_over_first_not_nil
- empty_count
- empty_string
- explicit_init
- fallthrough
- fatal_error_message
- first_where
- joined_default_parameter
- last_where
- literal_expression_end_indentation
- multiline_arguments
- multiline_parameters
- operator_usage_whitespace
- overridden_super_call
- private_outlet
- redundant_nil_coalescing
- sorted_first_last
- switch_case_alignment
- unneeded_parentheses_in_closure_argument
- vertical_parameter_alignment_on_call
disabled_rules:
- explicit_self
- identifier_name
- file_header
- explicit_acl
- explicit_top_level_acl
- explicit_type_interface
- missing_docs
- required_deinit
- trailing_whitespace
- trailing_newline
- trailing_comma
- vertical_whitespace
- indentation_width
- sorted_imports
- file_name
force_cast: warning
force_try: warning
line_length:
warning: 120
error: 140
ignores_comments: true
ignores_urls: true
file_length:
warning: 450
error: 500
ignore_comment_only_lines: true
type_body_length:
warning: 250
error: 400
function_body_length:
warning: 80
error: 120
cyclomatic_complexity:
warning: 15
error: 25
nesting:
type_level:
warning: 4
error: 6
function_level:
warning: 5
error: 7
large_tuple:
warning: 4
error: 5
reporter: "xcode"
Changelog
0.3.2 - Unreleased
0.3.1 - 2026-06-11
- Add support for setting the reminder URL field via
--urlonadd/editand--clear-urlonedit; thanks @jeremylahners. - Redesign the GitHub Pages documentation site with light/dark mode and a reminder-focused overview.
0.3.0 - 2026-05-28
- Add exact
--list-idtargeting, normalized list-name resolution,doctor,export,link,open, shell completion generation, table output, and release preflight checks. - Add a GitHub Pages documentation site for remindctl.sh.
- Raise the RemindCore coverage gate to 90% and run SwiftLint in strict mode.
- Add
searchandinfocommands for title, notes, URL lookup, and detailed reminder inspection. - Resolve numeric edit/complete/delete indexes against the default
showview 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
--repeatand--no-repeat - Add EventKit alarm support via
--alarmand--clear-alarm - Add reminder
urlto JSON output when EventKit exposes one - Add
lastModifiedDateto reminder JSON output - Add
creationDateto reminder JSON output - Add
openfilter 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
listto 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)
- Manage lists (list, create, rename, delete)
- Add, edit, complete, and delete reminders
- Authorization status and permission prompt command
- JSON and plain output modes for scripting
- Flexible date parsing (relative, ISO 8601, and common formats)
- GitHub Actions CI with lint, tests, and coverage gate
remindctl.sh
Commands
Show reminders
remindctl today
remindctl tomorrow
remindctl week
remindctl overdue
remindctl upcoming
remindctl open
remindctl completed
remindctl all
remindctl 2026-01-03Limit a view to one list:
remindctl show overdue --list Work
remindctl show overdue --list-id 7A12Show multiple lists together:
remindctl list Work ErrandsCreate reminders
remindctl add "Review notes"
remindctl add "Call Sam" --list Work --due tomorrow
remindctl add "Call Sam" --list-id 7A12 --due tomorrow
remindctl add "Take vitamins" --due tomorrow --repeat daily
remindctl add "Check mailbox" --location "1 Apple Park Way, Cupertino, CA"Useful add options:
--list <name>chooses the target list.--list-id <id-prefix>chooses the target list exactly.--due <date>sets a due date.--alarm <date>sets a notification alarm.--notes <text>adds notes.--repeat <rule>sets simple recurrence.--priority <none|low|medium|high>sets priority.--location <address>creates an arriving geofence trigger.--leavingchanges a location trigger to leaving.--radius <meters>adjusts the geofence radius.
Edit reminders
remindctl edit 1 --title "New title"
remindctl edit 4A83 --due "2026-01-04 09:00"
remindctl edit 4A83 --clear-due
remindctl edit 4A83 --list Office
remindctl edit 4A83 --list-id 7A12
remindctl edit 4A83 --no-repeatedit, complete, and delete accept indexes from the current default listing or ID prefixes.
Lists
remindctl list
remindctl list Work
remindctl list Projects --create
remindctl list Work --rename Office
remindctl list OldList --delete --force
remindctl list --list-id 7A12
remindctl list --list-id 7A12 --rename ArchiveMutating list operations accept one list name. Read-only list views can accept multiple names. List names resolve by exact match, case-insensitive match, then a normalized match that ignores emoji and punctuation. If a name is ambiguous, use --list-id.
Search and inspect
remindctl search "invoice" --list Work
remindctl search "project" --completed --json
remindctl info 1
remindctl info 4A83 --jsonExport, links, and app handoff
remindctl export --json
remindctl export --list Work --export-format csv
remindctl link 1
remindctl link --list-id 7A12
remindctl open 1
remindctl open --list Work
remindctl open --list Work --app
remindctl completion zshopen --list Work keeps the historical open-reminders filter. Add --app to open that list in Reminders.app.
Diagnostics
remindctl status
remindctl doctor --for-agentOutput
remindctl all --json
remindctl list --json
remindctl today --plain
remindctl today --format table
remindctl status --jsonGlobal output flags:
--jsonemits machine-readable JSON.--plainemits stable tab-separated lines.--format tableemits tabular output.--quietemits minimal output.--no-colordisables colored output.--no-inputdisables interactive prompts.
Try it
brew install steipete/tap/remindctl
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 list --list-id 7A12
remindctl search "milk"
remindctl info 1
remindctl doctor --for-agent
remindctl export --list Work --export-format csv
remindctl link 1
remindctl edit 1 --title "New title" --due 2026-01-04
remindctl complete 1 2 3
remindctl delete 4A83 --forceIndexes such as 1 come from the default reminder listing. Most commands also accept an ID prefix such as 4A83.
What remindctl does
- Uses Apple's public EventKit APIs, so changes sync through the normal Reminders and iCloud path.
- Reads and updates reminders from scripts, terminals, CI helpers, and local agents.
- Supports due dates, alarms, recurrence, priorities, notes, exact list IDs, completion, deletion, and location triggers.
- Emits JSON for automation, TSV with
--plain, tables with--format table, and compact human output by default. - Includes
doctor,export,link,open, and shell completion helpers for agent workflows. - Stays inside public EventKit limits. Private Reminders.app features such as tags, sections, smart lists, attachments, and the "Urgent" toggle are not exposed.
Pick your path
- Install from Homebrew or source in Install.
- See day-to-day syntax in Commands.
- Check macOS permission setup in Permissions.
- Run local UI checks with Manual Tests.
- Release notes and shipped changes live in the changelog.
Released under the MIT license. Not affiliated with Apple.
Install
Homebrew
brew install steipete/tap/remindctlFrom source
git clone https://github.com/openclaw/remindctl.git
cd remindctl
pnpm install
pnpm build
./bin/remindctl statusRequirements
- macOS 14 or later.
- Swift 6.2 or later when building from source.
- Full Reminders access for the terminal app that runs
remindctl.
First run
remindctl status
remindctl authorizeIf macOS reports access as denied, enable the terminal app in:
System Settings > Privacy & Security > RemindersManual tests
Scope
Run on a local GUI session (not SSH-only) so the Reminders permission prompt can appear.
Test data
- Use a dedicated list:
remindctl-manual-YYYYMMDD(create if missing). - Create 3 reminders with distinct states:
remindctl test A(due today, priority high)remindctl test B(due tomorrow)remindctl test C(no due date)
Checklist
- authorize:
remindctl authorize - status:
remindctl status - doctor:
remindctl doctor --for-agent --json - list lists:
remindctl list - list table output:
remindctl list --format table - list list contents:
remindctl list "remindctl-manual-YYYYMMDD" - list by ID:
remindctl list --list-id <list-id-prefix> - add reminders (3 variants)
- add to exact list ID:
remindctl add "remindctl test D" --list-id <list-id-prefix> - show filters:
today,tomorrow,week,overdue,upcoming,open,completed,all - search:
remindctl search "remindctl test" --format table - info:
remindctl info <id-prefix> --json - export:
remindctl export --list-id <list-id-prefix> --jsonand--export-format csv - link:
remindctl link <id-prefix>andremindctl link --list-id <list-id-prefix> - open filter:
remindctl open --list-id <list-id-prefix> --format table - edit: update title/notes/priority/due date
- complete: mark one reminder complete
- delete: remove reminders, then delete list
Release gate
make checkmust pass strict SwiftLint, tests, and the 90% RemindCore coverage gate.make docs-sitemust build without broken internal links.make release-check TAG=vX.Y.Zmust pass before pushing a release tag.
Results
- Date:
- Machine:
- Permission state before/after:
- Notes:
Permissions
remindctl uses EventKit. macOS grants Reminders access per app, so the terminal app that runs remindctl must have permission.
Check access
remindctl statusRequest access
remindctl authorizeIf macOS reports access as denied, enable the terminal app in:
System Settings > Privacy & Security > RemindersIf no prompt appears, run this once from the same terminal app:
osascript -e 'tell application "Reminders" to get name of reminders'Then allow access and rerun:
remindctl statusWhen running over SSH, grant access on the Mac that actually runs remindctl.
Releasing
Release notes source
- GitHub Release notes come from
CHANGELOG.mdfor the matching version section (## X.Y.Z - YYYY-MM-DD).
Steps
1. Update changelog and version
- Ensure
CHANGELOG.mdhas## X.Y.Z - YYYY-MM-DDwith final notes. - Update
version.envtoX.Y.Z. - Run
scripts/generate-version.sh(refreshesSources/remindctl/Version.swift+ embedded Info.plist).
2. Ensure checks are green
make check(strict lint, tests, and the 90% coverage gate)make release-check TAG=vX.Y.Z
3. Commit and tag
git tag -a vX.Y.Z -m "vX.Y.Z"git push origin vX.Y.Z
4. Autorelease
- Pushing
v*tags runs.github/workflows/release.yml. - The workflow builds
remindctl-macos.zip, creates or updates the GitHub Release, replaces release notes from the matchingCHANGELOG.mdsection, then dispatches the Homebrew tap formula updater. - Requires
HOMEBREW_TAP_TOKENwith workflow dispatch access tosteipete/homebrew-tap.
Manual rerun
- Use the
releaseworkflow dispatch withtag=vX.Y.Zto rebuild an existing tag. - Use
scripts/update-homebrew.sh vX.Y.Zto rerun only the centralized formula updater.
What happens in CI
.github/workflows/release.ymlruns on pushedv*tags and manual dispatch.- The GitHub-hosted artifact is ad-hoc signed for Homebrew distribution.
scripts/sign-and-notarize.shremains available for local notarized builds when needed.
MIT License
Copyright (c) 2026 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SHELL := /bin/bash
.PHONY: help format lint test check build remindctl release-check docs-site clean
help:
@printf "%s\n" \
"make format - swift format in-place" \
"make lint - swift format lint + strict swiftlint" \
"make test - sync version + swift test (coverage enabled)" \
"make check - lint + test + coverage gate" \
"make build - release build into bin/ (codesigned)" \
"make release-check TAG=vX.Y.Z - validate release preflight" \
"make remindctl - clean rebuild + run debug binary (ARGS=...)" \
"make docs-site - build GitHub Pages docs into dist/docs-site" \
"make clean - swift package clean"
format:
swift format --in-place --recursive Sources Tests
lint:
swift format lint --recursive Sources Tests
swiftlint --strict
test:
scripts/generate-version.sh
swift test --enable-code-coverage
check:
$(MAKE) lint
$(MAKE) test
scripts/check-coverage.sh
build:
scripts/generate-version.sh
mkdir -p bin
swift build -c release --product remindctl
cp .build/release/remindctl bin/remindctl
codesign --force --sign - --identifier com.steipete.remindctl bin/remindctl
release-check:
@if [ -z "$(TAG)" ]; then echo "Usage: make release-check TAG=vX.Y.Z" >&2; exit 1; fi
scripts/check-release.sh "$(TAG)"
remindctl:
scripts/generate-version.sh
swift package clean
swift build -c debug --product remindctl
./.build/debug/remindctl $(ARGS)
docs-site:
node scripts/build-docs-site.mjs
clean:
swift package clean
{
"name": "remindctl",
"version": "0.3.0",
"private": true,
"scripts": {
"version:sync": "scripts/generate-version.sh",
"remindctl": "pnpm -s version:sync && swift package clean && swift run remindctl",
"start": "pnpm -s remindctl",
"format": "swift format --in-place --recursive Sources Tests",
"lint": "swift format lint --recursive Sources Tests && swiftlint --strict",
"test": "pnpm -s version:sync && swift test --enable-code-coverage",
"coverage": "scripts/check-coverage.sh",
"release:check": "scripts/check-release.sh",
"check": "pnpm -s lint && pnpm -s test && pnpm -s coverage",
"docs:site": "node scripts/build-docs-site.mjs",
"clean": "swift package clean",
"build": "pnpm -s version:sync && mkdir -p bin && swift build -c release --product remindctl && cp .build/release/remindctl bin/remindctl && codesign --force --sign - --identifier com.steipete.remindctl bin/remindctl"
}
}
{
"originHash" : "a669a19d7dad51d9569b07c55a81b6066ee617b2f82a0f83681682c9ff35bc10",
"pins" : [
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b",
"version" : "0.2.2"
}
}
],
"version" : 3
}
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "remindctl",
platforms: [.macOS(.v14)],
products: [
.library(name: "RemindCore", targets: ["RemindCore"]),
.executable(name: "remindctl", targets: ["remindctl"]),
],
dependencies: [
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
],
targets: [
.target(
name: "RemindCore",
dependencies: [],
linkerSettings: [
.linkedFramework("CoreLocation"),
.linkedFramework("EventKit"),
]
),
.executableTarget(
name: "remindctl",
dependencies: [
"RemindCore",
.product(name: "Commander", package: "Commander"),
],
exclude: [
"Resources/Info.plist",
],
linkerSettings: [
.unsafeFlags([
"-Xlinker", "-sectcreate",
"-Xlinker", "__TEXT",
"-Xlinker", "__info_plist",
"-Xlinker", "Sources/remindctl/Resources/Info.plist",
]),
]
),
.testTarget(
name: "RemindCoreTests",
dependencies: [
"RemindCore",
]
),
.testTarget(
name: "remindctlTests",
dependencies: [
"remindctl",
"RemindCore",
]
),
],
swiftLanguageModes: [.v6]
)
remindctl
!remindctl banner
Fast command-line access to 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.
Docs: https://remindctl.sh
Install
Homebrew
brew install steipete/tap/remindctlFrom Source
pnpm install
pnpm build
# binary at ./bin/remindctlRequirements
- macOS 14+ (Sonoma or later)
- Swift 6.2+ when building from source
- Full Reminders access for the terminal app that runs
remindctl
Quick Start
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 add "Buy headphones" --url "https://example.com/product"
remindctl today
remindctl overdue
remindctl open
remindctl list Work Errands
remindctl list --list-id 7A12
remindctl search "milk"
remindctl info 1
remindctl doctor --for-agent
remindctl export --list Work --export-format csv
remindctl link 1
remindctl edit 1 --title "New title" --due 2026-01-04
remindctl edit 1 --url "https://example.com/product" # or --clear-url to remove it
remindctl complete 1 2 3
remindctl delete 4A83 --forceIndexes 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 search <query> | Search incomplete reminder titles, notes, and URLs |
remindctl info <id> | Show detailed reminder metadata |
remindctl list | Show reminder lists |
remindctl list <name...> | Show reminders from one or more lists |
remindctl export | Export reminders as JSON or CSV |
remindctl link <id> | Print a best-effort Reminders deep link |
remindctl open <id> | Open a reminder or list in Reminders.app |
remindctl doctor | Diagnose permissions and read-only rich-store access |
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:
remindctl today
remindctl tomorrow
remindctl week
remindctl overdue
remindctl upcoming
remindctl open
remindctl completed
remindctl all
remindctl 2026-01-03Search titles, notes, and URLs:
remindctl search "milk"
remindctl search "invoice" --list Work
remindctl search "project" --completed --jsonStandard search output includes stable reminder IDs instead of numeric indexes.
Inspect one reminder:
remindctl info 1
remindctl info 4A83 --jsonLimit a view to one list:
remindctl show overdue --list Work
remindctl show overdue --list-id 7A12Show multiple lists together:
remindctl list Work ErrandsLists
remindctl list
remindctl list Work
remindctl list Projects --create
remindctl list Work --rename Office
remindctl list OldList --delete --forceMutating list operations accept one list name. Read-only list views can accept multiple names. List names resolve by exact match, case-insensitive match, then a normalized match that ignores emoji and punctuation. If a name is ambiguous, use --list-id.
Exact list targeting:
remindctl list --list-id 7A12
remindctl add "Review notes" --list-id 7A12
remindctl edit 4A83 --list-id 7A12
remindctl list --list-id 7A12 --rename Archive
remindctl list --list-id 7A12 --delete --forceDates And Due Times
Accepted by --due and date filters:
today,tomorrow,yesterdayYYYY-MM-DDYYYY-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.
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-alarmThis 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:
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-repeatSupported repeat values:
daily,weekly,biweekly,monthly,yearlyevery 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.
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 200Location triggers use EventKit and CoreLocation geocoding. They may depend on system location services and network availability.
Output
Global output flags:
--jsonemits machine-readable JSON.--plainemits stable tab-separated lines.--format tableemits tabular output for scan-heavy commands.--quietemits minimal output, usually counts or nothing.--no-colordisables colored output.--no-inputdisables interactive prompts.
JSON includes public EventKit metadata when available:
creationDatelastModifiedDateurlalarmDatelocationTriggerrecurrenceRule
Example:
remindctl all --json
remindctl today --format table
remindctl list --json
remindctl status --jsonExport, Links, And Completion
remindctl export --json
remindctl export --list Work --export-format csv
remindctl link 1
remindctl link --list-id 7A12
remindctl open 1
remindctl open --list Work
remindctl open --list Work --app
remindctl open --app
remindctl completion zshlink and open <id> use best-effort Reminders deep links based on EventKit IDs. open --list Work keeps the historical open-reminders filter; add --app to open that list in Reminders.app.
Permissions
Check access:
remindctl status
remindctl doctor --for-agentRequest access:
remindctl authorizeIf macOS reports access as denied, enable the terminal app in:
System Settings > Privacy & Security > RemindersIf no prompt appears, run this once from the same terminal app:
osascript -e 'tell application "Reminders" to get name of reminders'Then allow access and rerun:
remindctl statusWhen 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
make remindctl ARGS="status" # clean build + run
make check # strict lint + tests + 90% coverage gate
make release-check TAG=vX.Y.Z # validate release preflight
pnpm build # release build into ./bin/remindctlRelease steps live in docs/RELEASING.md.
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const docsDir = path.join(root, "docs");
const outDir = path.join(root, "dist", "docs-site");
const repoBase = "https://github.com/openclaw/remindctl";
const repoEditBase = `${repoBase}/edit/main/docs`;
const cname = readCname();
const siteBase = cname ? `https://${cname}` : "";
const productName = "remindctl";
const productTagline = "Apple Reminders in your terminal";
const productDescription = "A fast macOS CLI for Apple Reminders, built for terminals, scripts, and agents.";
const installCommand = "brew install steipete/tap/remindctl";
const sections = [
["Start", ["index.md", "install.md", "commands.md", "permissions.md"]],
["Reference", ["manual-tests.md"]],
];
const buildExcludes = new Set(["RELEASING.md"]);
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
const allPages = allMarkdown(docsDir).map((file) => {
const rel = path.relative(docsDir, file).replaceAll(path.sep, "/");
const raw = fs.readFileSync(file, "utf8");
const { frontmatter, body } = parseFrontmatter(raw);
const title = frontmatter.title || firstHeading(body) || titleize(path.basename(rel, ".md"));
return { file, rel, title, outRel: outPath(rel, frontmatter), markdown: body.trim(), frontmatter };
});
const pages = allPages.filter((page) => page.rel !== "CNAME" && !buildExcludes.has(page.rel));
const pageMap = new Map(pages.map((page) => [page.rel, page]));
const nav = sections
.map(([name, rels]) => ({ name, pages: rels.map((rel) => pageMap.get(rel)).filter(Boolean) }))
.filter((section) => section.pages.length);
const sectionByRel = new Map();
for (const section of nav) for (const page of section.pages) sectionByRel.set(page.rel, section.name);
const orderedPages = nav.flatMap((section) => section.pages);
for (const page of pages) {
const markdown = page.outRel === "index.html" ? page.markdown : stripDuplicateTitle(page.markdown, page.title);
const html = markdownToHtml(markdown, page.rel);
const idx = orderedPages.findIndex((p) => p.rel === page.rel);
const prev = idx > 0 ? orderedPages[idx - 1] : null;
const next = idx >= 0 && idx < orderedPages.length - 1 ? orderedPages[idx + 1] : null;
const pageOut = path.join(outDir, page.outRel);
fs.mkdirSync(path.dirname(pageOut), { recursive: true });
fs.writeFileSync(pageOut, layout({ page, html, prev, next, sectionName: sectionByRel.get(page.rel) || "Reference" }), "utf8");
}
fs.writeFileSync(path.join(outDir, "favicon.svg"), faviconSvg(), "utf8");
fs.writeFileSync(path.join(outDir, ".nojekyll"), "", "utf8");
fs.writeFileSync(path.join(outDir, "llms.txt"), llmsTxt(), "utf8");
if (cname) fs.writeFileSync(path.join(outDir, "CNAME"), cname, "utf8");
validateLinks(outDir);
console.log(`built docs site: ${path.relative(root, outDir)}`);
function layout({ page, html, prev, next, sectionName }) {
const depth = page.outRel.split("/").length - 1;
const rootPrefix = depth ? "../".repeat(depth) : "";
const home = page.outRel === "index.html";
const title = home ? `${productName} - ${productTagline}` : `${page.title} - ${productName}`;
const description = page.frontmatter.description || (home ? productDescription : `${page.title} documentation for ${productName}.`);
const canonicalUrl = canonical(page);
const editUrl = `${repoEditBase}/${page.rel}`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeAttr(description)}">
<link rel="canonical" href="${escapeAttr(canonicalUrl)}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="${escapeAttr(productName)}">
<meta property="og:title" content="${escapeAttr(title)}">
<meta property="og:description" content="${escapeAttr(description)}">
<meta property="og:url" content="${escapeAttr(canonicalUrl)}">
<meta name="twitter:card" content="summary">
<link rel="icon" href="${rootPrefix}favicon.svg" type="image/svg+xml">
<script>${preThemeScript()}</script>
<style>${css()}</style>
</head>
<body${home ? ' class="home"' : ""}>
<button class="nav-toggle" type="button" aria-label="Toggle navigation" aria-expanded="false">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<div class="shell">
<aside class="sidebar">
<div class="sidebar-head">
<a class="brand" href="${hrefTo("index.html", page.outRel)}" aria-label="${productName} docs home">
<span class="mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span><strong>${productName}</strong><small>Reminders CLI docs</small></span>
</a>
${themeToggleHtml()}
</div>
<label class="search"><span>Search</span><input id="doc-search" type="search" placeholder="add, today, export"></label>
<nav>${navHtml(page)}</nav>
</aside>
<main>
${home ? homeHero() : standardHero(page, sectionName, editUrl)}
<article class="doc${home ? " doc-home" : ""}">${html}${pageNav(prev, next, page.outRel)}</article>
</main>
</div>
<script>${js()}</script>
</body>
</html>`;
}
function homeHero() {
return `<header class="home-hero">
<div class="home-copy">
<p class="eyebrow">macOS - EventKit - One CLI</p>
<h1>${productTagline}</h1>
<p class="lede">${productDescription}</p>
<div class="home-cta">
<a class="btn btn-primary" href="install.html">Install</a>
<a class="btn btn-ghost" href="${repoBase}" rel="noopener">GitHub</a>
</div>
<div class="home-install" aria-label="Install with Homebrew"><span class="prompt" aria-hidden="true">$</span><code>${installCommand}</code></div>
</div>
<div class="reminder-card" aria-label="Reminder preview">
<div class="card-bar"><span></span><span></span><span></span></div>
<div class="time-rail" aria-hidden="true"><i></i><i></i><i></i></div>
<ol>
<li><time>09:00</time><strong>Ship docs polish</strong><small>Work - due today</small></li>
<li><time>13:30</time><strong>Run e2e proof</strong><small>Terminal - JSON ready</small></li>
<li><time>17:00</time><strong>Review release notes</strong><small>Open - synced by iCloud</small></li>
</ol>
</div>
</header>`;
}
function standardHero(page, sectionName, editUrl) {
return `<header class="hero">
<div class="hero-text">
<p class="eyebrow">${escapeHtml(sectionName)}</p>
<h1>${escapeHtml(page.title)}</h1>
</div>
<div class="hero-meta">
<a class="repo" href="${repoBase}" rel="noopener">GitHub</a>
<a class="edit" href="${escapeAttr(editUrl)}" rel="noopener">Edit page</a>
</div>
</header>`;
}
function navHtml(currentPage) {
return nav.map((section) => `<section><h2>${escapeHtml(section.name)}</h2>${section.pages.map((page) => {
const active = page.rel === currentPage.rel ? " active" : "";
return `<a class="nav-link${active}" href="${hrefTo(page.outRel, currentPage.outRel)}">${escapeHtml(navTitle(page))}</a>`;
}).join("")}</section>`).join("");
}
function pageNav(prev, next, currentOutRel) {
if (!prev && !next) return "";
const cell = (page, dir) => page ? `<a class="${dir}" href="${hrefTo(page.outRel, currentOutRel)}"><small>${dir === "prev" ? "Previous" : "Next"}</small><span>${escapeHtml(page.title)}</span></a>` : "";
return `<nav class="page-nav">${cell(prev, "prev")}${cell(next, "next")}</nav>`;
}
function markdownToHtml(markdown, rel) {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const out = [];
let paragraph = [];
let list = null;
let fence = null;
let table = [];
const flushParagraph = () => {
if (!paragraph.length) return;
out.push(`<p>${inline(paragraph.join(" "))}</p>`);
paragraph = [];
};
const flushList = () => {
if (!list) return;
out.push(`<${list.type}>${list.items.map((item) => `<li>${inline(item)}</li>`).join("")}</${list.type}>`);
list = null;
};
const flushTable = () => {
if (!table.length) return;
const rows = table.map((line) => line.trim().replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim()));
const body = rows.filter((_, i) => i !== 1).map((cells, i) => {
const tag = i === 0 ? "th" : "td";
return `<tr>${cells.map((cell) => `<${tag}>${inline(cell)}</${tag}>`).join("")}</tr>`;
}).join("");
out.push(`<table>${body}</table>`);
table = [];
};
for (const line of lines) {
const fenceMatch = line.match(/^\s*```([A-Za-z0-9_-]*)\s*$/);
if (fenceMatch) {
if (fence) {
out.push(`<pre><code>${escapeHtml(fence.lines.join("\n"))}</code></pre>`);
fence = null;
} else {
flushParagraph(); flushList(); flushTable();
fence = { lang: fenceMatch[1], lines: [] };
}
continue;
}
if (fence) {
fence.lines.push(line);
continue;
}
if (!line.trim()) {
flushParagraph(); flushList(); flushTable();
continue;
}
if (line.includes("|") && /^\s*\|?[-:| ]+\|[-:| ]+\|?\s*$/.test(lines[lines.indexOf(line) + 1] || "")) {
flushParagraph(); flushList();
table.push(line);
continue;
}
if (table.length || /^\s*\|?[-:| ]+\|[-:| ]+\|?\s*$/.test(line)) {
table.push(line);
continue;
}
const h = line.match(/^(#{1,4})\s+(.+)$/);
if (h) {
flushParagraph(); flushList(); flushTable();
const level = h[1].length;
const text = h[2].trim();
const id = slug(text);
out.push(`<h${level} id="${id}"><a class="anchor" href="#${id}">#</a>${inline(text)}</h${level}>`);
continue;
}
const bullet = line.match(/^\s*[-*]\s+(.+)$/);
const ordered = line.match(/^\s*\d+\.\s+(.+)$/);
if (bullet || ordered) {
flushParagraph(); flushTable();
const type = bullet ? "ul" : "ol";
if (!list || list.type !== type) flushList();
if (!list) list = { type, items: [] };
list.items.push((bullet || ordered)[1]);
continue;
}
paragraph.push(line.trim());
}
flushParagraph(); flushList(); flushTable();
return out.join("\n");
function inline(value) {
let html = escapeHtml(value);
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, href) => `<a href="${escapeAttr(rewriteHref(href, rel))}">${escapeHtml(text)}</a>`);
return html;
}
}
function readCname() {
const file = path.join(docsDir, "CNAME");
return fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim() : "";
}
function allMarkdown(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return allMarkdown(full);
return entry.name.endsWith(".md") ? [full] : [];
}).sort();
}
function parseFrontmatter(raw) {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?/);
if (!match) return { frontmatter: {}, body: raw };
const frontmatter = {};
for (const line of match[1].split("\n")) {
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
if (!m) continue;
frontmatter[m[1]] = m[2].replace(/^["']|["']$/g, "");
}
return { frontmatter, body: raw.slice(match[0].length) };
}
function outPath(rel, frontmatter = {}) {
if (frontmatter.permalink === "/") return "index.html";
if (rel === "index.md" || rel === "README.md") return "index.html";
return rel.replace(/\.md$/, ".html");
}
function navTitle(page) {
if (page.rel === "index.md") return "Overview";
return page.title;
}
function firstHeading(markdown) {
return markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
}
function stripDuplicateTitle(markdown, title) {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return markdown.replace(new RegExp(`^#\\s+${escaped}\\s*\\n+`), "");
}
function titleize(value) {
return value.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function slug(text) {
return text.toLowerCase().replace(/`/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
function hrefTo(targetOutRel, currentOutRel) {
const currentDir = path.posix.dirname(currentOutRel);
return path.posix.relative(currentDir, targetOutRel) || path.posix.basename(targetOutRel);
}
function rewriteHref(href, rel) {
if (/^[a-z]+:/i.test(href) || href.startsWith("#")) return href;
if (href.endsWith(".md")) return href.replace(/\.md$/, ".html");
return href;
}
function canonical(page) {
if (!siteBase) return page.outRel;
if (page.outRel === "index.html") return `${siteBase}/`;
return `${siteBase}/${page.outRel}`;
}
function llmsTxt() {
const lines = [
`# ${productName}`,
"",
productDescription,
"",
"Canonical documentation:",
...orderedPages.map((page) => `- ${page.title}: ${canonical(page)}`),
"",
"Install:",
`- ${installCommand}`,
"",
`Source: ${repoBase}`,
];
return `${lines.join("\n")}\n`;
}
function validateLinks(dir) {
const files = fs.readdirSync(dir, { recursive: true }).filter((file) => String(file).endsWith(".html"));
const failures = [];
for (const file of files) {
const full = path.join(dir, file);
const html = fs.readFileSync(full, "utf8");
for (const match of html.matchAll(/href="([^"]+)"/g)) {
const href = match[1];
if (/^[a-z]+:/i.test(href) || href.startsWith("#") || href.startsWith("mailto:")) continue;
const target = path.resolve(path.dirname(full), href.split("#")[0]);
if (!fs.existsSync(target)) failures.push(`${file} -> ${href}`);
}
}
if (failures.length) throw new Error(`broken docs links:\n${failures.join("\n")}`);
}
function faviconSvg() {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="remindctl">
<rect width="64" height="64" rx="14" fill="#111827"/>
<rect x="15" y="12" width="34" height="40" rx="8" fill="#f8fafc"/>
<path d="M22 10v8M42 10v8" stroke="#14b8a6" stroke-width="5" stroke-linecap="round"/>
<path d="M23 30h18M23 39h12" stroke="#111827" stroke-width="4" stroke-linecap="round"/>
<path d="M39 42l4 4 8-11" fill="none" stroke="#f59e0b" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
}
function css() {
return `
:root{
--ink:#111827;--text:#242a32;--muted:#687383;--subtle:#9aa3af;--bg:#fafafa;--paper:#ffffff;--line:#e5e7eb;--line-soft:#f3f4f6;
--accent:#0f766e;--accent-soft:rgba(15,118,110,.12);--accent-strong:#115e59;--accent-fg:#ffffff;--gold:#d97706;--rose:#e11d48;
--code-bg:#101827;--code-fg:#e6edf3;--code-inline-fg:#172033;--shadow-card:0 8px 28px rgba(35,31,24,.09);
}
:root[data-theme="dark"]{
--ink:#f5f7fb;--text:#cad1dc;--muted:#8d96a4;--subtle:#5d6472;--bg:#0d1117;--paper:#161b22;--line:#2a303a;--line-soft:#1f252e;
--accent:#5eead4;--accent-soft:rgba(94,234,212,.13);--accent-strong:#99f6e4;--accent-fg:#06221f;--gold:#fbbf24;--rose:#fb7185;
--code-bg:#070b12;--code-fg:#edf2f7;--code-inline-fg:#e6edf3;--shadow-card:0 8px 28px rgba(0,0,0,.42);
}
:root{color-scheme:light}:root[data-theme="dark"]{color-scheme:dark}
*{box-sizing:border-box}
html{scroll-behavior:smooth;scroll-padding-top:24px}
body{margin:0;background:var(--bg);color:var(--text);font-family:"Inter",ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.65;overflow-x:hidden;-webkit-font-smoothing:antialiased;transition:background-color .18s,color .18s}
::selection{background:var(--accent);color:#fff}
a{color:var(--accent);text-decoration:none;transition:color .12s}a:hover{text-decoration:underline;text-underline-offset:.2em}
.shell{display:grid;grid-template-columns:268px minmax(0,1fr);min-height:100vh}
.sidebar{position:sticky;top:0;height:100vh;overflow:auto;background:var(--paper);border-right:1px solid var(--line);padding:24px 22px;scrollbar-width:thin;scrollbar-color:var(--line) transparent;transition:background-color .18s,border-color .18s}
.sidebar::-webkit-scrollbar{width:6px}.sidebar::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px}
.sidebar-head{display:flex;align-items:center;gap:10px;margin-bottom:24px}
.brand{display:flex;gap:11px;align-items:center;color:var(--ink);flex:1;min-width:0}.brand:hover{text-decoration:none}
.brand strong{display:block;font-size:1.05rem;line-height:1.1;font-weight:650;letter-spacing:0;color:var(--ink)}.brand small{display:block;color:var(--muted);font-size:.74rem;margin-top:3px;font-weight:400}
.brand .mark{position:relative;display:block;width:29px;height:29px;border-radius:8px;background:var(--ink);box-shadow:inset 0 -9px 0 rgba(255,255,255,.08);flex:0 0 auto}
.brand .mark i{position:absolute;left:7px;right:7px;height:2px;border-radius:2px;background:var(--paper);opacity:.95}.brand .mark i:nth-child(1){top:9px}.brand .mark i:nth-child(2){top:15px}.brand .mark i:nth-child(3){top:21px;width:9px;right:auto}.brand .mark:after{content:"";position:absolute;right:-2px;bottom:-2px;width:10px;height:10px;border-radius:50%;background:var(--gold);box-shadow:0 0 0 3px var(--paper)}
.theme-toggle{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:34px;height:34px;border-radius:8px;border:1px solid var(--line);background:var(--paper);color:var(--muted);cursor:pointer;padding:0;transition:border-color .15s,color .15s,background-color .15s,transform .12s}
.theme-toggle:hover{border-color:var(--ink);color:var(--ink)}.theme-toggle:active{transform:scale(.94)}.theme-toggle svg{width:16px;height:16px;display:block}.theme-icon-sun{display:none}:root[data-theme="dark"] .theme-icon-sun{display:block}:root[data-theme="dark"] .theme-icon-moon{display:none}
.search{display:block;margin:0 0 22px}.search span,nav h2,.eyebrow{display:block;color:var(--muted);font-size:.68rem;font-weight:650;text-transform:uppercase;letter-spacing:0;margin:0 0 7px}
.search input{width:100%;border:1px solid var(--line);background:var(--paper);border-radius:8px;padding:9px 12px;font:inherit;font-size:.9rem;color:var(--text);outline:none;transition:border-color .15s,box-shadow .15s,background-color .18s}.search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
nav section{margin:0 0 18px}.nav-link{display:block;color:var(--text);border-radius:6px;padding:5px 10px;margin:1px 0;font-size:.9rem;line-height:1.4;transition:background .12s,color .12s}.nav-link:hover{background:var(--line-soft);color:var(--ink);text-decoration:none}.nav-link.active{background:var(--accent-soft);color:var(--accent);font-weight:650}
main{width:100%;max-width:1180px;margin:0 auto;padding:32px clamp(20px,4.5vw,56px) 80px;min-width:0}
.hero{display:flex;align-items:flex-end;justify-content:space-between;gap:22px;border-bottom:1px solid var(--line);padding:8px 0 22px;margin-bottom:26px;flex-wrap:wrap}.hero-text{min-width:0;flex:1 1 320px}
.hero h1,.home-hero h1{margin:0;color:var(--ink);line-height:1.08;letter-spacing:0}.hero h1{font-size:2.25rem;font-weight:720}.hero-meta{display:flex;gap:8px;flex:0 0 auto;flex-wrap:wrap}
.repo,.edit,.btn{border:1px solid var(--line);border-radius:8px;padding:7px 12px;color:var(--text);background:var(--paper);font-weight:600;font-size:.86rem;text-decoration:none;transition:border-color .15s,color .15s,background .15s,transform .12s}.repo:hover,.edit:hover,.btn:hover{text-decoration:none;border-color:var(--ink);color:var(--ink)}.edit{color:var(--muted)}
.home-hero{display:grid;grid-template-columns:minmax(0,1fr) minmax(320px,410px);gap:36px;align-items:center;border-bottom:1px solid var(--line);padding:12px 0 34px;margin-bottom:30px}
.home-hero h1{font-size:3.35rem;font-weight:760;max-width:12ch}.lede{font-size:1.18rem;line-height:1.55;max-width:58ch;margin:16px 0 20px}.home-cta{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:0 0 14px}.btn-primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}.btn-primary:hover{background:var(--accent-strong);border-color:var(--accent-strong);color:var(--accent-fg)}
.home-install{position:relative;display:inline-flex;gap:12px;align-items:center;max-width:100%;background:var(--code-bg);color:var(--code-fg);border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:10px 12px 10px 16px;font:500 .9rem/1.2 "JetBrains Mono","SF Mono",ui-monospace,monospace;box-shadow:var(--shadow-card)}.home-install .prompt{color:#8391a6;user-select:none}.home-install code{background:transparent;border:0;color:inherit;font:inherit;padding:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.copy{background:rgba(255,255,255,.08);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:5px 10px;font:600 .7rem/1 ui-sans-serif,system-ui,sans-serif;cursor:pointer;transition:background .15s,border-color .15s}.copy:hover{background:rgba(255,255,255,.16)}.copy.copied{background:var(--accent);border-color:var(--accent)}
.reminder-card{position:relative;overflow:hidden;background:var(--paper);border:1px solid var(--line);border-radius:8px;padding:18px 18px 16px;box-shadow:var(--shadow-card);min-height:286px}.reminder-card:before{content:"";position:absolute;inset:0;background:linear-gradient(135deg,rgba(15,118,110,.12),transparent 38%),linear-gradient(315deg,rgba(217,119,6,.12),transparent 38%);pointer-events:none}.card-bar{position:relative;display:flex;gap:6px;margin-bottom:18px}.card-bar span{width:9px;height:9px;border-radius:50%;background:var(--line)}.card-bar span:nth-child(1){background:var(--rose)}.card-bar span:nth-child(2){background:var(--gold)}.card-bar span:nth-child(3){background:var(--accent)}
.time-rail{position:absolute;top:62px;bottom:28px;left:35px;width:2px;background:var(--line)}.time-rail i{position:absolute;left:50%;width:12px;height:12px;border-radius:50%;transform:translateX(-50%);background:var(--paper);border:2px solid var(--accent)}.time-rail i:nth-child(1){top:2px}.time-rail i:nth-child(2){top:72px;border-color:var(--gold)}.time-rail i:nth-child(3){top:144px;border-color:var(--rose)}
.reminder-card ol{position:relative;list-style:none;margin:0;padding:0 0 0 36px}.reminder-card li{margin:0 0 14px;padding:11px 12px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--paper) 88%,var(--bg));min-height:58px}.reminder-card time{display:block;color:var(--muted);font:600 .74rem/1.2 "JetBrains Mono","SF Mono",ui-monospace,monospace;margin-bottom:4px}.reminder-card strong{display:block;color:var(--ink);font-size:.94rem;line-height:1.25}.reminder-card small{display:block;color:var(--muted);font-size:.78rem;margin-top:2px}
.doc{max-width:74ch;min-width:0;overflow-wrap:break-word}.doc-home{max-width:78ch}body:not(.home) .doc>h1:first-child{display:none}.doc h1{font-size:2.45rem;line-height:1.08;color:var(--ink);margin:0 0 .6em;letter-spacing:0}.doc h2{font-size:1.45rem;line-height:1.2;color:var(--ink);margin:2em 0 .5em;letter-spacing:0}.doc h3{font-size:1.12rem;color:var(--ink);margin:1.6em 0 .35em;letter-spacing:0}.doc h4{color:var(--ink);margin:1.4em 0 .25em}.doc h1:first-child,.doc h2:first-child{margin-top:0}.doc p{margin:0 0 1.05em}.doc ul,.doc ol{padding-left:1.35rem;margin:0 0 1.15em}.doc li{margin:.25em 0}.doc strong{color:var(--ink);font-weight:650}
.anchor{float:left;margin-left:-1em;color:var(--subtle);opacity:0}.doc :is(h1,h2,h3,h4):hover .anchor{opacity:.75}.anchor:hover{opacity:1;text-decoration:none}
.doc code{font-family:"JetBrains Mono","SF Mono",ui-monospace,monospace;background:var(--line-soft);border:1px solid var(--line);border-radius:5px;padding:.08em .35em;font-size:.84em;color:var(--code-inline-fg)}.doc pre{position:relative;background:var(--code-bg);color:var(--code-fg);border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:15px 18px;overflow:auto;font-size:.86rem;line-height:1.6;scrollbar-width:thin;scrollbar-color:#334155 transparent}.doc pre code{display:block;background:transparent;border:0;padding:0;color:inherit;font-size:1em;white-space:pre}.doc pre .copy{position:absolute;top:8px;right:8px;opacity:0}.doc pre:hover .copy,.doc pre .copy:focus{opacity:1}
.doc table{width:100%;border-collapse:collapse;margin:1.2em 0;font-size:.92em}.doc th,.doc td{border-bottom:1px solid var(--line);padding:9px 10px;text-align:left;vertical-align:top}.doc th{color:var(--ink);background:var(--line-soft);font-weight:650}
.page-nav{display:grid;grid-template-columns:1fr 1fr;gap:14px;border-top:1px solid var(--line);margin-top:44px;padding-top:20px}.page-nav a{display:block;border:1px solid var(--line);border-radius:8px;padding:12px 14px;color:var(--text);background:var(--paper);transition:border-color .15s,transform .15s,box-shadow .15s}.page-nav a:hover{text-decoration:none;border-color:var(--accent);color:var(--ink)}.page-nav small{display:block;color:var(--muted);font-size:.68rem;text-transform:uppercase;font-weight:650}.page-nav span{display:block;color:var(--ink);font-weight:650}.page-nav .next{text-align:right;grid-column:2}
.nav-toggle{display:none;position:fixed;top:14px;right:14px;top:calc(14px + env(safe-area-inset-top,0px));right:calc(14px + env(safe-area-inset-right,0px));z-index:20;width:40px;height:40px;border-radius:9px;background:var(--paper);border:1px solid var(--line);color:var(--ink);cursor:pointer;padding:10px 9px;flex-direction:column;align-items:stretch;justify-content:space-between;box-shadow:var(--shadow-card)}.nav-toggle span{display:block;width:100%;height:2px;background:currentColor;border-radius:2px;transition:transform .2s,opacity .2s}.nav-toggle[aria-expanded="true"] span:nth-child(1){transform:translateY(8px) rotate(45deg)}.nav-toggle[aria-expanded="true"] span:nth-child(2){opacity:0}.nav-toggle[aria-expanded="true"] span:nth-child(3){transform:translateY(-8px) rotate(-45deg)}
@media(max-width:960px){.home-hero{grid-template-columns:1fr;gap:22px}.reminder-card{max-width:540px}.home-hero h1{font-size:2.7rem}}
@media(max-width:860px){.shell{display:block}.sidebar{position:fixed;inset:0 30% 0 0;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .25s ease,background-color .18s,border-color .18s;box-shadow:0 18px 40px rgba(0,0,0,.18);pointer-events:none}.sidebar.open{transform:translateX(0);pointer-events:auto}.nav-toggle{display:flex}main{padding:64px 18px 56px}.hero{display:block}.hero h1{font-size:1.9rem}.hero-meta{margin-top:14px}.home-hero{padding-top:8px}.home-hero h1{font-size:2.35rem}.doc h1{font-size:2rem}.anchor{display:none}}
@media(max-width:520px){main{padding:60px 14px 48px}.home-hero h1{font-size:2.15rem}.home-install{display:flex;width:100%}.reminder-card{padding:16px 14px}.doc pre{margin-left:-14px;margin-right:-14px;border-radius:0;border-left:0;border-right:0}.page-nav{grid-template-columns:1fr}.page-nav .next{grid-column:1;text-align:left}}
`;
}
function js() {
return `
const root=document.documentElement;
function applyTheme(mode){root.dataset.theme=mode;document.querySelectorAll('[data-theme-toggle]').forEach((button)=>button.setAttribute('aria-pressed',mode==='dark'?'true':'false'))}
function storedTheme(){try{return localStorage.getItem('theme')}catch(e){return null}}
function persistTheme(mode){try{localStorage.setItem('theme',mode)}catch(e){}}
applyTheme(root.dataset.theme==='dark'?'dark':'light');
document.querySelectorAll('[data-theme-toggle]').forEach((button)=>button.addEventListener('click',()=>{const next=root.dataset.theme==='dark'?'light':'dark';applyTheme(next);persistTheme(next)}));
const systemDark=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)');
function onSystemChange(event){if(storedTheme())return;applyTheme(event.matches?'dark':'light')}
if(systemDark){if(systemDark.addEventListener)systemDark.addEventListener('change',onSystemChange);else if(systemDark.addListener)systemDark.addListener(onSystemChange)}
const sidebar=document.querySelector('.sidebar');
const toggle=document.querySelector('.nav-toggle');
const mobileNav=window.matchMedia('(max-width: 860px)');
function setSidebarOpen(open){
if(!sidebar||!toggle)return;
sidebar.classList.toggle('open',open);
toggle.setAttribute('aria-expanded',open?'true':'false');
if(mobileNav.matches){
sidebar.inert=!open;
if(open)sidebar.removeAttribute('aria-hidden');else sidebar.setAttribute('aria-hidden','true');
}else{
sidebar.inert=false;
sidebar.removeAttribute('aria-hidden');
}
}
setSidebarOpen(false);
toggle?.addEventListener('click',()=>setSidebarOpen(!sidebar?.classList.contains('open')));
document.addEventListener('click',(event)=>{if(!sidebar?.classList.contains('open'))return;if(sidebar.contains(event.target)||toggle?.contains(event.target))return;setSidebarOpen(false)});
document.addEventListener('keydown',(event)=>{if(event.key==='Escape')setSidebarOpen(false)});
if(mobileNav.addEventListener)mobileNav.addEventListener('change',()=>setSidebarOpen(sidebar?.classList.contains('open')??false));
const search=document.querySelector('#doc-search');
search?.addEventListener('input',()=>{const q=search.value.toLowerCase().trim();document.querySelectorAll('nav section').forEach((section)=>{let any=false;section.querySelectorAll('.nav-link').forEach((link)=>{const match=!q||link.textContent.toLowerCase().includes(q);link.style.display=match?'block':'none';if(match)any=true});section.style.display=any?'block':'none'})});
function attachCopy(target,getText){const button=document.createElement('button');button.type='button';button.className='copy';button.textContent='Copy';button.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(getText());button.textContent='Copied';button.classList.add('copied');setTimeout(()=>{button.textContent='Copy';button.classList.remove('copied')},1400)}catch{button.textContent='Failed';setTimeout(()=>{button.textContent='Copy'},1400)}});target.appendChild(button)}
document.querySelectorAll('.doc pre').forEach((pre)=>attachCopy(pre,()=>pre.querySelector('code')?.textContent??''));
document.querySelectorAll('.home-install').forEach((el)=>attachCopy(el,()=>el.querySelector('code')?.textContent??''));
`;
}
function preThemeScript() {
return `(function(){var stored;try{stored=localStorage.getItem('theme')}catch(e){}var dark=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.dataset.theme=stored||(dark?'dark':'light')})();`;
}
function themeToggleHtml() {
return `<button class="theme-toggle" type="button" aria-label="Toggle dark mode" aria-pressed="false" data-theme-toggle>
<svg class="theme-icon-moon" viewBox="0 0 20 20" aria-hidden="true"><path d="M14.6 12.1A6.5 6.5 0 0 1 7.4 2.7a6.5 6.5 0 1 0 7.2 9.4z" fill="currentColor"/></svg>
<svg class="theme-icon-sun" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="3.4" fill="currentColor"/><g stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="10" y1="2" x2="10" y2="4"/><line x1="10" y1="16" x2="10" y2="18"/><line x1="2" y1="10" x2="4" y2="10"/><line x1="16" y1="10" x2="18" y2="10"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="14.4" y1="14.4" x2="15.8" y2="15.8"/><line x1="4.2" y1="15.8" x2="5.6" y2="14.4"/><line x1="14.4" y1="5.6" x2="15.8" y2="4.2"/></g></svg>
</button>`;
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]);
}
function escapeAttr(value) {
return escapeHtml(value);
}
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
CACHE_PATH="${HOME}/Library/Caches/remindctl/swiftpm"
COVERAGE_BUILD_PATH="${ROOT_DIR}/.build/coverage"
mkdir -p "${CACHE_PATH}"
MIN_COVERAGE="${COVERAGE_MIN:-90}"
INCLUDE_REGEX="${COVERAGE_INCLUDE_REGEX:-/Sources/RemindCore/}"
EXCLUDE_REGEX="${COVERAGE_EXCLUDE_REGEX:-/Sources/RemindCore/EventKitStore.swift}"
echo "==> swift test --enable-code-coverage (isolated build dir)"
swift test --enable-code-coverage --build-path "${COVERAGE_BUILD_PATH}" --cache-path "${CACHE_PATH}" >/dev/null
REPORT_JSON="$(
find "${COVERAGE_BUILD_PATH}" -type f -path "*debug/codecov/remindctl.json" -print0 2>/dev/null \
| xargs -0 ls -t 2>/dev/null \
| head -n 1
)"
if [ -z "${REPORT_JSON}" ] || [ ! -f "${REPORT_JSON}" ]; then
echo "ERROR: Coverage report not found (expected .build/**/debug/codecov/remindctl.json)." >&2
exit 1
fi
python3 - "$REPORT_JSON" "$INCLUDE_REGEX" "$EXCLUDE_REGEX" "$MIN_COVERAGE" <<'PY'
import json
import os
import re
import sys
report_path, include_re, exclude_re, min_str = sys.argv[1:5]
min_coverage = float(min_str)
with open(report_path, "r", encoding="utf-8") as f:
obj = json.load(f)
files = obj["data"][0]["files"]
include = re.compile(include_re)
exclude = re.compile(exclude_re) if exclude_re else None
selected = []
for item in files:
filename = item["filename"]
if not include.search(filename):
continue
if exclude and exclude.search(filename):
continue
summary = item.get("summary", {}).get("lines", {})
count = int(summary.get("count", 0))
covered = int(summary.get("covered", 0))
selected.append((filename, covered, count))
if not selected:
print(f"ERROR: No files matched coverage include regex: {include_re}", file=sys.stderr)
print(f" exclude regex: {exclude_re or '(none)'}", file=sys.stderr)
sys.exit(1)
total_lines = sum(count for _, _, count in selected)
total_covered = sum(covered for _, covered, _ in selected)
percent = (total_covered / total_lines * 100.0) if total_lines else 0.0
repo_root = os.getcwd() + os.sep
def rel(path: str) -> str:
return path[len(repo_root):] if path.startswith(repo_root) else path
print(f"==> Coverage (lines): {percent:.1f}% ({total_covered}/{total_lines})")
print(f" Scope: include={include_re} exclude={exclude_re or '(none)'}")
print(f" Min: {min_coverage:.1f}%")
worst = sorted(selected, key=lambda t: (t[1] / t[2] if t[2] else 0.0, -t[2]))[:10]
print(" Lowest covered files:")
for filename, covered, count in worst:
p = (covered / count * 100.0) if count else 0.0
print(f" - {p:5.1f}% {covered:4d}/{count:4d} {rel(filename)}")
if percent + 1e-9 < min_coverage:
sys.exit(2)
PY
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: $0 vX.Y.Z" >&2
exit 1
fi
TAG="$1"
VERSION="${TAG#v}"
if [[ "$TAG" != v* || "$VERSION" == "$TAG" ]]; then
echo "Release tag must look like vX.Y.Z" >&2
exit 1
fi
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT/version.env"
if [[ "${MARKETING_VERSION:-}" != "$VERSION" ]]; then
echo "version.env MARKETING_VERSION=${MARKETING_VERSION:-unset} does not match $VERSION" >&2
exit 1
fi
notes_file="$(mktemp)"
trap 'rm -f "$notes_file"' EXIT
awk -v v="$VERSION" '
$0 ~ ("^## " v "($|[[:space:]]-)") { in_section=1; next }
in_section && $0 ~ "^## " { exit }
in_section { print }
' "$ROOT/CHANGELOG.md" > "$notes_file"
if ! grep -q '[^[:space:]]' "$notes_file"; then
echo "No CHANGELOG.md notes found for $VERSION" >&2
exit 1
fi
if git -C "$ROOT" rev-parse "$TAG" >/dev/null 2>&1 \
|| git -C "$ROOT" ls-remote --exit-code --tags origin "$TAG" >/dev/null 2>&1; then
echo "Tag already exists: $TAG" >&2
exit 1
fi
if command -v gh >/dev/null 2>&1; then
gh api repos/openclaw/remindctl/actions/workflows/release.yml --jq '.state' | grep -qx active
gh secret list --repo openclaw/remindctl | awk '{print $1}' | grep -qx HOMEBREW_TAP_TOKEN
fi
echo "Release preflight OK: $TAG"
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
source "$ROOT/version.env"
OUTPUT="$ROOT/Sources/remindctl/Version.swift"
PLIST_OUTPUT="$ROOT/Sources/remindctl/Resources/Info.plist"
mkdir -p "$(dirname "$OUTPUT")"
mkdir -p "$(dirname "$PLIST_OUTPUT")"
cat > "$OUTPUT" <<SWIFT
// Generated by scripts/generate-version.sh. Do not edit.
enum RemindctlVersion {
static let current = "${MARKETING_VERSION}"
}
SWIFT
cat > "$PLIST_OUTPUT" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.steipete.remindctl</string>
<key>CFBundleName</key>
<string>remindctl</string>
<key>CFBundleExecutable</key>
<string>remindctl</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${MARKETING_VERSION}</string>
<key>CFBundleVersion</key>
<string>${MARKETING_VERSION}</string>
<key>NSRemindersUsageDescription</key>
<string>Manage your reminders from the terminal.</string>
</dict>
</plist>
PLIST
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
source "$ROOT/version.env"
APP_NAME="remindctl"
CODESIGN_IDENTITY=${CODESIGN_IDENTITY:-"Developer ID Application: Peter Steinberger (Y5PE65HELJ)"}
ENTITLEMENTS="${ROOT}/Resources/remindctl.entitlements"
OUTPUT_DIR=${OUTPUT_DIR:-/tmp}
ZIP_PATH="${OUTPUT_DIR}/remindctl-macos.zip"
ARCHES_VALUE=${ARCHES:-"arm64 x86_64"}
ARCH_LIST=( ${ARCHES_VALUE} )
DIST_DIR="$(mktemp -d "/tmp/${APP_NAME}-dist.XXXXXX")"
API_KEY_FILE="$(mktemp "/tmp/${APP_NAME}-notary.XXXXXX.p8")"
cleanup() {
rm -f "$API_KEY_FILE"
rm -rf "$DIST_DIR"
}
trap cleanup EXIT
if [[ -z "${APP_STORE_CONNECT_API_KEY_P8:-}" || -z "${APP_STORE_CONNECT_KEY_ID:-}" || -z "${APP_STORE_CONNECT_ISSUER_ID:-}" ]]; then
echo "Missing APP_STORE_CONNECT_* env vars (API key, key id, issuer id)." >&2
exit 1
fi
echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > "$API_KEY_FILE"
"$ROOT/scripts/generate-version.sh"
for ARCH in "${ARCH_LIST[@]}"; do
swift build -c release --product remindctl --arch "$ARCH"
done
BINARIES=()
for ARCH in "${ARCH_LIST[@]}"; do
BINARIES+=("$ROOT/.build/${ARCH}-apple-macosx/release/remindctl")
done
lipo -create "${BINARIES[@]}" -output "$DIST_DIR/remindctl"
if [[ -f "$ENTITLEMENTS" ]]; then
codesign --force --timestamp --options runtime --sign "$CODESIGN_IDENTITY" \
--entitlements "$ENTITLEMENTS" \
"$DIST_DIR/remindctl"
else
codesign --force --timestamp --options runtime --sign "$CODESIGN_IDENTITY" \
"$DIST_DIR/remindctl"
fi
chmod -R u+rw "$DIST_DIR"
xattr -cr "$DIST_DIR"
find "$DIST_DIR" -name '._*' -delete
DITTO_BIN=${DITTO_BIN:-/usr/bin/ditto}
(
cd "$DIST_DIR"
"$DITTO_BIN" --norsrc -c -k . "$ZIP_PATH"
)
xcrun notarytool submit "$ZIP_PATH" \
--key "$API_KEY_FILE" \
--key-id "$APP_STORE_CONNECT_KEY_ID" \
--issuer "$APP_STORE_CONNECT_ISSUER_ID" \
--wait
codesign --verify --strict --verbose=4 "$DIST_DIR/remindctl"
if ! spctl -a -t exec -vv "$DIST_DIR/remindctl"; then
echo "spctl check failed (CLI binaries often report 'not an app')." >&2
fi
echo "Done: $ZIP_PATH"
#!/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
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()
if let relative = parseRelativeDate(lower, now: now, calendar: calendar) {
return relative
}
let iso =
isoFormatter(withFraction: true).date(from: trimmed)
?? isoFormatter(withFraction: false).date(from: trimmed)
if let iso {
return ParsedUserDate(date: iso, isDateOnly: false)
}
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 ParsedUserDate(date: date, isDateOnly: isDateOnly)
}
}
return nil
}
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 = isDateOnly ? .none : .short
return formatter.string(from: date)
}
private static func parseRelativeDate(_ input: String, now: Date, calendar: Calendar) -> ParsedUserDate? {
switch input {
case "today":
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 ParsedUserDate(date: now, isDateOnly: false)
default:
return nil
}
}
private static func isoFormatter(withFraction: Bool) -> ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions =
withFraction
? [.withInternetDateTime, .withFractionalSeconds]
: [.withInternetDateTime]
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),
]
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, isDateOnly)
}
}
}
import Foundation
public enum RemindCoreError: LocalizedError, Sendable, Equatable {
case accessDenied
case writeOnlyAccess
case listNotFound(String)
case ambiguousList(String, matches: [String])
case reminderNotFound(String)
case ambiguousIdentifier(String, matches: [String])
case invalidIdentifier(String)
case invalidDate(String)
case unsupported(String)
case operationFailed(String)
public var errorDescription: String? {
switch self {
case .accessDenied:
return [
"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:
return [
"Reminders access is write-only.",
"Switch to Full Access in System Settings > Privacy & Security > Reminders.",
].joined(separator: " ")
case .listNotFound(let name):
return "List not found: \"\(name)\"."
case .ambiguousList(let name, let matches):
return "List \"\(name)\" matches multiple lists: \(matches.joined(separator: ", "))."
case .reminderNotFound(let id):
return "Reminder not found: \"\(id)\"."
case .ambiguousIdentifier(let input, let matches):
return "Identifier \"\(input)\" matches multiple reminders: \(matches.joined(separator: ", "))."
case .invalidIdentifier(let input):
return "Invalid identifier: \"\(input)\"."
case .invalidDate(let input):
return "Invalid date: \"\(input)\"."
case .unsupported(let message):
return message
case .operationFailed(let message):
return message
}
}
}
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
public init(calendar: Calendar = .current) {
self.calendar = calendar
}
public func requestAccess() async throws {
let status = Self.authorizationStatus()
switch status {
case .notDetermined:
let updated = try await requestAuthorization()
if updated != .fullAccess {
throw RemindCoreError.accessDenied
}
case .denied, .restricted:
throw RemindCoreError.accessDenied
case .writeOnly:
throw RemindCoreError.writeOnlyAccess
case .fullAccess:
break
}
}
public static func authorizationStatus() -> RemindersAuthorizationStatus {
RemindersAuthorizationStatus(eventKitStatus: EKEventStore.authorizationStatus(for: .reminder))
}
public func requestAuthorization() async throws -> RemindersAuthorizationStatus {
let status = Self.authorizationStatus()
switch status {
case .notDetermined:
let granted = try await requestFullAccess()
return granted ? .fullAccess : .denied
default:
return status
}
}
public func lists() async -> [ReminderList] {
eventStore.calendars(for: .reminder).map { calendar in
ReminderList(id: calendar.calendarIdentifier, title: calendar.title)
}
}
public func resolveList(_ target: ReminderListTarget) async throws -> ReminderList {
let lists = await lists()
switch target {
case .name(let name):
return try ListResolver.resolve(name, in: lists)
case .id(let id):
return try ListResolver.resolveID(id, in: lists)
}
}
public func defaultListName() -> String? { defaultList()?.title }
public func defaultList() -> ReminderList? {
guard let calendar = eventStore.defaultCalendarForNewReminders() else {
return nil
}
return ReminderList(id: calendar.calendarIdentifier, title: calendar.title)
}
public func reminders(in listName: String? = nil) async throws -> [ReminderItem] {
try await reminders(matching: listName.map(ReminderListTarget.name))
}
public func reminders(matching target: ReminderListTarget?) async throws -> [ReminderItem] {
await fetchReminders(in: try calendars(matching: target))
}
public func createList(name: String) async throws -> ReminderList {
let list = EKCalendar(for: .reminder, eventStore: eventStore)
list.title = name
guard let source = eventStore.defaultCalendarForNewReminders()?.source else {
throw RemindCoreError.operationFailed("Unable to determine default reminder source")
}
list.source = source
try eventStore.saveCalendar(list, commit: true)
return ReminderList(id: list.calendarIdentifier, title: list.title)
}
public func renameList(oldName: String, newName: String) async throws {
try await renameList(target: .name(oldName), newName: newName)
}
public func renameList(target: ReminderListTarget, newName: String) async throws {
let calendar = try calendar(matching: target)
guard calendar.allowsContentModifications else {
throw RemindCoreError.operationFailed("Cannot modify system list")
}
calendar.title = newName
try eventStore.saveCalendar(calendar, commit: true)
}
public func deleteList(name: String) async throws {
try await deleteList(target: .name(name))
}
public func deleteList(target: ReminderListTarget) async throws {
let calendar = try calendar(matching: target)
guard calendar.allowsContentModifications else {
throw RemindCoreError.operationFailed("Cannot delete system list")
}
try eventStore.removeCalendar(calendar, commit: true)
}
public func createReminder(_ draft: ReminderDraft, listName: String) async throws -> ReminderItem {
try await createReminder(draft, target: .name(listName))
}
public func createReminder(_ draft: ReminderDraft, target: ReminderListTarget) async throws -> ReminderItem {
let calendar = try calendar(matching: target)
let reminder = EKReminder(eventStore: eventStore)
reminder.title = draft.title
reminder.notes = draft.notes
reminder.url = draft.url
reminder.calendar = calendar
reminder.priority = draft.priority.eventKitValue
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)
}
public func updateReminder(id: String, update: ReminderUpdate) async throws -> ReminderItem {
let reminder = try reminder(withID: id)
if let title = update.title {
reminder.title = title
}
// Simple optional fields: outer optional present => apply. For url, inner nil clears it.
update.notes.map { reminder.notes = $0 }
update.url.map { reminder.url = $0 }
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
}
if let listTarget = update.listTarget {
reminder.calendar = try calendar(matching: listTarget)
} else if let listName = update.listName {
reminder.calendar = try calendar(matching: .name(listName))
}
if let isCompleted = update.isCompleted {
reminder.isCompleted = isCompleted
}
try eventStore.save(reminder, commit: true)
return item(from: reminder)
}
public func completeReminders(ids: [String]) async throws -> [ReminderItem] {
var updated: [ReminderItem] = []
for id in ids {
let reminder = try reminder(withID: id)
reminder.isCompleted = true
try eventStore.save(reminder, commit: true)
updated.append(item(from: reminder))
}
return updated
}
public func deleteReminders(ids: [String]) async throws -> Int {
var deleted = 0
for id in ids {
let reminder = try reminder(withID: id)
try eventStore.remove(reminder, commit: true)
deleted += 1
}
return deleted
}
}
extension RemindersStore {
private func requestFullAccess() async throws -> Bool {
try await withCheckedThrowingContinuation { continuation in
eventStore.requestFullAccessToReminders { granted, error in
if let error {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: granted)
}
}
}
private func fetchReminders(in calendars: [EKCalendar]) async -> [ReminderItem] {
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 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: 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 {
guard let item = eventStore.calendarItem(withIdentifier: id) as? EKReminder else {
throw RemindCoreError.reminderNotFound(id)
}
return item
}
private func calendar(named name: String) throws -> EKCalendar {
try calendar(matching: .name(name))
}
private func calendar(matching target: ReminderListTarget) throws -> EKCalendar {
let resolved = try resolvedList(matching: target)
let calendars = eventStore.calendars(for: .reminder)
guard let calendar = calendars.first(where: { $0.calendarIdentifier == resolved.id }) else {
throw RemindCoreError.listNotFound(resolved.id)
}
return calendar
}
private func calendars(matching target: ReminderListTarget?) throws -> [EKCalendar] {
let calendars = eventStore.calendars(for: .reminder)
guard let target else {
return calendars
}
let lists = calendars.map { ReminderList(id: $0.calendarIdentifier, title: $0.title) }
switch target {
case .name(let name):
let resolved = try ListResolver.resolveForRead(name, in: lists)
let ids = Set(resolved.map(\.id))
return calendars.filter { ids.contains($0.calendarIdentifier) }
case .id:
let resolved = try resolvedList(matching: target, in: lists)
return calendars.filter { $0.calendarIdentifier == resolved.id }
}
}
private func resolvedList(matching target: ReminderListTarget) throws -> ReminderList {
let lists = eventStore.calendars(for: .reminder).map { ReminderList(id: $0.calendarIdentifier, title: $0.title) }
return try resolvedList(matching: target, in: lists)
}
private func resolvedList(matching target: ReminderListTarget, in lists: [ReminderList]) throws -> ReminderList {
switch target {
case .name(let name):
return try ListResolver.resolve(name, in: lists)
case .id(let id):
return try ListResolver.resolveID(id, in: lists)
}
}
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? {
guard let components else { return nil }
return calendar.date(from: components)
}
private func item(from reminder: EKReminder) -> 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: 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
}
}
}
import Foundation
public enum IDResolver {
public static let minimumPrefixLength = 4
public static func resolve(
_ inputs: [String],
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 < numericSorted.count else {
throw RemindCoreError.invalidIdentifier(trimmed)
}
resolved.append(numericSorted[idx])
continue
}
if trimmed.count < minimumPrefixLength {
throw RemindCoreError.invalidIdentifier(trimmed)
}
let matches = sorted.filter { $0.id.lowercased().hasPrefix(trimmed.lowercased()) }
if matches.isEmpty {
throw RemindCoreError.reminderNotFound(trimmed)
}
if matches.count > 1 {
throw RemindCoreError.ambiguousIdentifier(trimmed, matches: matches.map { $0.id })
}
if let match = matches.first {
resolved.append(match)
}
}
return resolved
}
}
import Foundation
public enum ListResolver {
public static func normalizedName(_ value: String) -> String {
value
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
.unicodeScalars
.filter { scalar in
switch scalar.properties.generalCategory {
case .uppercaseLetter, .lowercaseLetter, .titlecaseLetter, .modifierLetter, .otherLetter, .decimalNumber:
return true
default:
return false
}
}
.map(String.init)
.joined()
.lowercased()
}
public static func resolve(_ name: String, in lists: [ReminderList]) throws -> ReminderList {
let exactMatches = lists.filter { $0.title == name }
if exactMatches.count == 1, let match = exactMatches.first {
return match
}
if exactMatches.count > 1 {
throw RemindCoreError.ambiguousList(name, matches: exactMatches.map(summary))
}
let caseMatches = lists.filter { $0.title.compare(name, options: [.caseInsensitive]) == .orderedSame }
if caseMatches.count == 1, let match = caseMatches.first {
return match
}
if caseMatches.count > 1 {
throw RemindCoreError.ambiguousList(name, matches: caseMatches.map(summary))
}
let normalized = normalizedName(name)
guard !normalized.isEmpty else {
throw RemindCoreError.listNotFound(name)
}
let normalizedMatches = lists.filter {
let title = normalizedName($0.title)
return !title.isEmpty && title == normalized
}
if normalizedMatches.count == 1, let match = normalizedMatches.first {
return match
}
if normalizedMatches.count > 1 {
throw RemindCoreError.ambiguousList(name, matches: normalizedMatches.map(summary))
}
throw RemindCoreError.listNotFound(name)
}
public static func resolveForRead(_ name: String, in lists: [ReminderList]) throws -> [ReminderList] {
let exactMatches = lists.filter { $0.title == name }
if !exactMatches.isEmpty {
return exactMatches
}
return [try resolve(name, in: lists)]
}
public static func resolveID(_ id: String, in lists: [ReminderList]) throws -> ReminderList {
let matches = lists.filter { $0.id.lowercased().hasPrefix(id.lowercased()) }
if matches.count == 1, let match = matches.first {
return match
}
if matches.count > 1 {
throw RemindCoreError.ambiguousList(id, matches: matches.map(summary))
}
throw RemindCoreError.listNotFound(id)
}
private static func summary(_ list: ReminderList) -> String {
"\(list.title) (\(list.id))"
}
}
import Foundation
public enum ReminderPriority: String, Codable, CaseIterable, Sendable {
case none
case low
case medium
case high
public init(eventKitValue: Int) {
switch eventKitValue {
case 1...4:
self = .high
case 5:
self = .medium
case 6...9:
self = .low
default:
self = .none
}
}
public var eventKitValue: Int {
switch self {
case .none:
return 0
case .high:
return 1
case .medium:
return 5
case .low:
return 9
}
}
}
public 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
public init(id: String, title: String) {
self.id = id
self.title = title
}
}
public enum ReminderListTarget: Sendable, Equatable {
case name(String)
case id(String)
}
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
public init(
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
}
}
public struct ReminderDraft: Sendable {
public let title: String
public let notes: String?
public let url: URL?
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?,
url: URL? = nil,
dueDate: ParsedUserDate?,
alarmDate: ParsedUserDate? = nil,
recurrenceRule: RecurrenceRule? = nil,
locationTrigger: LocationTrigger? = nil,
priority: ReminderPriority
) {
self.title = title
self.notes = notes
self.url = url
self.dueDate = dueDate
self.alarmDate = alarmDate
self.recurrenceRule = recurrenceRule
self.locationTrigger = locationTrigger
self.priority = priority
}
}
public struct ReminderUpdate: Sendable {
public let title: String?
public let notes: String?
// Double optional: nil = leave unchanged, .some(nil) = clear, .some(url) = set.
public let url: URL??
public let dueDate: ParsedUserDate??
public let alarmDate: ParsedUserDate??
public let recurrenceRule: RecurrenceRule??
public let priority: ReminderPriority?
public let listName: String?
public let listTarget: ReminderListTarget?
public let isCompleted: Bool?
public init(
title: String? = nil,
notes: String? = nil,
url: URL?? = nil,
dueDate: ParsedUserDate?? = nil,
alarmDate: ParsedUserDate?? = nil,
recurrenceRule: RecurrenceRule?? = nil,
priority: ReminderPriority? = nil,
listName: String? = nil,
listTarget: ReminderListTarget? = nil,
isCompleted: Bool? = nil
) {
self.title = title
self.notes = notes
self.url = url
self.dueDate = dueDate
self.alarmDate = alarmDate
self.recurrenceRule = recurrenceRule
self.priority = priority
self.listName = listName
self.listTarget = listTarget
self.isCompleted = isCompleted
}
}
import Foundation
public enum ReminderFilter: Equatable, Sendable {
case today
case tomorrow
case week
case overdue
case upcoming
case open
case completed
case date(Date)
case all
}
public enum ReminderFiltering {
public static func parse(_ input: String, now: Date = Date(), calendar: Calendar = .current) -> ReminderFilter? {
let token = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch token {
case "today", "tday":
return .today
case "tomorrow", "t":
return .tomorrow
case "week", "w":
return .week
case "overdue", "o":
return .overdue
case "upcoming", "u":
return .upcoming
case "open":
return .open
case "completed", "done", "c":
return .completed
case "all", "a":
return .all
default:
if let date = DateParsing.parseUserDate(token, now: now, calendar: calendar) {
return .date(date)
}
return nil
}
}
public static func apply(
_ reminders: [ReminderItem],
filter: ReminderFilter,
now: Date = Date(),
calendar: Calendar = .current
) -> [ReminderItem] {
let startOfToday = calendar.startOfDay(for: now)
let startOfTomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? startOfToday
let startOfDayAfterTomorrow =
calendar.date(byAdding: .day, value: 2, to: startOfToday) ?? startOfTomorrow
switch filter {
case .today:
return reminders.filter { reminder in
let isToday = reminder.dueDate.map { $0 >= startOfToday && $0 < startOfTomorrow } ?? false
let isOverdue = reminder.dueDate.map { $0 < startOfToday } ?? false
return !reminder.isCompleted && (isToday || isOverdue)
}
case .tomorrow:
return reminders.filter { reminder in
let isTomorrow = reminder.dueDate.map { $0 >= startOfTomorrow && $0 < startOfDayAfterTomorrow } ?? false
return !reminder.isCompleted && isTomorrow
}
case .week:
let interval = calendar.dateInterval(of: .weekOfYear, for: now)
let start = interval?.start ?? startOfToday
let end = interval?.end ?? now
return reminders.filter { reminder in
let inWeek = reminder.dueDate.map { $0 >= start && $0 <= end } ?? false
return !reminder.isCompleted && inWeek
}
case .overdue:
return reminders.filter { reminder in
let isOverdue = reminder.dueDate.map { $0 < startOfToday } ?? false
return !reminder.isCompleted && isOverdue
}
case .upcoming:
return reminders.filter { reminder in
!reminder.isCompleted && reminder.dueDate != nil
}
case .open:
return reminders.filter { !$0.isCompleted }
case .completed:
return reminders.filter { $0.isCompleted }
case .date(let date):
return reminders.filter { reminder in
let matches = reminder.dueDate.map { calendar.isDate($0, inSameDayAs: date) } ?? false
return !reminder.isCompleted && matches
}
case .all:
return reminders
}
}
public static func sort(_ reminders: [ReminderItem]) -> [ReminderItem] {
reminders.sorted { lhs, rhs in
switch (lhs.dueDate, rhs.dueDate) {
case (nil, nil):
return lhs.title < rhs.title
case (nil, _?):
return false
case (_?, nil):
return true
case (let left?, let right?):
if left == right {
return lhs.title < rhs.title
}
return left < right
}
}
}
}
import EventKit
import Foundation
public enum RemindersAuthorizationStatus: String, Codable, Sendable, Equatable {
case notDetermined = "not-determined"
case restricted = "restricted"
case denied = "denied"
case writeOnly = "write-only"
case fullAccess = "full-access"
public init(eventKitStatus: EKAuthorizationStatus) {
switch eventKitStatus {
case .notDetermined:
self = .notDetermined
case .restricted:
self = .restricted
case .denied:
self = .denied
case .writeOnly:
self = .writeOnly
case .fullAccess, .authorized:
self = .fullAccess
@unknown default:
self = .denied
}
}
public var isAuthorized: Bool {
self == .fullAccess
}
public var displayName: String {
switch self {
case .notDetermined:
return "Not determined"
case .restricted:
return "Restricted"
case .denied:
return "Denied"
case .writeOnly:
return "Write-only"
case .fullAccess:
return "Full access"
}
}
}
import Foundation
import RemindCore
enum CommandHelpers {
static func parsePriority(_ value: String) throws -> ReminderPriority {
switch value.lowercased() {
case "none":
return .none
case "low":
return .low
case "medium", "med":
return .medium
case "high":
return .high
default:
throw RemindCoreError.operationFailed("Invalid priority: \"\(value)\" (use none|low|medium|high)")
}
}
static func parseDueDate(_ value: String) throws -> ParsedUserDate {
guard let parsed = DateParsing.parseUserDateWithMetadata(value) else {
throw RemindCoreError.invalidDate(value)
}
return parsed
}
static func parseURL(_ value: String) throws -> URL {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard
!trimmed.isEmpty,
let url = URL(string: trimmed),
let scheme = url.scheme,
!scheme.isEmpty
else {
throw RemindCoreError.operationFailed("Invalid URL: \"\(value)\" (include a scheme like https://)")
}
return url
}
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)
}
static func listTarget(name: String?, id: String?) throws -> ReminderListTarget? {
if let name, let id, !name.isEmpty, !id.isEmpty {
throw RemindCoreError.operationFailed("Use either --list or --list-id, not both")
}
if let id, !id.isEmpty {
return .id(id)
}
if let name, !name.isEmpty {
return .name(name)
}
return nil
}
static func requiredListTarget(
name: String?,
id: String?,
argumentName: String = "list"
) throws -> ReminderListTarget {
guard let target = try listTarget(name: name, id: id) else {
throw ParsedValuesError.missingArgument(argumentName)
}
return target
}
static func reminder(_ reminder: ReminderItem, matchesSearch query: String) -> Bool {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let haystack = [
reminder.title,
reminder.notes ?? "",
reminder.url?.absoluteString ?? "",
].joined(separator: "\n")
return haystack.range(of: trimmed, options: [.caseInsensitive, .diacriticInsensitive]) != nil
}
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")
"""
)
}
}
import Commander
import Foundation
import RemindCore
struct CommandRouter {
let rootName = "remindctl"
let version: String
let specs: [CommandSpec]
let program: Program
init() {
self.version = CommandRouter.resolveVersion()
self.specs = [
ShowCommand.spec,
ListCommand.spec,
SearchCommand.spec,
InfoCommand.spec,
AddCommand.spec,
EditCommand.spec,
CompleteCommand.spec,
DeleteCommand.spec,
StatusCommand.spec,
AuthorizeCommand.spec,
DoctorCommand.spec,
ExportCommand.spec,
LinkCommand.spec,
OpenCommand.spec,
CompletionCommand.spec,
]
let descriptor = CommandDescriptor(
name: rootName,
abstract: "Manage Apple Reminders from the terminal",
discussion: nil,
signature: CommandSignature(),
subcommands: specs.map { $0.descriptor },
defaultSubcommandName: "show"
)
self.program = Program(descriptors: [descriptor])
}
func run() async -> Int32 {
await run(argv: CommandLine.arguments)
}
func run(argv: [String]) async -> Int32 {
var argv = normalizeArguments(argv)
argv = applyAliases(argv)
if argv.contains("--version") || argv.contains("-V") {
Swift.print(version)
return 0
}
if argv.contains("--help") || argv.contains("-h") {
printHelp(for: argv)
return 0
}
argv = rewriteImplicitShow(argv)
do {
let invocation = try program.resolve(argv: argv)
guard let commandName = invocation.path.last,
let spec = specs.first(where: { $0.name == commandName })
else {
Console.printError("Unknown command")
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
return 1
}
let runtime = RuntimeOptions(parsedValues: invocation.parsedValues)
do {
try runtime.validate()
try await spec.run(invocation.parsedValues, runtime)
return 0
} catch {
Console.printError(error.localizedDescription)
return 1
}
} catch let error as CommanderProgramError {
Console.printError(error.description)
if case .missingSubcommand = error {
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
}
return 1
} catch {
Console.printError(error.localizedDescription)
return 1
}
}
private func normalizeArguments(_ argv: [String]) -> [String] {
guard !argv.isEmpty else { return argv }
var copy = argv
copy[0] = URL(fileURLWithPath: argv[0]).lastPathComponent
return copy
}
private func applyAliases(_ argv: [String]) -> [String] {
guard argv.count >= 2 else { return argv }
var copy = argv
if copy[1] == "lists" || copy[1] == "ls" {
copy[1] = "list"
}
if copy[1] == "rm" {
copy[1] = "delete"
}
if copy[1] == "done" {
copy[1] = "complete"
}
return copy
}
private func rewriteImplicitShow(_ argv: [String]) -> [String] {
guard argv.count >= 2 else { return argv }
let token = argv[1]
if token.hasPrefix("-") {
return argv
}
let commandNames = Set(specs.map { $0.name })
if commandNames.contains(token) {
return argv
}
if ReminderFiltering.parse(token) != nil {
var copy = argv
copy.insert("show", at: 1)
return copy
}
return argv
}
private func printHelp(for argv: [String]) {
let path = helpPath(from: argv)
if path.count <= 1 {
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
return
}
if let spec = specs.first(where: { $0.name == path[1] }) {
HelpPrinter.printCommand(rootName: rootName, spec: spec)
} else {
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
}
}
private func helpPath(from argv: [String]) -> [String] {
var path: [String] = []
for token in argv {
if token == "--help" || token == "-h" { continue }
if token.hasPrefix("-") { break }
path.append(token)
}
return path
}
private static func resolveVersion() -> String {
if let envVersion = ProcessInfo.processInfo.environment["REMINDCTL_VERSION"], !envVersion.isEmpty {
return envVersion
}
return RemindctlVersion.current
}
}
import Commander
import Foundation
import RemindCore
enum AuthorizeCommand {
static var spec: CommandSpec {
CommandSpec(
name: "authorize",
abstract: "Request Reminders access",
discussion: "Triggers the Reminders permission prompt when available.",
signature: CommandSignatures.withRuntimeFlags(CommandSignature()),
usageExamples: [
"remindctl authorize",
"remindctl authorize --json",
"remindctl authorize --quiet",
]
) { _, runtime in
let store = RemindersStore()
let current = RemindersStore.authorizationStatus()
let status: RemindersAuthorizationStatus
if current == .notDetermined {
status = try await store.requestAuthorization()
} else {
status = current
}
OutputRenderer.printAuthorizationStatus(status, format: runtime.outputFormat)
switch status {
case .fullAccess:
return
case .writeOnly:
throw RemindCoreError.writeOnlyAccess
case .notDetermined, .denied, .restricted:
throw RemindCoreError.accessDenied
}
}
}
}
import Commander
import Foundation
import RemindCore
enum CompleteCommand {
static var spec: CommandSpec {
CommandSpec(
name: "complete",
abstract: "Mark reminders complete",
discussion: "Use indexes or ID prefixes from show output.",
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
arguments: [
.make(label: "ids", help: "Indexes or ID prefixes", isOptional: true)
],
flags: [
.make(label: "dryRun", names: [.short("n"), .long("dry-run")], help: "Preview without changes")
]
)
),
usageExamples: [
"remindctl complete 1",
"remindctl complete 1 2 3",
"remindctl complete 4A83",
]
) { values, runtime in
let inputs = values.positional
guard !inputs.isEmpty else {
throw ParsedValuesError.missingArgument("ids")
}
let store = RemindersStore()
try await store.requestAccess()
let reminders = try await store.reminders(in: nil)
let resolved = try CommandHelpers.resolveShowIdentifiers(inputs, from: reminders)
if values.flag("dryRun") {
OutputRenderer.printReminders(resolved, format: runtime.outputFormat)
return
}
let updated = try await store.completeReminders(ids: resolved.map { $0.id })
OutputRenderer.printReminders(updated, format: runtime.outputFormat)
}
}
}
import Commander
import Foundation
import RemindCore
enum StatusCommand {
static var spec: CommandSpec {
CommandSpec(
name: "status",
abstract: "Show Reminders authorization status",
discussion: "Reports the current Reminders permission state without prompting.",
signature: CommandSignatures.withRuntimeFlags(CommandSignature()),
usageExamples: [
"remindctl status",
"remindctl status --json",
"remindctl status --plain",
]
) { _, runtime in
let status = RemindersStore.authorizationStatus()
OutputRenderer.printAuthorizationStatus(status, format: runtime.outputFormat)
if runtime.outputFormat == .standard, !status.isAuthorized {
for line in PermissionsHelp.guidanceLines(for: status) {
Swift.print(line)
}
}
}
}
}
import Foundation
@main
enum RemindctlMain {
static func main() async {
let code = await CommandRouter().run()
exit(code)
}
}
// Generated by scripts/generate-version.sh. Do not edit.
enum RemindctlVersion {
static let current = "0.3.1"
}