diff --git a/CHANGELOG.md b/CHANGELOG.md index 35ef32d..6d41b0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Reject surplus positional arguments instead of silently discarding them, using Commander 0.3.0 for strict command validation. + - Keep numeric reminder indexes stable across reordered EventKit fetches by breaking equal due-date/title sort ties with the reminder ID. - Reject impossible ISO dates, malformed offsets, and trailing date text; select explicit field order so day-first inputs cannot silently become another date while retaining unambiguous legacy spellings. - Handle CRLF as a complete newline when removing managed URL notes and exporting CSV, preventing stale URL mirrors, split CSV records, and missed formula prefixes. diff --git a/Package.resolved b/Package.resolved index 5f533dc..45a958f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "d1cb13c1640dc8cb50d58a7784a7c829ba3087e0652eaa17b387fd44e2e845db", + "originHash" : "f1b9f6633dcdb2bc5c64181065ee84f01f0af378e6234f652ab5db8f6c627977", "pins" : [ { "identity" : "commander", "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/Commander.git", "state" : { - "revision" : "bd219c4ee9032fee3e009856f81fcc6ec09a85f4", - "version" : "0.2.4" + "revision" : "3f5a3cb1f19ce7ca79fdc2111a09dfb3da200c1c", + "version" : "0.3.0" } } ], diff --git a/Package.swift b/Package.swift index b7f5e53..ba30846 100644 --- a/Package.swift +++ b/Package.swift @@ -9,7 +9,7 @@ let package = Package( .executable(name: "remindctl", targets: ["remindctl"]), ], dependencies: [ - .package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"), + .package(url: "https://github.com/steipete/Commander.git", from: "0.3.0"), ], targets: [ .target( diff --git a/README.md b/README.md index ea0f84f..4d96048 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Indexes such as `1` come from the default reminder listing. Most commands also a | `remindctl status` | Show Reminders permission status | | `remindctl authorize` | Request Reminders permission when macOS allows it | -Run `remindctl --help` for the full option list. +Run `remindctl --help` for the full option list. Unexpected extra arguments are rejected; quote multi-word titles and list names. ## Showing Reminders diff --git a/Sources/remindctl/CommandRouter.swift b/Sources/remindctl/CommandRouter.swift index 05edb0a..b5cfeb3 100644 --- a/Sources/remindctl/CommandRouter.swift +++ b/Sources/remindctl/CommandRouter.swift @@ -45,8 +45,7 @@ struct CommandRouter { } func run(argv: [String]) async -> Int32 { - var argv = normalizeArguments(argv) - argv = applyAliases(argv) + var argv = applyAliases(argv) let controlArguments = argv.prefix { $0 != "--" } if controlArguments.contains("--version") || controlArguments.contains("-V") { @@ -62,7 +61,7 @@ struct CommandRouter { argv = rewriteImplicitShow(argv) do { - let invocation = try program.resolve(argv: argv) + let invocation = try program.resolve(arguments: [rootName] + argv.dropFirst()) guard let commandName = invocation.path.last, let spec = specs.first(where: { $0.name == commandName }) else { @@ -91,13 +90,6 @@ struct CommandRouter { } } - private func normalizeArguments(_ argv: [String]) -> [String] { - guard !argv.isEmpty else { return argv } - var copy = argv - copy[0] = rootName - return copy - } - private func applyAliases(_ argv: [String]) -> [String] { guard argv.count >= 2 else { return argv } var copy = argv diff --git a/Sources/remindctl/Commands/CompleteCommand.swift b/Sources/remindctl/Commands/CompleteCommand.swift index 5c8c7f3..25adb6a 100644 --- a/Sources/remindctl/Commands/CompleteCommand.swift +++ b/Sources/remindctl/Commands/CompleteCommand.swift @@ -11,7 +11,7 @@ enum CompleteCommand { signature: CommandSignatures.withRuntimeFlags( CommandSignature( arguments: [ - .make(label: "ids", help: "Indexes or ID prefixes", isOptional: true) + .make(label: "ids", help: "Indexes or ID prefixes", isOptional: true, parsing: .remaining) ], flags: [ .make(label: "dryRun", names: [.short("n"), .long("dry-run")], help: "Preview without changes") diff --git a/Sources/remindctl/Commands/DeleteCommand.swift b/Sources/remindctl/Commands/DeleteCommand.swift index 2c5ba24..1f20ec9 100644 --- a/Sources/remindctl/Commands/DeleteCommand.swift +++ b/Sources/remindctl/Commands/DeleteCommand.swift @@ -11,7 +11,7 @@ enum DeleteCommand { signature: CommandSignatures.withRuntimeFlags( CommandSignature( arguments: [ - .make(label: "ids", help: "Indexes or ID prefixes", isOptional: true) + .make(label: "ids", help: "Indexes or ID prefixes", isOptional: true, parsing: .remaining) ], flags: [ .make(label: "dryRun", names: [.short("n"), .long("dry-run")], help: "Preview without changes"), diff --git a/Sources/remindctl/Commands/ListCommand.swift b/Sources/remindctl/Commands/ListCommand.swift index 0decd01..1d600f6 100644 --- a/Sources/remindctl/Commands/ListCommand.swift +++ b/Sources/remindctl/Commands/ListCommand.swift @@ -18,7 +18,7 @@ enum ListCommand { signature: CommandSignatures.withRuntimeFlags( CommandSignature( arguments: [ - .make(label: "name", help: "List name(s)", isOptional: true) + .make(label: "name", help: "List name(s)", isOptional: true, parsing: .remaining) ], options: [ .make( diff --git a/Tests/remindctlTests/CommandRouterTests.swift b/Tests/remindctlTests/CommandRouterTests.swift index 9dfd23f..eab771b 100644 --- a/Tests/remindctlTests/CommandRouterTests.swift +++ b/Tests/remindctlTests/CommandRouterTests.swift @@ -3,6 +3,29 @@ import Testing @testable import remindctl struct CommandRouterTests { + @Test("Surplus command arguments are rejected") + func surplusArguments() async { + let code = await CommandRouter().run(argv: ["remindctl", "completion", "bash", "unexpected"]) + #expect(code == 1) + } + + @Test("Bulk commands preserve all positional values", arguments: ["list", "complete", "delete"]) + func bulkArguments(_ command: String) throws { + let invocation = try CommandRouter().program.resolve(arguments: [ + "remindctl", command, "First", "Second", "--plain", + ]) + #expect(invocation.parsedValues.positional == ["First", "Second"]) + #expect(invocation.parsedValues.flags.contains("plainOutput")) + } + + @Test( + "Single-target commands reject extra positionals", arguments: ["add", "edit", "search", "info", "link", "open"]) + func singleTargetArguments(_ command: String) { + #expect(throws: (any Error).self) { + try CommandRouter().program.resolve(arguments: ["remindctl", command, "First", "Second"]) + } + } + @Test("A renamed executable resolves the canonical command") func renamedExecutable() async { let code = await CommandRouter().run(argv: ["/tmp/custom-reminders", "completion", "bash"]) diff --git a/Tests/remindctlTests/ListActionTests.swift b/Tests/remindctlTests/ListActionTests.swift index 4745893..2ddf1a4 100644 --- a/Tests/remindctlTests/ListActionTests.swift +++ b/Tests/remindctlTests/ListActionTests.swift @@ -5,7 +5,7 @@ import Testing struct ListActionTests { private func action(_ arguments: [String]) throws -> ListCommand.Action { - let values = try CommandRouter().program.resolve(argv: ["remindctl", "list"] + arguments).parsedValues + let values = try CommandRouter().program.resolve(arguments: ["remindctl", "list"] + arguments).parsedValues return try ListCommand.action( names: values.positional, listID: values.option("listID"),