diff --git a/.gitignore b/.gitignore
index 9bf2602..a085e89 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,3 @@ Package.resolved
.DS_Store
*.swp
*.swo
-
-# CLI wrapper (generated by build.sh)
-/macnotifier
diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns
new file mode 100644
index 0000000..5c1dd2a
Binary files /dev/null and b/Resources/AppIcon.icns differ
diff --git a/Resources/icon.svg b/Resources/icon.svg
new file mode 100644
index 0000000..e3b7592
--- /dev/null
+++ b/Resources/icon.svg
@@ -0,0 +1,13 @@
+
diff --git a/bin/macnotifier b/bin/macnotifier
new file mode 100755
index 0000000..c4e387b
--- /dev/null
+++ b/bin/macnotifier
@@ -0,0 +1,84 @@
+#!/bin/bash
+set -euo pipefail
+
+# Resolve symlinks so the script works when invoked via symlink (e.g. Homebrew).
+SELF="$0"
+if [ -L "$SELF" ]; then
+ TARGET="$(readlink "$SELF")"
+ case "$TARGET" in
+ /*) SELF="$TARGET" ;;
+ *) SELF="$(dirname "$SELF")/$TARGET" ;;
+ esac
+fi
+SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
+APP_BUNDLE="$(cd "$SCRIPT_DIR/.." && pwd)/macnotifier.app"
+
+# Handle -h/--help directly so output is visible in the terminal.
+# open -n does not connect the app's stdout/stderr to the terminal.
+for arg in "$@"; do
+ case "$arg" in
+ -h|--help)
+ cat <<'USAGE'
+Usage: macnotifier [options]
+
+Options:
+ -t, --title
Notification title (default: "macnotifier")
+ -m, --message Notification message (required)
+ -e, --execute Shell command to execute on click
+ -a, --activate Bundle ID of app to activate on click
+ --sound Sound name in ~/Library/Sounds or /System/Library/Sounds (e.g. "Glass")
+ --icon Path to image file to attach as icon
+ -h, --help Show this help message
+USAGE
+ exit 0
+ ;;
+ esac
+done
+
+# Validate -m is present with a value before launching the app.
+has_message=false
+expect_value=false
+for arg in "$@"; do
+ if [ "$expect_value" = true ]; then
+ has_message=true
+ break
+ fi
+ case "$arg" in
+ -m|--message) expect_value=true ;;
+ esac
+done
+
+if [ "$expect_value" = true ] && [ "$has_message" = false ]; then
+ echo "Error: -m requires a value" >&2
+ exit 1
+fi
+
+if [ "$has_message" = false ]; then
+ echo "Error: -m (message) is required" >&2
+ exit 1
+fi
+
+if [ ! -d "$APP_BUNDLE" ]; then
+ echo "Error: $APP_BUNDLE not found (run scripts/build.sh first)" >&2
+ exit 1
+fi
+
+# Convert --icon relative paths to absolute paths.
+# open -n changes the working directory, so relative paths would break.
+args=()
+while [ $# -gt 0 ]; do
+ if [ "$1" = "--icon" ] && [ $# -ge 2 ]; then
+ args+=("$1")
+ shift
+ case "$1" in
+ /*) args+=("$1") ;; # absolute path: pass through
+ ~*) args+=("$1") ;; # tilde path: Swift expands it
+ *) args+=("$PWD/$1") ;; # relative path: convert to absolute
+ esac
+ else
+ args+=("$1")
+ fi
+ shift
+done
+
+open -n "$APP_BUNDLE" --args "${args[@]}"
diff --git a/scripts/build.sh b/scripts/build.sh
index 7685f64..fadafbd 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -32,6 +32,8 @@ cat > "$APP_BUNDLE/Contents/Info.plist" <1.0
CFBundlePackageType
APPL
+ CFBundleIconFile
+ AppIcon
LSUIElement
diff --git a/scripts/test.sh b/scripts/test.sh
index ba7e8c5..04d1149 100755
--- a/scripts/test.sh
+++ b/scripts/test.sh
@@ -84,6 +84,69 @@ run_test_output "-h shows --sound option" 0 "--sound" -h
# Test: -h shows --icon option
run_test_output "-h shows --icon option" 0 "--icon" -h
+# CLI wrapper tests
+WRAPPER="$PROJECT_DIR/bin/macnotifier"
+
+run_wrapper_test() {
+ local name="$1"
+ local expected_exit="$2"
+ local expected_pattern="$3"
+ shift 3
+
+ set +e
+ local output
+ output=$("$WRAPPER" "$@" 2>&1)
+ local actual_exit=$?
+ set -e
+
+ if [ "$actual_exit" -ne "$expected_exit" ]; then
+ echo "FAIL: $name (expected exit $expected_exit, got $actual_exit)"
+ failed=$((failed + 1))
+ return
+ fi
+
+ if echo "$output" | grep -qi -- "$expected_pattern"; then
+ echo "PASS: $name"
+ passed=$((passed + 1))
+ else
+ echo "FAIL: $name (output missing '$expected_pattern')"
+ failed=$((failed + 1))
+ fi
+}
+
+if [ ! -x "$WRAPPER" ]; then
+ echo "FAIL: CLI wrapper not found or not executable at $WRAPPER"
+ failed=$((failed + 1))
+else
+ echo "PASS: CLI wrapper exists and is executable"
+ passed=$((passed + 1))
+
+ # Test: wrapper -h shows usage with "Usage" in output
+ run_wrapper_test "wrapper -h shows help" 0 "Usage" -h
+
+ # Test: wrapper without -m shows error
+ run_wrapper_test "wrapper missing -m exits 1" 1 "required"
+
+ # Test: wrapper -m without value shows error
+ run_wrapper_test "wrapper -m without value exits 1" 1 "requires" -m
+
+ # Test: wrapper works via symlink
+ SYMLINK_DIR="$(mktemp -d)"
+ ln -s "$WRAPPER" "$SYMLINK_DIR/macnotifier"
+ set +e
+ symlink_output=$("$SYMLINK_DIR/macnotifier" -h 2>&1)
+ symlink_exit=$?
+ set -e
+ rm -rf "$SYMLINK_DIR"
+ if [ "$symlink_exit" -eq 0 ] && echo "$symlink_output" | grep -qi "Usage"; then
+ echo "PASS: wrapper works via symlink"
+ passed=$((passed + 1))
+ else
+ echo "FAIL: wrapper via symlink (exit $symlink_exit)"
+ failed=$((failed + 1))
+ fi
+fi
+
echo ""
echo "Results: $passed passed, $failed failed"