diff --git a/README.md b/README.md index d5c6c25..2796789 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -90,11 +99,31 @@ swift build -c release ## Usage ``` -maic print the command -maic -r print it, then confirm before running +maic 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 ` 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. diff --git a/Sources/MaicCore/CommandCleaning.swift b/Sources/MaicCore/CommandCleaning.swift new file mode 100644 index 0000000..3be0c5c --- /dev/null +++ b/Sources/MaicCore/CommandCleaning.swift @@ -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) +} diff --git a/Sources/MaicCore/RunConfirmation.swift b/Sources/MaicCore/RunConfirmation.swift deleted file mode 100644 index 8eb2117..0000000 --- a/Sources/MaicCore/RunConfirmation.swift +++ /dev/null @@ -1,25 +0,0 @@ -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/MaicCore/ShellInit.swift b/Sources/MaicCore/ShellInit.swift new file mode 100644 index 0000000..ed57a85 --- /dev/null +++ b/Sources/MaicCore/ShellInit.swift @@ -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 +} +"""# diff --git a/Sources/maic/Maic.swift b/Sources/maic/Maic.swift index 2ade77e..7f74f0a 100644 --- a/Sources/maic/Maic.swift +++ b/Sources/maic/Maic.swift @@ -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. @@ -33,7 +36,13 @@ struct Maic { static func main() async { var args = Array(CommandLine.arguments.dropFirst()) - var runAfter = false + // `maic --init ` 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 { @@ -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) } @@ -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 @@ -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 { @@ -171,18 +138,22 @@ struct Maic { USAGE: maic - maic -r 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) } diff --git a/Tests/maicTests/main.swift b/Tests/maicTests/main.swift index 93c0e53..039367c 100644 --- a/Tests/maicTests/main.swift +++ b/Tests/maicTests/main.swift @@ -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 { diff --git a/build.sh b/build.sh index 6cb9f06..a8e776a 100755 --- a/build.sh +++ b/build.sh @@ -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)"' diff --git a/install.sh b/install.sh index b2aac60..83c9e7e 100755 --- a/install.sh +++ b/install.sh @@ -70,8 +70,50 @@ install -m 0755 "$binsrc" "$PREFIX/maic" ver="$("$PREFIX/maic" --version 2>/dev/null || true)" echo "Installed: $PREFIX/maic${ver:+ ($ver)}" >&2 +# --- shell integration ----------------------------------------------------- +# maic places the suggested command on your zsh prompt (editable; nothing runs +# until you press Enter) via a small function. Wire it into ~/.zshrc, idempotently. +# Set MAIC_NO_SHELL_INIT=1 to skip this and get the manual instructions instead. +rc="${ZDOTDIR:-$HOME}/.zshrc" +begin="# >>> maic shell integration >>>" +end="# <<< maic shell integration <<<" + +# The managed block prepends PREFIX to PATH only when it isn't already there, +# so the `eval` line can find `maic` at shell startup. +path_line="" case ":$PATH:" in *":$PREFIX:"*) ;; - *) echo "Note: $PREFIX is not on your PATH. Add this to ~/.zshrc:" >&2 - echo " export PATH=\"$PREFIX:\$PATH\"" >&2 ;; + *) path_line="export PATH=\"$PREFIX:\$PATH\"" ;; esac + +if [ -n "${MAIC_NO_SHELL_INIT:-}" ]; then + echo "Skipping shell integration (MAIC_NO_SHELL_INIT set). To enable it, add to $rc:" >&2 + [ -n "$path_line" ] && echo " $path_line" >&2 + echo ' eval "$(maic --init zsh)"' >&2 +else + block="$begin" + [ -n "$path_line" ] && block="$block +$path_line" + block="$block +eval \"\$(maic --init zsh)\" +$end" + + touch "$rc" + if grep -qF "$begin" "$rc"; then + # Drop any existing managed block first, so re-running stays idempotent. + awk -v b="$begin" -v e="$end" ' + $0==b {skip=1} + skip {if ($0==e) skip=0; next} + {print} + ' "$rc" > "$rc.maic.tmp" && mv "$rc.maic.tmp" "$rc" + fi + # Ensure a separating newline so the marker never fuses onto the user's last + # line (a ~/.zshrc isn't guaranteed to end in a newline). + if [ -s "$rc" ] && [ -n "$(tail -c1 "$rc")" ]; then + printf '\n' >> "$rc" + fi + printf '%s\n' "$block" >> "$rc" + echo "Enabled maic zsh integration in $rc." >&2 + echo "Start a new shell (or run: source $rc) to use it." >&2 + echo "To remove it, delete the block between the '$begin' / '$end' markers." >&2 +fi