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
47 changes: 38 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,20 @@ ls -laS

$ maic show my primary local IP address
ipconfig getifaddr en0
```

With the zsh integration enabled, the suggested command is placed **on your prompt**
instead of just printed — ready to run, edit, or discard, exactly as if you'd typed it:

$ maic -r flush the DNS cache
sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder
Run this? [Y/n/e to edit]
```
$ maic flush the DNS cache
# …a beat while the on-device model thinks…
$ sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder█ ← now on your prompt
```

Press **Enter** to run it (it lands in your shell history like any command you typed),
edit it first, or **Ctrl-C** to discard. Nothing runs on its own.

Because it's told to assume the BSD userland that ships with macOS, it prefers
`stat -f`, `sed -i ''`, `pbcopy`, `mdfind`, `networksetup`, etc. — not GNU/Linux flags.

Expand All @@ -35,7 +43,8 @@ Because it's told to assume the BSD userland that ships with macOS, it prefers

## Install

One line — downloads the latest release, verifies its checksum, and installs to `~/.local/bin`:
One line — downloads the latest release, verifies its checksum, installs to `~/.local/bin`,
and enables the [zsh integration](#shell-integration-zsh) (set `MAIC_NO_SHELL_INIT=1` to skip that):

```
curl -fsSL https://raw.githubusercontent.com/emarref/maic/main/install.sh | bash
Expand Down Expand Up @@ -90,11 +99,31 @@ swift build -c release
## Usage

```
maic <what you want to do> print the command
maic -r <what you want to do> print it, then confirm before running
maic <what you want to do> suggest the command
maic --init zsh print the zsh integration (for ~/.zshrc)
maic -h help
```

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.
## Shell integration (zsh)

maic never runs anything on its own. Instead it hands you the command to run,
edit, or throw away. The `install.sh` one-liner wires this into your `~/.zshrc`
automatically; from a source build, add it yourself:

```
eval "$(maic --init zsh)"
```

Then `maic <task>` places the suggested command on your next prompt, editable and
cursor-ready. Press Enter to run it (it enters your shell history normally), tweak
it first, or Ctrl-C to discard.

Why a shell function rather than doing it all in the binary? A child process can't
type into its parent shell's line editor — so the command is handed back to zsh
(via `print -z`), which is also what makes it land in your history and lets `cd`
or `export` actually stick.

Skip the automatic setup with `MAIC_NO_SHELL_INIT=1` when installing. To remove
the integration later, delete the block between the `# >>> maic shell integration >>>`
and `# <<< maic shell integration <<<` markers in your `~/.zshrc`. Without the integration (or in a pipe, `$(…)`, or a
non-interactive shell) `maic` simply prints the command, so `x=$(maic …)` still works.
28 changes: 28 additions & 0 deletions Sources/MaicCore/CommandCleaning.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation

/// Strip anything the model may have wrapped the command in despite instructions:
/// fenced code blocks, a stray single-backtick wrap, or a leading shell prompt
/// (`$ ` / `% `). Returns the bare command, trimmed of surrounding whitespace.
public func cleanCommand(_ raw: String) -> String {
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)

// Remove a fenced code block if present.
if text.hasPrefix("```") {
var lines = text.components(separatedBy: "\n")
lines.removeFirst() // opening fence (possibly with a language tag)
if let last = lines.last, last.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
lines.removeLast()
}
text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
}

// Strip a stray single-backtick wrap or leading shell prompt.
if text.hasPrefix("`") && text.hasSuffix("`") && text.count > 1 {
text = String(text.dropFirst().dropLast())
}
for prefix in ["$ ", "% "] where text.hasPrefix(prefix) {
text = String(text.dropFirst(prefix.count))
}

return text.trimmingCharacters(in: .whitespacesAndNewlines)
}
25 changes: 0 additions & 25 deletions Sources/MaicCore/RunConfirmation.swift

This file was deleted.

30 changes: 30 additions & 0 deletions Sources/MaicCore/ShellInit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation

/// The zsh shell integration for maic, emitted by `maic --init zsh`.
///
/// A child process can't type into its parent shell's line editor, so the
/// "put the command on my prompt" behaviour has to live in the shell itself.
/// This function shadows the binary: it asks the binary for a command, then
/// uses zsh's buffer stack (`print -z`) to place that command on the next
/// prompt — editable, cursor-ready, nothing run until the user presses Enter.
///
/// Users wire it in with `eval "$(maic --init zsh)"` in their `~/.zshrc`.
///
/// A raw string literal keeps the shell's `$`, backticks and quotes verbatim.
public let zshShellInit: String = #"""
# maic shell integration (zsh). Enable with: eval "$(maic --init zsh)"
maic() {
emulate -L zsh
local __maic_cmd
__maic_cmd="$(command maic "$@")" || return
[[ -n $__maic_cmd ]] || return
# On an interactive prompt, drop the command onto the line editor so the user
# can edit or run it. Otherwise (piped, captured in $(...), non-interactive)
# just print it so `x=$(maic ...)` and pipelines keep working.
if [[ -o interactive && -t 1 ]]; then
print -rz -- "$__maic_cmd"
else
print -r -- "$__maic_cmd"
fi
}
"""#
105 changes: 38 additions & 67 deletions Sources/maic/Maic.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import MaicCore
// Describe a task in plain English; get back a single macOS-specific shell command.
//
// maic list files in this dir sorted by size, largest first
// maic -r flush the dns cache # -r prompts before running the command
//
// With the zsh integration enabled (`eval "$(maic --init zsh)"`), the command is
// placed on your prompt — editable, cursor-ready — instead of just printed, so you
// can run it (Enter), tweak it, or discard it (Ctrl-C). Nothing runs on its own.
//
// Nothing leaves the machine: this uses Apple's on-device FoundationModels.

Expand All @@ -33,7 +36,13 @@ struct Maic {
static func main() async {
var args = Array(CommandLine.arguments.dropFirst())

var runAfter = false
// `maic --init <shell>` prints the shell integration and exits. Handled
// before anything else so it never reaches the model. A leading `--init`
// is unambiguous — a plain-English task never starts with a `--` flag.
if args.first == "--init" {
printShellInit(for: args.count > 1 ? args[1] : "zsh")
}

var remaining: [String] = []
for arg in args {
switch arg {
Expand All @@ -44,7 +53,13 @@ struct Maic {
print("maic \(maicVersion)")
exit(0)
case "-r", "--run":
runAfter = true
// Deprecated no-op. maic no longer runs commands itself — the zsh
// integration puts the command on your prompt to run or edit. The
// flag is still swallowed so old muscle memory doesn't leak `-r`
// into the task text, but we say so rather than change behaviour
// silently.
FileHandle.standardError.write(Data(
"maic: -r/--run is deprecated and no longer runs the command; enable the zsh integration to run it from your prompt (see --help)\n".utf8))
default:
remaining.append(arg)
}
Expand Down Expand Up @@ -75,7 +90,7 @@ struct Maic {
let command: String
do {
let response = try await session.respond(to: query, options: options)
command = clean(response.content)
command = cleanCommand(response.content)
} catch {
FileHandle.standardError.write(Data("maic: generation failed — \(error.localizedDescription)\n".utf8))
exit(70) // EX_SOFTWARE
Expand All @@ -86,69 +101,21 @@ struct Maic {
exit(70)
}

// Print the command. When invoked through the zsh function the command is
// captured and placed on the prompt; run bare, it's just printed.
print(command)

guard runAfter else { return }

// 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 runConfirmation(for: answer) {
case .run:
break
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
case .abort:
exit(0)
}

exit(run(toRun))
}

/// Strip anything the model may have wrapped the command in despite instructions.
static func clean(_ raw: String) -> String {
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)

// Remove a fenced code block if present.
if text.hasPrefix("```") {
var lines = text.components(separatedBy: "\n")
lines.removeFirst() // opening fence (possibly with a language tag)
if let last = lines.last, last.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
lines.removeLast()
}
text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
}

// Strip a stray single-backtick wrap or leading shell prompt.
if text.hasPrefix("`") && text.hasSuffix("`") && text.count > 1 {
text = String(text.dropFirst().dropLast())
}
for prefix in ["$ ", "% "] where text.hasPrefix(prefix) {
text = String(text.dropFirst(prefix.count))
}

return text.trimmingCharacters(in: .whitespacesAndNewlines)
}

static func run(_ command: String) -> Int32 {
let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
let process = Process()
process.executableURL = URL(fileURLWithPath: shell)
process.arguments = ["-c", command]
do {
try process.run()
} catch {
FileHandle.standardError.write(Data("maic: failed to launch shell — \(error.localizedDescription)\n".utf8))
return 126
/// Emit shell integration for the named shell, then exit. Only zsh is supported.
static func printShellInit(for shell: String) -> Never {
switch shell {
case "zsh":
print(zshShellInit)
exit(0)
default:
FileHandle.standardError.write(Data("maic: --init supports only 'zsh' (got '\(shell)')\n".utf8))
exit(64) // EX_USAGE
}
process.waitUntilExit()
return process.terminationStatus
}

static func describe(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String {
Expand All @@ -171,18 +138,22 @@ struct Maic {

USAGE:
maic <what you want to do>
maic -r <what you want to do> confirm, then run the command

With the zsh integration enabled, the suggested command is placed on your
prompt — press Enter to run it, edit it first, or Ctrl-C to discard.
Enable it once by adding this to your ~/.zshrc:

eval "$(maic --init zsh)"

OPTIONS:
-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)
--init zsh print the zsh shell integration (for eval in ~/.zshrc)
-v, --version print the version and exit
-h, --help show this help

EXAMPLES:
maic list files sorted by size, largest first
maic show my local IP address
maic -r flush the DNS cache
maic flush the DNS cache
"""
print(usage)
}
Expand Down
54 changes: 34 additions & 20 deletions Tests/maicTests/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,47 @@ import MaicCore
var failures = 0

@MainActor
func check(_ input: String, _ expected: RunConfirmation, _ label: String) {
let got = runConfirmation(for: input)
func expect(_ got: String, _ expected: String, _ label: String) {
if got == expected {
print("ok - \(label)")
} else {
print("FAIL - \(label): runConfirmation(\"\(input)\") == \(got), expected \(expected)")
print("FAIL - \(label): got \"\(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")
@MainActor
func expect(_ cond: Bool, _ label: String) {
if cond {
print("ok - \(label)")
} else {
print("FAIL - \(label)")
failures += 1
}
}

// --- cleanCommand: the model's output is stripped back to a bare command ---

expect(cleanCommand("ls -laS"), "ls -laS", "plain command is unchanged")
expect(cleanCommand(" ls -laS "), "ls -laS", "surrounding whitespace trimmed")
expect(cleanCommand("$ ls -laS"), "ls -laS", "leading '$ ' prompt stripped")
expect(cleanCommand("% ls -laS"), "ls -laS", "leading '% ' prompt stripped")
expect(cleanCommand("`ls -laS`"), "ls -laS", "single-backtick wrap stripped")
expect(cleanCommand("```\nls -laS\n```"), "ls -laS", "bare fenced block stripped")
expect(cleanCommand("```sh\nls -laS\n```"), "ls -laS", "language-tagged fence stripped")
expect(cleanCommand("```bash\nfind . -type f -mtime +1\n```"),
"find . -type f -mtime +1", "fence around a realistic command")
expect(cleanCommand(""), "", "empty stays empty")
expect(cleanCommand(" "), "", "whitespace-only collapses to empty")
// A lone backtick is not a wrap and must survive (dropFirst/dropLast guard).
expect(cleanCommand("`"), "`", "a lone backtick is left alone")

// --- zshShellInit: the integration emitted by `maic --init zsh` -----------

expect(zshShellInit.contains("maic() {"), "init defines a maic() function")
expect(zshShellInit.contains("command maic"), "init calls the real binary via `command maic`")
expect(zshShellInit.contains("print -rz"), "init pushes the command onto the zle buffer")
expect(zshShellInit.contains("-t 1"), "init guards the interactive path on a TTY")

print("")
if failures > 0 {
Expand Down
5 changes: 5 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@ case ":$PATH:" in
*) echo "Note: $PREFIX is not on your PATH. Add this to ~/.zshrc:"
echo " export PATH=\"$PREFIX:\$PATH\"" ;;
esac

# The release installer (install.sh) wires the zsh integration into ~/.zshrc for
# you. From a source build, enable it yourself by adding this to ~/.zshrc:
echo 'For the "put the command on my prompt" behaviour, add to ~/.zshrc:'
echo ' eval "$(maic --init zsh)"'
Loading
Loading