diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift
index 757db00..b02ab8e 100644
--- a/Sources/RemindCore/Models.swift
+++ b/Sources/RemindCore/Models.swift
@@ -154,6 +154,39 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
self.listID = listID
self.listName = listName
}
+
+ public var tags: [String] {
+ Self.extractTrailingTags(from: title).tags
+ }
+
+ public var titleWithoutTags: String {
+ Self.extractTrailingTags(from: title).title
+ }
+
+ private static let trailingTagPattern = try! NSRegularExpression(
+ pattern: "(?:^|\\s)#([A-Za-z0-9][A-Za-z0-9_-]*)$"
+ )
+
+ private static func extractTrailingTags(from rawTitle: String) -> (title: String, tags: [String]) {
+ var title = rawTitle.trimmingCharacters(in: .whitespacesAndNewlines)
+ var extracted: [String] = []
+
+ while !title.isEmpty {
+ let range = NSRange(title.startIndex..
[String] {
+ var tags: [String] = []
+ var seen: Set = []
+
+ for raw in rawValues {
+ for candidate in raw.split(separator: ",", omittingEmptySubsequences: false) {
+ var tag = String(candidate).trimmingCharacters(in: .whitespacesAndNewlines)
+ if tag.hasPrefix("#") {
+ tag.removeFirst()
+ }
+ guard !tag.isEmpty else {
+ throw RemindCoreError.operationFailed("Tag cannot be empty")
+ }
+ guard tag.range(of: #"^[A-Za-z0-9][A-Za-z0-9_-]*$"#, options: .regularExpression) != nil else {
+ throw RemindCoreError.operationFailed("Invalid tag: \"\(tag)\"")
+ }
+ let key = tag.lowercased()
+ if seen.insert(key).inserted {
+ tags.append(tag)
+ }
+ }
+ }
+
+ return tags
+ }
+
+ static func parseTitleTags(_ rawTitle: String) -> (baseTitle: String, tags: [String]) {
+ let pattern = #"(?:^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)$"#
+ var title = rawTitle.trimmingCharacters(in: .whitespacesAndNewlines)
+ var extracted: [String] = []
+
+ while !title.isEmpty,
+ let range = title.range(of: pattern, options: .regularExpression)
+ {
+ let match = String(title[range])
+ let tag =
+ match
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .dropFirst()
+ extracted.append(String(tag))
+ title.removeSubrange(range)
+ title = title.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ return (baseTitle: title, tags: extracted.reversed())
+ }
+
+ static func composeTitle(baseTitle: String, tags: [String]) -> String {
+ let normalized = tags.map { "#\($0)" }.joined(separator: " ")
+ guard !normalized.isEmpty else { return baseTitle }
+ guard !baseTitle.isEmpty else { return normalized }
+ return "\(baseTitle) \(normalized)"
+ }
+
+ static func mergeTags(existing: [String], add: [String], remove: [String], clear: Bool) -> [String] {
+ var current = clear ? [] : existing
+
+ if !remove.isEmpty {
+ let removeSet = Set(remove.map { $0.lowercased() })
+ current.removeAll { removeSet.contains($0.lowercased()) }
+ }
+
+ var seen = Set(current.map { $0.lowercased() })
+ for tag in add {
+ let key = tag.lowercased()
+ if seen.insert(key).inserted {
+ current.append(tag)
+ }
+ }
+
+ return current
+ }
}
diff --git a/Sources/remindctl/CommandRouter.swift b/Sources/remindctl/CommandRouter.swift
index 7117ec2..4909a87 100644
--- a/Sources/remindctl/CommandRouter.swift
+++ b/Sources/remindctl/CommandRouter.swift
@@ -12,6 +12,7 @@ struct CommandRouter {
self.version = CommandRouter.resolveVersion()
self.specs = [
ShowCommand.spec,
+ TagsCommand.spec,
ListCommand.spec,
AddCommand.spec,
EditCommand.spec,
diff --git a/Sources/remindctl/Commands/AddCommand.swift b/Sources/remindctl/Commands/AddCommand.swift
index b33a935..e615cfa 100644
--- a/Sources/remindctl/Commands/AddCommand.swift
+++ b/Sources/remindctl/Commands/AddCommand.swift
@@ -37,6 +37,12 @@ enum AddCommand {
help: "daily|weekly|biweekly|monthly|yearly|every N days/weeks/months/years",
parsing: .singleValue
),
+ .make(
+ label: "tag",
+ names: [.long("tag")],
+ help: "Tag name (repeatable or comma-separated)",
+ parsing: .singleValue
+ ),
.make(
label: "priority",
names: [.short("p"), .long("priority")],
@@ -56,6 +62,8 @@ enum AddCommand {
"remindctl add \"Check mailbox\" --location \"1 Apple Park Way, Cupertino, CA\"",
"remindctl add \"Take vitamins\" --due tomorrow --repeat daily",
"remindctl add \"Review docs\" --priority high",
+ "remindctl add \"Buy milk\" --tag shopping --tag urgent",
+ "remindctl add \"Buy milk\" --tag shopping,urgent",
]
) { values, runtime in
let titleOption = values.option("title")
@@ -85,6 +93,7 @@ enum AddCommand {
let radiusValue = values.option("radius")
let repeatValue = values.option("repeat")
let priorityValue = values.option("priority")
+ let tagValues = values.optionValues("tag")
let dueDate = try dueValue.map(CommandHelpers.parseDueDate)
let alarmDate = try alarmValue.map(CommandHelpers.parseDueDate)
@@ -95,6 +104,10 @@ enum AddCommand {
)
let recurrenceRule = try repeatValue.map(CommandHelpers.parseRecurrence)
let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none
+ let tags = try CommandHelpers.parseTags(tagValues)
+ let parsedTitle = CommandHelpers.parseTitleTags(title)
+ let mergedTags = CommandHelpers.mergeTags(existing: parsedTitle.tags, add: tags, remove: [], clear: false)
+ let titleWithTags = CommandHelpers.composeTitle(baseTitle: parsedTitle.baseTitle, tags: mergedTags)
let store = RemindersStore()
try await store.requestAccess()
@@ -110,7 +123,7 @@ enum AddCommand {
}
let draft = ReminderDraft(
- title: title,
+ title: titleWithTags,
notes: notes,
dueDate: dueDate,
alarmDate: alarmDate,
diff --git a/Sources/remindctl/Commands/EditCommand.swift b/Sources/remindctl/Commands/EditCommand.swift
index a7a7992..a5c0296 100644
--- a/Sources/remindctl/Commands/EditCommand.swift
+++ b/Sources/remindctl/Commands/EditCommand.swift
@@ -25,6 +25,18 @@ enum EditCommand {
help: "daily|weekly|biweekly|monthly|yearly|every N days/weeks/months/years",
parsing: .singleValue
),
+ .make(
+ label: "tag",
+ names: [.long("tag")],
+ help: "Add tag (repeatable or comma-separated)",
+ parsing: .singleValue
+ ),
+ .make(
+ label: "removeTag",
+ names: [.long("remove-tag")],
+ help: "Remove tag (repeatable or comma-separated)",
+ parsing: .singleValue
+ ),
.make(
label: "priority",
names: [.short("p"), .long("priority")],
@@ -36,6 +48,7 @@ enum EditCommand {
.make(label: "clearDue", names: [.long("clear-due")], help: "Clear due date"),
.make(label: "clearAlarm", names: [.long("clear-alarm")], help: "Clear alarm"),
.make(label: "noRepeat", names: [.long("no-repeat")], help: "Remove recurrence"),
+ .make(label: "clearTags", names: [.long("clear-tags")], help: "Remove all tags"),
.make(label: "complete", names: [.long("complete")], help: "Mark completed"),
.make(label: "incomplete", names: [.long("incomplete")], help: "Mark incomplete"),
]
@@ -48,6 +61,8 @@ enum EditCommand {
"remindctl edit 4A83 --repeat weekly",
"remindctl edit 2 --priority high --notes \"Call before noon\"",
"remindctl edit 3 --clear-due --clear-alarm --no-repeat",
+ "remindctl edit 1 --tag urgent --remove-tag someday",
+ "remindctl edit 1 --clear-tags",
]
) { values, runtime in
guard let input = values.argument(0) else {
@@ -62,11 +77,27 @@ enum EditCommand {
throw RemindCoreError.reminderNotFound(input)
}
- let title = values.option("title")
+ var title = values.option("title")
let listName = values.option("list")
let notes = values.option("notes")
let alarmValue = values.option("alarm")
let repeatValue = values.option("repeat")
+ let addTags = try CommandHelpers.parseTags(values.optionValues("tag"))
+ let removeTags = try CommandHelpers.parseTags(values.optionValues("removeTag"))
+ let clearTags = values.flag("clearTags")
+ let hasTagChange = !addTags.isEmpty || !removeTags.isEmpty || clearTags
+
+ if hasTagChange {
+ let sourceTitle = title ?? reminder.title
+ let parsed = CommandHelpers.parseTitleTags(sourceTitle)
+ let merged = CommandHelpers.mergeTags(
+ existing: parsed.tags,
+ add: addTags,
+ remove: removeTags,
+ clear: clearTags
+ )
+ title = CommandHelpers.composeTitle(baseTitle: parsed.baseTitle, tags: merged)
+ }
var dueUpdate: ParsedUserDate??
if let dueValue = values.option("due") {
@@ -115,7 +146,7 @@ enum EditCommand {
let hasChanges =
title != nil || listName != nil || notes != nil || dueUpdate != nil || alarmUpdate != nil || priority != nil
- || recurrenceUpdate != nil || isCompleted != nil
+ || recurrenceUpdate != nil || isCompleted != nil || hasTagChange
if !hasChanges {
throw RemindCoreError.operationFailed("No changes specified")
}
diff --git a/Sources/remindctl/Commands/ShowCommand.swift b/Sources/remindctl/Commands/ShowCommand.swift
index 04c4033..db430b1 100644
--- a/Sources/remindctl/Commands/ShowCommand.swift
+++ b/Sources/remindctl/Commands/ShowCommand.swift
@@ -23,7 +23,13 @@ enum ShowCommand {
names: [.short("l"), .long("list")],
help: "Limit to a specific list",
parsing: .singleValue
- )
+ ),
+ .make(
+ label: "tag",
+ names: [.long("tag")],
+ help: "Filter by tag (repeatable or comma-separated)",
+ parsing: .singleValue
+ ),
]
)
),
@@ -33,10 +39,12 @@ enum ShowCommand {
"remindctl show overdue",
"remindctl show 2026-01-04",
"remindctl show --list Work",
+ "remindctl show --tag shopping",
]
) { values, runtime in
let listName = values.option("list")
let filterToken = values.argument(0)
+ let tagFilters = try CommandHelpers.parseTags(values.optionValues("tag")).map { $0.lowercased() }
let filter: ReminderFilter
if let token = filterToken {
@@ -51,7 +59,17 @@ enum ShowCommand {
let store = RemindersStore()
try await store.requestAccess()
let reminders = try await store.reminders(in: listName)
- let filtered = ReminderFiltering.apply(reminders, filter: filter)
+ let filteredByDate = ReminderFiltering.apply(reminders, filter: filter)
+ let filtered: [ReminderItem]
+ if tagFilters.isEmpty {
+ filtered = filteredByDate
+ } else {
+ let tagFilterSet = Set(tagFilters)
+ filtered = filteredByDate.filter { reminder in
+ let reminderTags = Set(reminder.tags.map { $0.lowercased() })
+ return !tagFilterSet.isDisjoint(with: reminderTags)
+ }
+ }
OutputRenderer.printReminders(filtered, format: runtime.outputFormat)
}
}
diff --git a/Sources/remindctl/Commands/TagsCommand.swift b/Sources/remindctl/Commands/TagsCommand.swift
new file mode 100644
index 0000000..90270dd
--- /dev/null
+++ b/Sources/remindctl/Commands/TagsCommand.swift
@@ -0,0 +1,54 @@
+import Commander
+import Foundation
+import RemindCore
+
+enum TagsCommand {
+ static var spec: CommandSpec {
+ CommandSpec(
+ name: "tags",
+ abstract: "List tags or reminders for a tag",
+ discussion: "Without an argument, prints all tags with counts. With a tag, shows reminders that match it.",
+ signature: CommandSignatures.withRuntimeFlags(
+ CommandSignature(
+ arguments: [
+ .make(label: "tag", help: "Tag name", isOptional: true)
+ ]
+ )
+ ),
+ usageExamples: [
+ "remindctl tags",
+ "remindctl tags shopping",
+ ]
+ ) { values, runtime in
+ let requestedTag = values.argument(0)
+
+ let store = RemindersStore()
+ try await store.requestAccess()
+ let reminders = try await store.reminders(in: nil)
+
+ if let requestedTag {
+ let parsed = try CommandHelpers.parseTags([requestedTag])
+ guard let filterTag = parsed.first, parsed.count == 1 else {
+ throw RemindCoreError.operationFailed("Provide a single tag")
+ }
+ let key = filterTag.lowercased()
+ let matching = reminders.filter { reminder in
+ reminder.tags.contains { $0.lowercased() == key }
+ }
+ OutputRenderer.printReminders(matching, format: runtime.outputFormat)
+ return
+ }
+
+ var byKey: [String: TagSummary] = [:]
+ for reminder in reminders {
+ for tag in reminder.tags {
+ let key = tag.lowercased()
+ let existing = byKey[key]
+ byKey[key] = TagSummary(tag: existing?.tag ?? tag, count: (existing?.count ?? 0) + 1)
+ }
+ }
+
+ OutputRenderer.printTagSummaries(Array(byKey.values), format: runtime.outputFormat)
+ }
+ }
+}
diff --git a/Sources/remindctl/OutputFormatting.swift b/Sources/remindctl/OutputFormatting.swift
index 6dac77d..a4e06b5 100644
--- a/Sources/remindctl/OutputFormatting.swift
+++ b/Sources/remindctl/OutputFormatting.swift
@@ -20,6 +20,53 @@ struct AuthorizationSummary: Codable, Sendable, Equatable {
let authorized: Bool
}
+struct TagSummary: Codable, Sendable, Equatable {
+ let tag: String
+ let count: Int
+}
+
+struct ReminderOutput: Codable, Sendable, Equatable {
+ let id: String
+ let title: String
+ let titleWithoutTags: String
+ let tags: [String]
+ let notes: String?
+ let url: URL?
+ let isCompleted: Bool
+ let completionDate: Date?
+ let creationDate: Date?
+ let lastModifiedDate: Date?
+ let priority: ReminderPriority
+ let dueDate: Date?
+ let dueDateIsAllDay: Bool
+ let alarmDate: Date?
+ let recurrenceRule: RecurrenceRule?
+ let locationTrigger: LocationTrigger?
+ let listID: String
+ let listName: String
+
+ init(reminder: ReminderItem) {
+ id = reminder.id
+ title = reminder.title
+ titleWithoutTags = reminder.titleWithoutTags
+ tags = reminder.tags
+ notes = reminder.notes
+ url = reminder.url
+ isCompleted = reminder.isCompleted
+ completionDate = reminder.completionDate
+ creationDate = reminder.creationDate
+ lastModifiedDate = reminder.lastModifiedDate
+ priority = reminder.priority
+ dueDate = reminder.dueDate
+ dueDateIsAllDay = reminder.dueDateIsAllDay
+ alarmDate = reminder.alarmDate
+ recurrenceRule = reminder.recurrenceRule
+ locationTrigger = reminder.locationTrigger
+ listID = reminder.listID
+ listName = reminder.listName
+ }
+}
+
enum OutputRenderer {
static func printReminders(_ reminders: [ReminderItem], format: OutputFormat) {
switch format {
@@ -28,7 +75,7 @@ enum OutputRenderer {
case .plain:
printRemindersPlain(reminders)
case .json:
- printJSON(reminders)
+ printJSON(reminders.map(ReminderOutput.init))
case .quiet:
Swift.print(reminders.count)
}
@@ -55,16 +102,37 @@ enum OutputRenderer {
DateParsing.formatDisplay($0, isDateOnly: reminder.dueDateIsAllDay)
} ?? "no due date"
let recurrence = recurrenceSuffix(for: reminder)
- Swift.print("✓ \(reminder.title) [\(reminder.listName)] — \(due)\(recurrence)")
+ Swift.print("✓ \(displayTitle(for: reminder)) [\(reminder.listName)] — \(due)\(recurrence)")
case .plain:
Swift.print(plainLine(for: reminder))
case .json:
- printJSON(reminder)
+ printJSON(ReminderOutput(reminder: reminder))
case .quiet:
break
}
}
+ static func printTagSummaries(_ summaries: [TagSummary], format: OutputFormat) {
+ switch format {
+ case .standard:
+ guard !summaries.isEmpty else {
+ Swift.print("No tags found")
+ return
+ }
+ for summary in summaries.sorted(by: { $0.tag.localizedCaseInsensitiveCompare($1.tag) == .orderedAscending }) {
+ Swift.print("#\(summary.tag)\t\(summary.count)")
+ }
+ case .plain:
+ for summary in summaries.sorted(by: { $0.tag.localizedCaseInsensitiveCompare($1.tag) == .orderedAscending }) {
+ Swift.print("\(summary.tag)\t\(summary.count)")
+ }
+ case .json:
+ printJSON(summaries)
+ case .quiet:
+ Swift.print(summaries.count)
+ }
+ }
+
static func printDeleteResult(_ count: Int, format: OutputFormat) {
switch format {
case .standard:
@@ -107,7 +175,8 @@ enum OutputRenderer {
let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)"
let recurrence = recurrenceSuffix(for: reminder)
Swift.print(
- "[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)\(recurrence)")
+ "[\(index + 1)] [\(status)] \(displayTitle(for: reminder)) [\(reminder.listName)] — \(due)\(priority)\(recurrence)"
+ )
}
}
@@ -138,6 +207,17 @@ enum OutputRenderer {
].joined(separator: "\t")
}
+ private static func displayTitle(for reminder: ReminderItem) -> String {
+ let tags = reminder.tags.map { "#\($0)" }.joined(separator: " ")
+ if tags.isEmpty {
+ return reminder.titleWithoutTags
+ }
+ if reminder.titleWithoutTags.isEmpty {
+ return tags
+ }
+ return "\(reminder.titleWithoutTags) \(tags)"
+ }
+
private static func printListsStandard(_ summaries: [ListSummary]) {
guard !summaries.isEmpty else {
Swift.print("No reminder lists found")
diff --git a/Tests/RemindCoreTests/ReminderTagParsingTests.swift b/Tests/RemindCoreTests/ReminderTagParsingTests.swift
new file mode 100644
index 0000000..a55ceff
--- /dev/null
+++ b/Tests/RemindCoreTests/ReminderTagParsingTests.swift
@@ -0,0 +1,42 @@
+import Testing
+
+@testable import RemindCore
+
+@MainActor
+struct ReminderTagParsingTests {
+ @Test("Trailing hashtags are exposed as tags")
+ func trailingHashtagsAreTags() {
+ let reminder = ReminderItem(
+ id: "abc",
+ title: "Buy milk #shopping #urgent",
+ notes: nil,
+ isCompleted: false,
+ completionDate: nil,
+ priority: .none,
+ dueDate: nil,
+ listID: "list",
+ listName: "Inbox"
+ )
+
+ #expect(reminder.titleWithoutTags == "Buy milk")
+ #expect(reminder.tags == ["shopping", "urgent"])
+ }
+
+ @Test("Only trailing hashtags are parsed as tags")
+ func onlyTrailingHashtagsAreTags() {
+ let reminder = ReminderItem(
+ id: "abc",
+ title: "Discuss #hash syntax with team #work",
+ notes: nil,
+ isCompleted: false,
+ completionDate: nil,
+ priority: .none,
+ dueDate: nil,
+ listID: "list",
+ listName: "Inbox"
+ )
+
+ #expect(reminder.titleWithoutTags == "Discuss #hash syntax with team")
+ #expect(reminder.tags == ["work"])
+ }
+}
diff --git a/Tests/remindctlTests/TagCommandTests.swift b/Tests/remindctlTests/TagCommandTests.swift
new file mode 100644
index 0000000..f401fcf
--- /dev/null
+++ b/Tests/remindctlTests/TagCommandTests.swift
@@ -0,0 +1,43 @@
+import Testing
+
+@testable import remindctl
+
+@MainActor
+struct TagCommandTests {
+ @Test("Tag helpers parse, merge, and compose tags")
+ func tagHelpers() throws {
+ let tags = try CommandHelpers.parseTags(["shopping,urgent", "#Work"])
+ #expect(tags == ["shopping", "urgent", "Work"])
+
+ let parsed = CommandHelpers.parseTitleTags("Buy milk #shopping #urgent")
+ #expect(parsed.baseTitle == "Buy milk")
+ #expect(parsed.tags == ["shopping", "urgent"])
+
+ let merged = CommandHelpers.mergeTags(
+ existing: parsed.tags,
+ add: ["Work"],
+ remove: ["urgent"],
+ clear: false
+ )
+ #expect(merged == ["shopping", "Work"])
+ #expect(CommandHelpers.composeTitle(baseTitle: parsed.baseTitle, tags: merged) == "Buy milk #shopping #Work")
+ }
+
+ @Test("Help includes tag options")
+ func helpIncludesTagOptions() {
+ let rootHelp = HelpPrinter.renderRoot(
+ version: "0.0.0",
+ rootName: "remindctl",
+ commands: [ShowCommand.spec, TagsCommand.spec, AddCommand.spec, EditCommand.spec]
+ ).joined(separator: "\n")
+ let addHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: AddCommand.spec).joined(separator: "\n")
+ let editHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: EditCommand.spec).joined(separator: "\n")
+ let showHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: ShowCommand.spec).joined(separator: "\n")
+
+ #expect(rootHelp.contains("tags"))
+ #expect(addHelp.contains("--tag"))
+ #expect(editHelp.contains("--remove-tag"))
+ #expect(editHelp.contains("--clear-tags"))
+ #expect(showHelp.contains("--tag"))
+ }
+}