From e49326c5583681610ab80e21cb1ffe0b52ad849a Mon Sep 17 00:00:00 2001 From: Malcolm Fell Date: Mon, 27 Jul 2026 18:11:56 +1200 Subject: [PATCH 1/2] Default -r confirmation to yes (Enter runs the command) In -r/--run mode the confirmation prompt now defaults to yes: pressing Enter (or y/yes) runs the command, n/no or anything unrecognised aborts, and e/edit still edits first. Prompt is now [Y/n/e to edit]. Extracts the pure answer->decision logic into a new MaicCore library (runConfirmation(for:)) so it is unit-testable, adds a swift-run test harness (swift test needs full Xcode; this runs under Command Line Tools and CI alike), wires it into CI, and updates --help and the README. Closes #1 --- .github/workflows/ci.yml | 3 ++ Package.swift | 15 ++++++++ README.md | 8 ++--- Sources/MaicCore/RunConfirmation.swift | 25 ++++++++++++++ Sources/maic/Maic.swift | 19 ++++++----- Tests/maicTests/main.swift | 47 ++++++++++++++++++++++++++ 6 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 Sources/MaicCore/RunConfirmation.swift create mode 100644 Tests/maicTests/main.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec36893..db12795 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,3 +28,6 @@ jobs: - name: Build (release) run: swift build -c release + + - name: Test + run: swift run maicTests diff --git a/Package.swift b/Package.swift index d565d0e..d0bc827 100644 --- a/Package.swift +++ b/Package.swift @@ -7,9 +7,24 @@ let package = Package( .macOS("26.0"), ], targets: [ + // Pure, I/O-free logic — importable and testable anywhere. + .target( + name: "MaicCore", + path: "Sources/MaicCore" + ), + // The CLI itself: argument handling, the on-device model, and I/O. .executableTarget( name: "maic", + dependencies: ["MaicCore"], path: "Sources/maic" ), + // Test harness, run with `swift run maicTests`. A plain executable + // rather than a testTarget: `swift test` needs full Xcode, but this + // runs under Command Line Tools and on CI alike. + .executableTarget( + name: "maicTests", + dependencies: ["MaicCore"], + path: "Tests/maicTests" + ), ] ) diff --git a/README.md b/README.md index 36f5487..d5c6c25 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ ipconfig getifaddr en0 $ maic -r flush the DNS cache sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder -Run this? [y/N/e to edit] +Run this? [Y/n/e to edit] ``` Because it's told to assume the BSD userland that ships with macOS, it prefers @@ -95,6 +95,6 @@ maic -r print it, then confirm before running maic -h help ``` -The `-r`/`--run` flow always confirms first (`y` to run, `e` to edit, anything else -to abort) — the model can be wrong or suggest something destructive, so nothing runs -without your say-so. +The `-r`/`--run` flow always confirms first: press Enter (or `y`) to run, `e` to +edit the command before running, or `n` to abort. The model can be wrong or suggest +something destructive, so it never runs without a confirmation step. diff --git a/Sources/MaicCore/RunConfirmation.swift b/Sources/MaicCore/RunConfirmation.swift new file mode 100644 index 0000000..8eb2117 --- /dev/null +++ b/Sources/MaicCore/RunConfirmation.swift @@ -0,0 +1,25 @@ +import Foundation + +/// What to do with the user's answer at the `-r` confirmation prompt. +public enum RunConfirmation: Equatable { + case run + case edit + case abort +} + +/// Interpret the answer typed at the `-r` prompt. +/// +/// Pressing Enter (empty input) defaults to running; `y`/`yes` run; +/// `e`/`edit` edits first; `n`/`no` and anything unrecognised decline +/// (so a stray keystroke never runs a command by accident). +/// Case-insensitive and whitespace-tolerant. +public func runConfirmation(for rawAnswer: String) -> RunConfirmation { + switch rawAnswer.trimmingCharacters(in: .whitespaces).lowercased() { + case "", "y", "yes": + return .run + case "e", "edit": + return .edit + default: + return .abort + } +} diff --git a/Sources/maic/Maic.swift b/Sources/maic/Maic.swift index 213a5cf..2ade77e 100644 --- a/Sources/maic/Maic.swift +++ b/Sources/maic/Maic.swift @@ -1,5 +1,6 @@ import Foundation import FoundationModels +import MaicCore // `maic` — a tiny local interface to the on-device model shipped with macOS. // Describe a task in plain English; get back a single macOS-specific shell command. @@ -89,20 +90,21 @@ struct Maic { guard runAfter else { return } - // A model can be wrong or dangerous — always confirm before executing. - FileHandle.standardError.write(Data("Run this? [y/N/e to edit] ".utf8)) - let answer = readLine(strippingNewline: true)?.lowercased() ?? "" + // A model can be wrong or dangerous, so we still confirm — but the + // common case is "yes, run it", so Enter defaults to running. + FileHandle.standardError.write(Data("Run this? [Y/n/e to edit] ".utf8)) + let answer = readLine(strippingNewline: true) ?? "" var toRun = command - switch answer { - case "y", "yes": + switch runConfirmation(for: answer) { + case .run: break - case "e", "edit": + case .edit: FileHandle.standardError.write(Data("Edit command: ".utf8)) let edited = readLine(strippingNewline: true)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !edited.isEmpty else { exit(0) } toRun = edited - default: + case .abort: exit(0) } @@ -172,7 +174,8 @@ struct Maic { maic -r confirm, then run the command OPTIONS: - -r, --run after printing the command, ask before executing it + -r, --run after printing the command, confirm then run it + (prompt is [Y/n/e to edit]; Enter or y runs, n aborts, e edits first) -v, --version print the version and exit -h, --help show this help diff --git a/Tests/maicTests/main.swift b/Tests/maicTests/main.swift new file mode 100644 index 0000000..93c0e53 --- /dev/null +++ b/Tests/maicTests/main.swift @@ -0,0 +1,47 @@ +import Foundation +import MaicCore + +// Test harness for MaicCore. Run with `swift run maicTests`. +// Exits non-zero if any case fails, so CI can gate on it. +// +// A plain executable is used instead of a `testTarget` because `swift test` +// requires a full Xcode install to execute; this runs under the Command Line +// Tools toolchain too. + +var failures = 0 + +@MainActor +func check(_ input: String, _ expected: RunConfirmation, _ label: String) { + let got = runConfirmation(for: input) + if got == expected { + print("ok - \(label)") + } else { + print("FAIL - \(label): runConfirmation(\"\(input)\") == \(got), expected \(expected)") + failures += 1 + } +} + +// Acceptance criteria for issue #1. +check("", .run, "empty input (Enter) defaults to run") +check("y", .run, "y runs") +check("yes", .run, "yes runs") +check("n", .abort, "n aborts") +check("no", .abort, "no aborts") +check("e", .edit, "e edits") +check("edit", .edit, "edit edits") +check("maybe", .abort, "unrecognised input aborts") +check("q", .abort, "unrecognised input aborts") + +// Case-insensitive and whitespace-tolerant. +check("Y", .run, "uppercase Y runs") +check("YES", .run, "uppercase YES runs") +check(" ", .run, "whitespace-only == empty == run") +check(" no ", .abort, "surrounding whitespace tolerated for no") +check("Edit", .edit, "mixed-case Edit edits") + +print("") +if failures > 0 { + print("\(failures) test(s) FAILED") + exit(1) +} +print("all tests passed") From 610efe61aff3c8cd8f1c0b2cb821f0b7516b0f0b Mon Sep 17 00:00:00 2001 From: Malcolm Fell Date: Tue, 28 Jul 2026 08:14:28 +1200 Subject: [PATCH 2/2] ci: gate releases on the test suite too --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 970dc1b..5cd9ec3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,9 @@ jobs: - name: Build (release, arm64) run: swift build -c release + - name: Test + run: swift run maicTests + - name: Ad-hoc sign run: codesign -s - --force .build/release/maic