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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ jobs:

- name: Build (release)
run: swift build -c release

- name: Test
run: swift run maicTests
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
),
]
)
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -95,6 +95,6 @@ maic -r <what you want to do> 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.
25 changes: 25 additions & 0 deletions Sources/MaicCore/RunConfirmation.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
19 changes: 11 additions & 8 deletions Sources/maic/Maic.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -172,7 +174,8 @@ struct Maic {
maic -r <what you want to do> 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

Expand Down
47 changes: 47 additions & 0 deletions Tests/maicTests/main.swift
Original file line number Diff line number Diff line change
@@ -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")
Loading