diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift
index 5f4fe90..0919beb 100644
--- a/Sources/RemindCore/Models.swift
+++ b/Sources/RemindCore/Models.swift
@@ -75,6 +75,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 571bc25..7ddf8af 100644
--- a/Sources/remindctl/Commands/AddCommand.swift
+++ b/Sources/remindctl/Commands/AddCommand.swift
@@ -18,6 +18,12 @@ enum AddCommand {
.make(label: "list", names: [.short("l"), .long("list")], help: "List name", parsing: .singleValue),
.make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue),
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", 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")],
@@ -31,6 +37,8 @@ enum AddCommand {
"remindctl add \"Buy milk\"",
"remindctl add --title \"Call mom\" --list Personal --due tomorrow",
"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")
@@ -56,9 +64,14 @@ enum AddCommand {
let notes = values.option("notes")
let dueValue = values.option("due")
let priorityValue = values.option("priority")
+ let tagValues = values.optionValues("tag")
let dueDate = try dueValue.map(CommandHelpers.parseDueDate)
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()
@@ -73,7 +86,7 @@ enum AddCommand {
throw RemindCoreError.operationFailed("No default list found. Specify --list.")
}
- let draft = ReminderDraft(title: title, notes: notes, dueDate: dueDate, priority: priority)
+ let draft = ReminderDraft(title: titleWithTags, notes: notes, dueDate: dueDate, priority: priority)
let reminder = try await store.createReminder(draft, listName: targetList)
OutputRenderer.printReminder(reminder, format: runtime.outputFormat)
}
diff --git a/Sources/remindctl/Commands/EditCommand.swift b/Sources/remindctl/Commands/EditCommand.swift
index 75a3c31..2b5754a 100644
--- a/Sources/remindctl/Commands/EditCommand.swift
+++ b/Sources/remindctl/Commands/EditCommand.swift
@@ -18,6 +18,18 @@ enum EditCommand {
.make(label: "list", names: [.short("l"), .long("list")], help: "Move to list", parsing: .singleValue),
.make(label: "due", names: [.short("d"), .long("due")], help: "Set due date", parsing: .singleValue),
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Set notes", 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")],
@@ -27,6 +39,7 @@ enum EditCommand {
],
flags: [
.make(label: "clearDue", names: [.long("clear-due")], help: "Clear due date"),
+ .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"),
]
@@ -37,6 +50,8 @@ enum EditCommand {
"remindctl edit 4A83 --due tomorrow",
"remindctl edit 2 --priority high --notes \"Call before noon\"",
"remindctl edit 3 --clear-due",
+ "remindctl edit 1 --tag urgent --remove-tag someday",
+ "remindctl edit 1 --clear-tags",
]
) { values, runtime in
guard let input = values.argument(0) else {
@@ -51,9 +66,25 @@ 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 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: Date??
if let dueValue = values.option("due") {
@@ -78,7 +109,9 @@ enum EditCommand {
}
let isCompleted: Bool? = completeFlag ? true : (incompleteFlag ? false : nil)
- if title == nil && listName == nil && notes == nil && dueUpdate == nil && priority == nil && isCompleted == nil {
+ if title == nil && listName == nil && notes == nil && dueUpdate == nil && priority == nil
+ && isCompleted == nil && !hasTagChange
+ {
throw RemindCoreError.operationFailed("No changes specified")
}
diff --git a/Sources/remindctl/Commands/ShowCommand.swift b/Sources/remindctl/Commands/ShowCommand.swift
index c484634..68fe879 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 ee85c61..a98ad61 100644
--- a/Sources/remindctl/OutputFormatting.swift
+++ b/Sources/remindctl/OutputFormatting.swift
@@ -20,6 +20,39 @@ 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 isCompleted: Bool
+ let completionDate: Date?
+ let priority: ReminderPriority
+ let dueDate: Date?
+ let listID: String
+ let listName: String
+
+ init(reminder: ReminderItem) {
+ id = reminder.id
+ title = reminder.title
+ titleWithoutTags = reminder.titleWithoutTags
+ tags = reminder.tags
+ notes = reminder.notes
+ isCompleted = reminder.isCompleted
+ completionDate = reminder.completionDate
+ priority = reminder.priority
+ dueDate = reminder.dueDate
+ listID = reminder.listID
+ listName = reminder.listName
+ }
+}
+
enum OutputRenderer {
static func printReminders(_ reminders: [ReminderItem], format: OutputFormat) {
switch format {
@@ -28,7 +61,7 @@ enum OutputRenderer {
case .plain:
printRemindersPlain(reminders)
case .json:
- printJSON(reminders)
+ printJSON(reminders.map(ReminderOutput.init))
case .quiet:
Swift.print(reminders.count)
}
@@ -51,16 +84,37 @@ enum OutputRenderer {
switch format {
case .standard:
let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date"
- Swift.print("✓ \(reminder.title) [\(reminder.listName)] — \(due)")
+ Swift.print("✓ \(displayTitle(for: reminder)) [\(reminder.listName)] — \(due)")
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:
@@ -98,7 +152,7 @@ enum OutputRenderer {
let status = reminder.isCompleted ? "x" : " "
let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date"
let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)"
- Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)")
+ Swift.print("[\(index + 1)] [\(status)] \(displayTitle(for: reminder)) [\(reminder.listName)] — \(due)\(priority)")
}
}
@@ -121,6 +175,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")