Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/scripts/check-skill-json-fields.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ skill_file="skills/reminders-cli/SKILL.md"
expected=$(sed -n '/enum EncodingKeys/,/^ }/p' "$swift_file" \
| sed -n 's/^ *case \([A-Za-z0-9_]*\).*/\1/p')

# The backticks are literal table markup, not command substitution.
# shellcheck disable=SC2016
actual=$(sed -n '/<!-- json-fields:start -->/,/<!-- json-fields:end -->/p' "$skill_file" \
| sed -n 's/^| `\([A-Za-z0-9_]*\)`.*/\1/p')

Expand Down
4 changes: 3 additions & 1 deletion .github/scripts/live-test/live-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ if ! created=$(run new-list "$name" --format json 2>new-list.err); then
fi
echo "$created"
list_id=$(jqr .calendarIdentifier <<<"$created")
[ -n "$list_id" ] && [ "$list_id" != null ] || fail "new-list returned no calendarIdentifier"
if [ -z "$list_id" ] || [ "$list_id" = null ]; then
fail "new-list returned no calendarIdentifier"
fi

# --- add: due date, notes, priority, weekly repeat --------------------------
echo "--- add"
Expand Down
150 changes: 150 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
---
name: Lint

on:
push:
branches:
- main
pull_request:
branches:
- main

permissions:
contents: read

env:
# Used by both the shell and the actions job.
SHELLCHECK_VERSION: 0.11.0
SHELLCHECK_SHA256: 8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198

# Each linter is its own job so a failure names the file type. Tools are pinned to exact versions,
# and downloaded binaries are checked against the SHA-256 digest GitHub records for the release asset.
jobs:
markdown:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

# Files and rules come from .markdownlint-cli2.yaml, whose globs add to the action's default of the root files.
- uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0

markdown-links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- name: Restore lychee cache
uses: actions/cache@v6
with:
path: .lycheecache
key: lychee-${{ github.sha }}
restore-keys: lychee-

- uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
with:
lycheeVersion: v0.24.2
args: --config lychee.toml --no-progress README.md CLAUDE.md 'skills/**/*.md'
jobSummary: false

swift:
runs-on: ubuntu-latest
env:
SWIFTLINT_VERSION: 0.65.1
SWIFTLINT_SHA256: caeed6f4a679c35539ffaf124f6c4ab4a8416917f7d8796279dc52b74026059d
steps:
- uses: actions/checkout@v7

- name: Install SwiftLint
run: |
curl -fsSLo "$RUNNER_TEMP/swiftlint.zip" \
"https://github.com/realm/SwiftLint/releases/download/$SWIFTLINT_VERSION/swiftlint_linux_amd64.zip"
echo "$SWIFTLINT_SHA256 $RUNNER_TEMP/swiftlint.zip" | sha256sum -c -
unzip -q "$RUNNER_TEMP/swiftlint.zip" -d "$RUNNER_TEMP/swiftlint"
echo "$RUNNER_TEMP/swiftlint" >> "$GITHUB_PATH"

- name: SwiftLint
run: swiftlint lint --strict --reporter github-actions-logging

makefile:
runs-on: ubuntu-latest
env:
CHECKMAKE_VERSION: v0.3.2
CHECKMAKE_SHA256: e2effb876913f3ee2caef0ba35f6202c5e8a3cd55a077d8d2b9ce2034257b6af
steps:
- uses: actions/checkout@v7

- name: Install checkmake
run: |
mkdir -p "$RUNNER_TEMP/checkmake"
curl -fsSLo "$RUNNER_TEMP/checkmake/checkmake" \
"https://github.com/checkmake/checkmake/releases/download/$CHECKMAKE_VERSION/checkmake-$CHECKMAKE_VERSION.linux.amd64"
echo "$CHECKMAKE_SHA256 $RUNNER_TEMP/checkmake/checkmake" | sha256sum -c -
chmod +x "$RUNNER_TEMP/checkmake/checkmake"
echo "$RUNNER_TEMP/checkmake" >> "$GITHUB_PATH"

- name: checkmake
run: checkmake Makefile

yaml:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

# Files and rules come from .yamllint.yml.
- name: yamllint
run: pipx run yamllint==1.38.0 --strict --format github .

actions:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
steps:
- uses: actions/checkout@v7

- name: Install actionlint
run: |
curl -fsSLo "$RUNNER_TEMP/actionlint.tar.gz" \
"https://github.com/rhysd/actionlint/releases/download/v$ACTIONLINT_VERSION/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
echo "$ACTIONLINT_SHA256 $RUNNER_TEMP/actionlint.tar.gz" | sha256sum -c -
mkdir -p "$RUNNER_TEMP/actionlint"
tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP/actionlint" actionlint
echo "$RUNNER_TEMP/actionlint" >> "$GITHUB_PATH"

- name: Install shellcheck
run: |
curl -fsSLo "$RUNNER_TEMP/shellcheck.tar.xz" \
"https://github.com/koalaman/shellcheck/releases/download/v$SHELLCHECK_VERSION/shellcheck-v$SHELLCHECK_VERSION.linux.x86_64.tar.xz"
echo "$SHELLCHECK_SHA256 $RUNNER_TEMP/shellcheck.tar.xz" | sha256sum -c -
tar -xJf "$RUNNER_TEMP/shellcheck.tar.xz" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/shellcheck-v$SHELLCHECK_VERSION" >> "$GITHUB_PATH"

# Also runs shellcheck over every `run:` block.
- name: actionlint
run: actionlint

shell:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

# The runner's own shellcheck is older and reports differently, so both jobs install the pinned one.
- name: Install shellcheck
run: |
curl -fsSLo "$RUNNER_TEMP/shellcheck.tar.xz" \
"https://github.com/koalaman/shellcheck/releases/download/v$SHELLCHECK_VERSION/shellcheck-v$SHELLCHECK_VERSION.linux.x86_64.tar.xz"
echo "$SHELLCHECK_SHA256 $RUNNER_TEMP/shellcheck.tar.xz" | sha256sum -c -
tar -xJf "$RUNNER_TEMP/shellcheck.tar.xz" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/shellcheck-v$SHELLCHECK_VERSION" >> "$GITHUB_PATH"

- name: shellcheck
run: git ls-files -z '*.sh' | xargs -0 shellcheck

json:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

# Syntax only: release-please and SwiftPM own the formatting of the files they write.
- name: jq
run: git ls-files -z '*.json' '*.resolved' | xargs -0 -n1 jq empty
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.build
.claude
reminders.tar.gz
.lycheecache
13 changes: 13 additions & 0 deletions .markdownlint-cli2.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
config:
# Prose is written one paragraph per line, and tables and command lines can't be wrapped.
MD013: false

globs:
- "**/*.md"

ignores:
# Generated by release-please.
- CHANGELOG.md
- .build/**
- .claude/**
39 changes: 39 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
included:
- Sources
- Tests
- Package.swift
excluded:
- .build

disabled_rules:
# Business logic lives in Reminders.swift and the subcommands in CLI.swift by design (see
# CLAUDE.md), and test cases group every test of one area in a single class.
- file_length
- type_body_length
# The testable free functions (configureNewReminder, applyEdit, ...) take each option as its own
# defaulted parameter, so callers and tests set only what they need.
- function_parameter_count
# TODOs here record upstream limitations (Apple feedback, minimum macOS version), not open work.
- todo

# Trailing commas in multi-line literals are the codebase's style: they keep diffs to one line.
trailing_comma:
mandatory_comma: true

# A multi-line condition or signature puts its opening brace on its own line.
opening_brace:
ignore_multiline_function_signatures: true
ignore_multiline_statement_conditions: true

# An exhaustive switch over an enum is one decision, not one per case.
cyclomatic_complexity:
ignores_case_statements: true

identifier_name:
excluded:
# SwiftLint's default exclusion, which setting this list replaces.
- id
# Operands of comparators and paired test fixtures.
- a
- b
17 changes: 17 additions & 0 deletions .yamllint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
extends: default

ignore: |
.build/
.claude/

rules:
# `on:` is a workflow key, not a boolean.
truthy:
check-keys: false
# Workflow comments and step commands explain themselves on one line.
line-length:
max: 160
# SHA-pinned actions carry their version as `@sha # vX.Y.Z`.
comments:
min-spaces-from-content: 1
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Full release package (tarball + shasums, as used for GitHub releases): `make package`
- Clean build artifacts: `make clean`
- Run locally without installing: `swift run reminders <subcommand> ...`
- Lint (as `.github/workflows/lint.yml` does): `swiftlint lint --strict`, `markdownlint-cli2`, `lychee --config lychee.toml README.md CLAUDE.md 'skills/**/*.md'`, `checkmake Makefile`, `yamllint --strict .`, `actionlint`, `git ls-files -z '*.sh' | xargs -0 shellcheck`, `git ls-files -z '*.json' '*.resolved' | xargs -0 -n1 jq empty`

There is no linter configured (no SwiftLint/SwiftFormat) — code quality is enforced only via `-warnings-as-errors` on both library targets, applied both in `Package.swift` and again explicitly in CI.
Besides `-warnings-as-errors` on both library targets (applied in `Package.swift` and again explicitly in CI), `.github/workflows/lint.yml` lints every file type in one job per linter, all blocking: Swift with SwiftLint (`.swiftlint.yml`), Markdown with markdownlint-cli2 (`.markdownlint-cli2.yaml`) and its links with lychee (`lychee.toml`), the `Makefile` with checkmake, YAML with yamllint (`.yamllint.yml`), workflows with actionlint, shell scripts with shellcheck, and JSON syntax with jq. `CHANGELOG.md` is excluded, since release-please generates it. The tool versions and download digests are pinned in the workflow. Fix a finding rather than disabling its rule; a rule is only disabled or tuned in the config where it conflicts with a deliberate project pattern, with the reason next to it, and a single exception is a `// swiftlint:disable:next <rule>` with a comment saying why.

## Architecture

Expand All @@ -30,7 +31,7 @@ Execution flows in one direction through four layers:
5. **Supporting extensions**, used by the layers above:
- `NaturalLanguage.swift` — `DateComponents(argument:)` (`ExpressibleByArgument`), parses natural-language date strings like `"tomorrow 9am"` for `--due-date` options via `NSDataDetector`. Known limitation: `"next weekend"` doesn't parse (Apple Feedback FB8921206), covered by a test expecting `nil`.
- `ShowAllQuery.swift` — the `show-all` parameters behind the `today`, `overdue` and `upcoming` convenience commands, built by `ShowAllQuery.today/overdue/upcoming(...)` (which take `now`/`calendar` so `ShowAllQueryTests` can pin the dates) and run through `Reminders.showAllReminders(_:outputFormat:)`. These commands must stay free of filter logic: a new shortcut is a new builder, not a new code path.
- `ReminderSelection.swift` — the EventKit-free expansion of the reminder ID arguments of `complete`, `uncomplete`, `delete` (variadic) and `edit`, `postpone` (one argument): comma-separated IDs and `-` for newline-separated IDs on stdin (injected for tests). `isBatch` depends only on the raw arguments and picks the JSON shape: the single object for one plain ID, an array otherwise. `Edit.validate()` uses it to reject new text on a batch. The commands resolve every ID with `resolveReminders(_:idsOrPrefixes:onList:)` before changing anything, stage the changes in memory (`Store.reset()` on any error), and save or remove them with `commit: false` followed by one `Store.commit()`.
- `ReminderSelection.swift` — the EventKit-free expansion of the reminder ID arguments of `complete`, `uncomplete`, `delete` (variadic) and `edit`, `postpone` (one argument): comma-separated IDs and `-` for newline-separated IDs on stdin (injected for tests). `isBatch` depends only on the raw arguments and picks the JSON shape: the single object for one plain ID, an array otherwise. `Edit.validate()` uses it to reject new text on a batch. The commands resolve every ID with `resolveReminders(_:idsOrPrefixes:onList:)` before changing anything, stage the changes in memory (`eventStore.reset()` on any error), and save or remove them with `commit: false` followed by one `eventStore.commit()`.
- `Sort.swift` — `Sort`/`CustomSortOrder` enums backing `show --sort`/`--sort-order`, plus `ListSort` backing `show-lists --sort`.
- `RepeatDays.swift` — `RepeatDays`, the parsed `--repeat-on` day set (names, `weekdays`/`weekends` aliases, de-duplicated and Sunday-first), plus `plainWeekdays(of:)`, which reads a rule's days only when none carries a week number (so "last Friday of the month" isn't reported as plain days). It backs the plain `on Mon, Wed` suffix, JSON `recurrenceDays` and the weekly-on-days branch of `nextOccurrence`.
- `ListSummary.swift` — the `ListSummary` value type behind `show-lists` plus the EventKit-fetch-free `summarizeLists(_:reminders:now:includeCompleted:)` and `formatListSummaries(_:)`, so the per-list counting and the aligned plain output are unit-testable. Counts come from one fetch over all calendars (`fetchReminders(matching:displayOptions:)` with EventKit's incomplete-reminders predicate, or the all-reminders one for `--include-completed`), bucketed by `calendar.calendarIdentifier`. "Overdue" is the free function `isOverdue(_:now:)` in `Reminders.swift`, shared with `show --overdue` so the two views can't disagree.
Expand Down
10 changes: 7 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@ RELEASE_BUILD=./.build/apple/Products/Release
EXECUTABLE=reminders
ARCHIVE=$(EXECUTABLE).tar.gz

.PHONY: clean build-release package
.PHONY: all clean build-release package test

all: build-release

build-release:
swift build --configuration release --arch arm64 --arch x86_64

test:
swift test -Xswiftc -warnings-as-errors

package: build-release
$(RELEASE_BUILD)/$(EXECUTABLE) --generate-completion-script zsh > _reminders
tar -pvczf $(ARCHIVE) _reminders -C $(RELEASE_BUILD) $(EXECUTABLE)
tar -zxvf $(ARCHIVE)
@shasum -a 256 $(ARCHIVE)
@shasum -a 256 $(EXECUTABLE)
@shasum -a 256 $(ARCHIVE) $(EXECUTABLE)
rm $(EXECUTABLE) _reminders

clean:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -690,8 +690,8 @@ brew install udondan/software/reminders-cli

### From GitHub releases

Download the latest release from
[here](https://github.com/udondan/reminders-cli/releases)
Download the latest release from the
[releases page](https://github.com/udondan/reminders-cli/releases)

```console
tar -zxvf reminders.tar.gz
Expand Down
Loading