diff --git a/.vscode/settings.json b/.vscode/settings.json
index 5e2c64fa..f5ac6a66 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -80,6 +80,7 @@
"Laomedeia",
"ldflags",
"libexec",
+ "libnotify",
"listcontexts",
"listextensions",
"listmodels",
@@ -93,6 +94,7 @@
"matplotlib",
"mattn",
"mbed",
+ "metacharacters",
"Miessler",
"nometa",
"numpy",
@@ -102,6 +104,7 @@
"opencode",
"openrouter",
"Orus",
+ "osascript",
"otiai",
"pdflatex",
"pipx",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa1ba4c0..a97bd9f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
# Changelog
+## v1.4.276 (2025-08-08)
+
+### Direct commits
+
+- Ci: add write permissions to update_release_notes job
+
+- Add contents write permission to release notes job
+
+- Enable GitHub Actions to modify repository contents
+- Fix potential permission issues during release process
+
## v1.4.275 (2025-08-07)
### PR [#1676](https://github.com/danielmiessler/Fabric/pull/1676) by [ksylvan](https://github.com/ksylvan): Refactor authentication to support GITHUB_TOKEN and GH_TOKEN
diff --git a/README.md b/README.md
index 65dd7941..4977026c 100644
--- a/README.md
+++ b/README.md
@@ -551,6 +551,9 @@ Application Options:
--voice= TTS voice name for supported models (e.g., Kore, Charon, Puck)
(default: Kore)
--list-gemini-voices List all available Gemini TTS voices
+ --notification Send desktop notification when command completes
+ --notification-command= Custom command to run for notifications (overrides built-in
+ notifications)
Help Options:
-h, --help Show this help message
diff --git a/cmd/fabric/version.go b/cmd/fabric/version.go
index 3c391725..1bfee2ac 100644
--- a/cmd/fabric/version.go
+++ b/cmd/fabric/version.go
@@ -1,3 +1,3 @@
package main
-var version = "v1.4.275"
+var version = "v1.4.276"
diff --git a/cmd/generate_changelog/changelog.db b/cmd/generate_changelog/changelog.db
index f595ba6f..3c17b90f 100644
Binary files a/cmd/generate_changelog/changelog.db and b/cmd/generate_changelog/changelog.db differ
diff --git a/cmd/generate_changelog/incoming/1679.txt b/cmd/generate_changelog/incoming/1679.txt
new file mode 100644
index 00000000..9d6b78ef
--- /dev/null
+++ b/cmd/generate_changelog/incoming/1679.txt
@@ -0,0 +1,7 @@
+### PR [#1679](https://github.com/danielmiessler/Fabric/pull/1679) by [ksylvan](https://github.com/ksylvan): Add cross-platform desktop notifications to Fabric CLI
+
+- Add desktop notification support with cross-platform providers for macOS, Linux, and Windows
+- Implement notification manager with fallback provider detection and configuration options
+- Integrate notification sending into chat processing workflow with CLI interface flags
+- Fix security vulnerabilities by hardening notification commands to prevent shell and script injection attacks
+- Improve Unicode handling and environment variable inheritance in notification providers
diff --git a/completions/_fabric b/completions/_fabric
index e92fec7e..fa56c9f0 100644
--- a/completions/_fabric
+++ b/completions/_fabric
@@ -117,6 +117,8 @@ _fabric() {
'(--think-start-tag)--think-start-tag[Start tag for thinking sections (default: )]:start tag:' \
'(--think-end-tag)--think-end-tag[End tag for thinking sections (default: )]:end tag:' \
'(--disable-responses-api)--disable-responses-api[Disable OpenAI Responses API (default: false)]' \
+ '(--notification)--notification[Send desktop notification when command completes]' \
+ '(--notification-command)--notification-command[Custom command to run for notifications]:notification command:' \
'(-h --help)'{-h,--help}'[Show this help message]' \
'*:arguments:'
}
diff --git a/completions/fabric.bash b/completions/fabric.bash
index 1acbc8db..afeb2c5c 100644
--- a/completions/fabric.bash
+++ b/completions/fabric.bash
@@ -13,7 +13,7 @@ _fabric() {
_get_comp_words_by_ref -n : cur prev words cword
# Define all possible options/flags
- local opts="--pattern -p --variable -v --context -C --session --attachment -a --setup -S --temperature -t --topp -T --stream -s --presencepenalty -P --raw -r --frequencypenalty -F --listpatterns -l --listmodels -L --listcontexts -x --listsessions -X --updatepatterns -U --copy -c --model -m --modelContextLength --output -o --output-session --latest -n --changeDefaultModel -d --youtube -y --playlist --transcript --transcript-with-timestamps --comments --metadata --language -g --scrape_url -u --scrape_question -q --seed -e --wipecontext -w --wipesession -W --printcontext --printsession --readability --input-has-vars --dry-run --serve --serveOllama --address --api-key --config --search --search-location --image-file --image-size --image-quality --image-compression --image-background --suppress-think --think-start-tag --think-end-tag --disable-responses-api --voice --list-gemini-voices --version --listextensions --addextension --rmextension --strategy --liststrategies --listvendors --shell-complete-list --help -h"
+ local opts="--pattern -p --variable -v --context -C --session --attachment -a --setup -S --temperature -t --topp -T --stream -s --presencepenalty -P --raw -r --frequencypenalty -F --listpatterns -l --listmodels -L --listcontexts -x --listsessions -X --updatepatterns -U --copy -c --model -m --modelContextLength --output -o --output-session --latest -n --changeDefaultModel -d --youtube -y --playlist --transcript --transcript-with-timestamps --comments --metadata --language -g --scrape_url -u --scrape_question -q --seed -e --wipecontext -w --wipesession -W --printcontext --printsession --readability --input-has-vars --dry-run --serve --serveOllama --address --api-key --config --search --search-location --image-file --image-size --image-quality --image-compression --image-background --suppress-think --think-start-tag --think-end-tag --disable-responses-api --voice --list-gemini-voices --notification --notification-command --version --listextensions --addextension --rmextension --strategy --liststrategies --listvendors --shell-complete-list --help -h"
# Helper function for dynamic completions
_fabric_get_list() {
@@ -85,7 +85,7 @@ _fabric() {
return 0
;;
# Options requiring simple arguments (no specific completion logic here)
- -v | --variable | -t | --temperature | -T | --topp | -P | --presencepenalty | -F | --frequencypenalty | --modelContextLength | -n | --latest | -y | --youtube | -g | --language | -u | --scrape_url | -q | --scrape_question | -e | --seed | --address | --api-key | --search-location | --image-compression | --think-start-tag | --think-end-tag)
+ -v | --variable | -t | --temperature | -T | --topp | -P | --presencepenalty | -F | --frequencypenalty | --modelContextLength | -n | --latest | -y | --youtube | -g | --language | -u | --scrape_url | -q | --scrape_question | -e | --seed | --address | --api-key | --search-location | --image-compression | --think-start-tag | --think-end-tag | --notification-command)
# No specific completion suggestions, user types the value
return 0
;;
diff --git a/completions/fabric.fish b/completions/fabric.fish
index 24ae5afc..eaaeaec5 100755
--- a/completions/fabric.fish
+++ b/completions/fabric.fish
@@ -76,6 +76,7 @@ complete -c fabric -l strategy -d "Choose a strategy from the available strategi
complete -c fabric -l think-start-tag -d "Start tag for thinking sections (default: )"
complete -c fabric -l think-end-tag -d "End tag for thinking sections (default: )"
complete -c fabric -l voice -d "TTS voice name for supported models (e.g., Kore, Charon, Puck)" -a "(__fabric_get_gemini_voices)"
+complete -c fabric -l notification-command -d "Custom command to run for notifications (overrides built-in notifications)"
# Boolean flags (no arguments)
complete -c fabric -s S -l setup -d "Run setup for all reconfigurable parts of fabric"
@@ -108,4 +109,5 @@ complete -c fabric -l list-gemini-voices -d "List all available Gemini TTS voice
complete -c fabric -l shell-complete-list -d "Output raw list without headers/formatting (for shell completion)"
complete -c fabric -l suppress-think -d "Suppress text enclosed in thinking tags"
complete -c fabric -l disable-responses-api -d "Disable OpenAI Responses API (default: false)"
+complete -c fabric -l notification -d "Send desktop notification when command completes"
complete -c fabric -s h -l help -d "Show this help message"
diff --git a/docs/Desktop-Notifications.md b/docs/Desktop-Notifications.md
new file mode 100644
index 00000000..6bb6f76a
--- /dev/null
+++ b/docs/Desktop-Notifications.md
@@ -0,0 +1,183 @@
+# Desktop Notifications
+
+Fabric supports desktop notifications to alert you when commands complete, which is especially useful for long-running tasks or when you're multitasking.
+
+## Quick Start
+
+Enable notifications with the `--notification` flag:
+
+```bash
+fabric --pattern summarize --notification < article.txt
+```
+
+## Configuration
+
+### Command Line Options
+
+- `--notification`: Enable desktop notifications when command completes
+- `--notification-command`: Use a custom notification command instead of built-in notifications
+
+### YAML Configuration
+
+Add notification settings to your `~/.config/fabric/config.yaml`:
+
+```yaml
+# Enable notifications by default
+notification: true
+
+# Optional: Custom notification command
+notificationCommand: 'notify-send --urgency=normal "$1" "$2"'
+```
+
+## Platform Support
+
+### macOS
+
+- **Default**: Uses `osascript` (built into macOS)
+- **Enhanced**: Install `terminal-notifier` for better notifications:
+
+ ```bash
+ brew install terminal-notifier
+ ```
+
+### Linux
+
+- **Requirement**: Install `notify-send`:
+
+ ```bash
+ # Ubuntu/Debian
+ sudo apt install libnotify-bin
+
+ # Fedora
+ sudo dnf install libnotify
+ ```
+
+### Windows
+
+- **Default**: Uses PowerShell message boxes (built-in)
+
+## Custom Notification Commands
+
+The `--notification-command` flag allows you to use custom notification scripts or commands. The command receives the title as `$1` and message as `$2` as shell positional arguments.
+
+**Security Note**: The title and message content are properly escaped to prevent command injection attacks from AI-generated output containing shell metacharacters.
+
+### Examples
+
+**macOS with custom sound:**
+
+```bash
+fabric --pattern analyze_claims --notification-command 'osascript -e "display notification \"$2\" with title \"$1\" sound name \"Ping\""' < document.txt
+```
+
+**Linux with urgency levels:**
+
+```bash
+fabric --pattern extract_wisdom --notification-command 'notify-send --urgency=critical "$1" "$2"' < video-transcript.txt
+```
+
+**Custom script:**
+
+```bash
+fabric --pattern summarize --notification-command '/path/to/my-notification-script.sh "$1" "$2"' < report.pdf
+```
+
+**Testing your custom command:**
+
+```bash
+# Test that $1 and $2 are passed correctly
+fabric --pattern raw_query --notification-command 'echo "Title: $1, Message: $2"' "test input"
+```
+
+## Notification Content
+
+Notifications include:
+
+- **Title**: "Fabric Command Complete" or "Fabric: [pattern] Complete"
+- **Message**: Brief summary of the output (first 100 characters)
+
+For long outputs, the message is truncated with "..." to fit notification display limits.
+
+## Use Cases
+
+### Long-Running Tasks
+
+```bash
+# Process large document with notifications
+fabric --pattern analyze_paper --notification < research-paper.pdf
+
+# Extract wisdom from long video with alerts
+fabric -y "https://youtube.com/watch?v=..." --pattern extract_wisdom --notification
+```
+
+### Background Processing
+
+```bash
+# Process multiple files and get notified when each completes
+for file in *.txt; do
+ fabric --pattern summarize --notification < "$file" &
+done
+```
+
+### Integration with Other Tools
+
+```bash
+# Combine with other commands
+curl -s "https://api.example.com/data" | \
+ fabric --pattern analyze_data --notification --output results.md
+```
+
+## Troubleshooting
+
+### No Notifications Appearing
+
+1. **Check system notifications are enabled** for Terminal/your shell
+2. **Verify notification tools are installed**:
+ - macOS: `which osascript` (should exist)
+ - Linux: `which notify-send`
+ - Windows: `where.exe powershell`
+
+3. **Test with simple command**:
+
+ ```bash
+ echo "test" | fabric --pattern raw_query --notification --dry-run
+ ```
+
+### Notification Permission Issues
+
+On some systems, you may need to grant notification permissions to your terminal application:
+
+- **macOS**: System Preferences → Security & Privacy → Privacy → Notifications → Enable for Terminal
+- **Linux**: Depends on desktop environment; usually automatic
+- **Windows**: Usually works by default
+
+### Custom Commands Not Working
+
+- Ensure your custom notification command is executable
+- Test the command manually with sample arguments
+- Check that all required dependencies are installed
+
+## Advanced Configuration
+
+### Environment-Specific Settings
+
+Create different configuration files for different environments:
+
+```bash
+# Work computer (quieter notifications)
+fabric --config ~/.config/fabric/work-config.yaml --notification
+
+# Personal computer (with sound)
+fabric --config ~/.config/fabric/personal-config.yaml --notification
+```
+
+### Integration with Task Management
+
+```bash
+# Custom script that also logs to task management system
+notificationCommand: '/usr/local/bin/fabric-notify-and-log.sh "$1" "$2"'
+```
+
+## Examples
+
+See `docs/notification-config.yaml` for a complete configuration example with various notification command options.
diff --git a/docs/notification-config.yaml b/docs/notification-config.yaml
new file mode 100644
index 00000000..a81a0a8c
--- /dev/null
+++ b/docs/notification-config.yaml
@@ -0,0 +1,21 @@
+# Example Fabric configuration with notification support
+# Save this to ~/.config/fabric/config.yaml to use as defaults
+
+# Enable notifications by default for all commands
+notification: true
+
+# Optional: Use a custom notification command
+# Examples:
+# macOS with custom sound:
+# notificationCommand: 'osascript -e "display notification \"$2\" with title \"$1\" sound name \"Ping\""'
+#
+# Linux with custom urgency:
+# notificationCommand: 'notify-send --urgency=normal "$1" "$2"'
+#
+# Custom script:
+# notificationCommand: '/path/to/custom-notification-script.sh "$1" "$2"'
+
+# Other common settings
+model: "gpt-4o"
+temperature: 0.7
+stream: true
diff --git a/internal/cli/chat.go b/internal/cli/chat.go
index 8fad447a..a9726609 100644
--- a/internal/cli/chat.go
+++ b/internal/cli/chat.go
@@ -3,12 +3,14 @@ package cli
import (
"fmt"
"os"
+ "os/exec"
"path/filepath"
"strings"
"github.com/danielmiessler/fabric/internal/core"
"github.com/danielmiessler/fabric/internal/domain"
"github.com/danielmiessler/fabric/internal/plugins/db/fsdb"
+ "github.com/danielmiessler/fabric/internal/tools/notifications"
)
// handleChatProcessing handles the main chat processing logic
@@ -115,9 +117,65 @@ func handleChatProcessing(currentFlags *Flags, registry *core.PluginRegistry, me
}
}
}
+
+ // Send notification if requested
+ if chatOptions.Notification {
+ if err = sendNotification(chatOptions, chatReq.PatternName, result); err != nil {
+ // Log notification error but don't fail the main command
+ fmt.Fprintf(os.Stderr, "Failed to send notification: %v\n", err)
+ }
+ }
+
return
}
+// sendNotification sends a desktop notification about command completion.
+//
+// When truncating the result for notification display, this function counts Unicode code points,
+// not grapheme clusters. As a result, complex emoji or accented characters with multiple combining
+// characters may be truncated improperly. This is a limitation of the current implementation.
+func sendNotification(options *domain.ChatOptions, patternName, result string) error {
+ title := "Fabric Command Complete"
+ if patternName != "" {
+ title = fmt.Sprintf("Fabric: %s Complete", patternName)
+ }
+
+ // Limit message length for notification display (counts Unicode code points)
+ message := "Command completed successfully"
+ if result != "" {
+ maxLength := 100
+ runes := []rune(result)
+ if len(runes) > maxLength {
+ message = fmt.Sprintf("Output: %s...", string(runes[:maxLength]))
+ } else {
+ message = fmt.Sprintf("Output: %s", result)
+ }
+ // Clean up newlines for notification display
+ message = strings.ReplaceAll(message, "\n", " ")
+ }
+
+ // Use custom notification command if provided
+ if options.NotificationCommand != "" {
+ // SECURITY: Pass title and message as proper shell positional arguments $1 and $2
+ // This matches the documented interface where custom commands receive title and message as shell variables
+ cmd := exec.Command("sh", "-c", options.NotificationCommand+" \"$1\" \"$2\"", "--", title, message)
+
+ // For debugging: capture and display output from custom commands
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ return cmd.Run()
+ }
+
+ // Use built-in notification system
+ notificationManager := notifications.NewNotificationManager()
+ if !notificationManager.IsAvailable() {
+ return fmt.Errorf("no notification system available")
+ }
+
+ return notificationManager.Send(title, message)
+}
+
// isTTSModel checks if the model is a text-to-speech model
func isTTSModel(modelName string) bool {
lowerModel := strings.ToLower(modelName)
diff --git a/internal/cli/chat_test.go b/internal/cli/chat_test.go
new file mode 100644
index 00000000..d2928ccd
--- /dev/null
+++ b/internal/cli/chat_test.go
@@ -0,0 +1,166 @@
+package cli
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/danielmiessler/fabric/internal/domain"
+)
+
+func TestSendNotification_SecurityEscaping(t *testing.T) {
+ tests := []struct {
+ name string
+ title string
+ message string
+ command string
+ expectError bool
+ description string
+ }{
+ {
+ name: "Normal content",
+ title: "Test Title",
+ message: "Test message content",
+ command: `echo "Title: $1, Message: $2"`,
+ expectError: false,
+ description: "Normal content should work fine",
+ },
+ {
+ name: "Content with backticks",
+ title: "Test Title",
+ message: "Test `whoami` injection",
+ command: `echo "Title: $1, Message: $2"`,
+ expectError: false,
+ description: "Backticks should be escaped and not executed",
+ },
+ {
+ name: "Content with semicolon injection",
+ title: "Test Title",
+ message: "Test; echo INJECTED; echo end",
+ command: `echo "Title: $1, Message: $2"`,
+ expectError: false,
+ description: "Semicolon injection should be prevented",
+ },
+ {
+ name: "Content with command substitution",
+ title: "Test Title",
+ message: "Test $(whoami) injection",
+ command: `echo "Title: $1, Message: $2"`,
+ expectError: false,
+ description: "Command substitution should be escaped",
+ },
+ {
+ name: "Content with quote injection",
+ title: "Test Title",
+ message: "Test ' || echo INJECTED || echo ' end",
+ command: `echo "Title: $1, Message: $2"`,
+ expectError: false,
+ description: "Quote injection should be prevented",
+ },
+ {
+ name: "Content with newlines",
+ title: "Test Title",
+ message: "Line 1\nLine 2\nLine 3",
+ command: `echo "Title: $1, Message: $2"`,
+ expectError: false,
+ description: "Newlines should be handled safely",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ options := &domain.ChatOptions{
+ NotificationCommand: tt.command,
+ Notification: true,
+ }
+
+ // This test mainly verifies that the function doesn't panic
+ // and properly escapes dangerous content. The actual command
+ // execution is tested separately in integration tests.
+ err := sendNotification(options, "test_pattern", tt.message)
+
+ if tt.expectError && err == nil {
+ t.Errorf("Expected error for %s, but got none", tt.description)
+ }
+
+ if !tt.expectError && err != nil {
+ t.Errorf("Unexpected error for %s: %v", tt.description, err)
+ }
+ })
+ }
+}
+
+func TestSendNotification_TitleGeneration(t *testing.T) {
+ tests := []struct {
+ name string
+ patternName string
+ expected string
+ }{
+ {
+ name: "No pattern name",
+ patternName: "",
+ expected: "Fabric Command Complete",
+ },
+ {
+ name: "With pattern name",
+ patternName: "summarize",
+ expected: "Fabric: summarize Complete",
+ },
+ {
+ name: "Pattern with special characters",
+ patternName: "test_pattern-v2",
+ expected: "Fabric: test_pattern-v2 Complete",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ options := &domain.ChatOptions{
+ NotificationCommand: `echo "Title: $1"`,
+ Notification: true,
+ }
+
+ // We're testing the title generation logic
+ // The actual notification command would echo the title
+ err := sendNotification(options, tt.patternName, "test message")
+
+ // The function should not error for valid inputs
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ })
+ }
+}
+
+func TestSendNotification_MessageTruncation(t *testing.T) {
+ longMessage := strings.Repeat("A", 150) // 150 characters
+ shortMessage := "Short message"
+
+ tests := []struct {
+ name string
+ message string
+ expected string
+ }{
+ {
+ name: "Short message",
+ message: shortMessage,
+ },
+ {
+ name: "Long message truncation",
+ message: longMessage,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ options := &domain.ChatOptions{
+ NotificationCommand: `echo "Message: $2"`,
+ Notification: true,
+ }
+
+ err := sendNotification(options, "test", tt.message)
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ })
+ }
+}
diff --git a/internal/cli/flags.go b/internal/cli/flags.go
index 269788c5..d53562f0 100644
--- a/internal/cli/flags.go
+++ b/internal/cli/flags.go
@@ -91,6 +91,8 @@ type Flags struct {
DisableResponsesAPI bool `long:"disable-responses-api" yaml:"disableResponsesAPI" description:"Disable OpenAI Responses API (default: false)"`
Voice string `long:"voice" yaml:"voice" description:"TTS voice name for supported models (e.g., Kore, Charon, Puck)" default:"Kore"`
ListGeminiVoices bool `long:"list-gemini-voices" description:"List all available Gemini TTS voices"`
+ Notification bool `long:"notification" yaml:"notification" description:"Send desktop notification when command completes"`
+ NotificationCommand string `long:"notification-command" yaml:"notificationCommand" description:"Custom command to run for notifications (overrides built-in notifications)"`
}
var debug = false
@@ -427,25 +429,27 @@ func (o *Flags) BuildChatOptions() (ret *domain.ChatOptions, err error) {
}
ret = &domain.ChatOptions{
- Model: o.Model,
- Temperature: o.Temperature,
- TopP: o.TopP,
- PresencePenalty: o.PresencePenalty,
- FrequencyPenalty: o.FrequencyPenalty,
- Raw: o.Raw,
- Seed: o.Seed,
- ModelContextLength: o.ModelContextLength,
- Search: o.Search,
- SearchLocation: o.SearchLocation,
- ImageFile: o.ImageFile,
- ImageSize: o.ImageSize,
- ImageQuality: o.ImageQuality,
- ImageCompression: o.ImageCompression,
- ImageBackground: o.ImageBackground,
- SuppressThink: o.SuppressThink,
- ThinkStartTag: startTag,
- ThinkEndTag: endTag,
- Voice: o.Voice,
+ Model: o.Model,
+ Temperature: o.Temperature,
+ TopP: o.TopP,
+ PresencePenalty: o.PresencePenalty,
+ FrequencyPenalty: o.FrequencyPenalty,
+ Raw: o.Raw,
+ Seed: o.Seed,
+ ModelContextLength: o.ModelContextLength,
+ Search: o.Search,
+ SearchLocation: o.SearchLocation,
+ ImageFile: o.ImageFile,
+ ImageSize: o.ImageSize,
+ ImageQuality: o.ImageQuality,
+ ImageCompression: o.ImageCompression,
+ ImageBackground: o.ImageBackground,
+ SuppressThink: o.SuppressThink,
+ ThinkStartTag: startTag,
+ ThinkEndTag: endTag,
+ Voice: o.Voice,
+ Notification: o.Notification || o.NotificationCommand != "",
+ NotificationCommand: o.NotificationCommand,
}
return
}
diff --git a/internal/domain/domain.go b/internal/domain/domain.go
index a904623c..c2eeb17f 100644
--- a/internal/domain/domain.go
+++ b/internal/domain/domain.go
@@ -25,28 +25,30 @@ type ChatRequest struct {
}
type ChatOptions struct {
- Model string
- Temperature float64
- TopP float64
- PresencePenalty float64
- FrequencyPenalty float64
- Raw bool
- Seed int
- ModelContextLength int
- MaxTokens int
- Search bool
- SearchLocation string
- ImageFile string
- ImageSize string
- ImageQuality string
- ImageCompression int
- ImageBackground string
- SuppressThink bool
- ThinkStartTag string
- ThinkEndTag string
- AudioOutput bool
- AudioFormat string
- Voice string
+ Model string
+ Temperature float64
+ TopP float64
+ PresencePenalty float64
+ FrequencyPenalty float64
+ Raw bool
+ Seed int
+ ModelContextLength int
+ MaxTokens int
+ Search bool
+ SearchLocation string
+ ImageFile string
+ ImageSize string
+ ImageQuality string
+ ImageCompression int
+ ImageBackground string
+ SuppressThink bool
+ ThinkStartTag string
+ ThinkEndTag string
+ AudioOutput bool
+ AudioFormat string
+ Voice string
+ Notification bool
+ NotificationCommand string
}
// NormalizeMessages remove empty messages and ensure messages order user-assist-user
diff --git a/internal/tools/notifications/notifications.go b/internal/tools/notifications/notifications.go
new file mode 100644
index 00000000..3b19dbc9
--- /dev/null
+++ b/internal/tools/notifications/notifications.go
@@ -0,0 +1,128 @@
+package notifications
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "runtime"
+)
+
+// NotificationProvider interface for different notification backends
+type NotificationProvider interface {
+ Send(title, message string) error
+ IsAvailable() bool
+}
+
+// NotificationManager handles cross-platform notifications
+type NotificationManager struct {
+ provider NotificationProvider
+}
+
+// NewNotificationManager creates a new notification manager with the best available provider
+func NewNotificationManager() *NotificationManager {
+ var provider NotificationProvider
+
+ switch runtime.GOOS {
+ case "darwin":
+ // Try terminal-notifier first, then fall back to osascript
+ provider = &TerminalNotifierProvider{}
+ if !provider.IsAvailable() {
+ provider = &OSAScriptProvider{}
+ }
+ case "linux":
+ provider = &NotifySendProvider{}
+ case "windows":
+ provider = &PowerShellProvider{}
+ default:
+ provider = &NoopProvider{}
+ }
+
+ return &NotificationManager{provider: provider}
+}
+
+// Send sends a notification using the configured provider
+func (nm *NotificationManager) Send(title, message string) error {
+ if nm.provider == nil {
+ return fmt.Errorf("no notification provider available")
+ }
+ return nm.provider.Send(title, message)
+}
+
+// IsAvailable checks if notifications are available
+func (nm *NotificationManager) IsAvailable() bool {
+ return nm.provider != nil && nm.provider.IsAvailable()
+}
+
+// macOS terminal-notifier implementation
+type TerminalNotifierProvider struct{}
+
+func (t *TerminalNotifierProvider) Send(title, message string) error {
+ cmd := exec.Command("terminal-notifier", "-title", title, "-message", message, "-sound", "Glass")
+ return cmd.Run()
+}
+
+func (t *TerminalNotifierProvider) IsAvailable() bool {
+ _, err := exec.LookPath("terminal-notifier")
+ return err == nil
+}
+
+// macOS osascript implementation
+type OSAScriptProvider struct{}
+
+func (o *OSAScriptProvider) Send(title, message string) error {
+ // SECURITY: Use separate arguments instead of string interpolation to prevent AppleScript injection
+ script := `display notification (system attribute "FABRIC_MESSAGE") with title (system attribute "FABRIC_TITLE") sound name "Glass"`
+ cmd := exec.Command("osascript", "-e", script)
+
+ // Set environment variables for the AppleScript to read safely
+ cmd.Env = append(os.Environ(), "FABRIC_TITLE="+title, "FABRIC_MESSAGE="+message)
+ return cmd.Run()
+}
+
+func (o *OSAScriptProvider) IsAvailable() bool {
+ _, err := exec.LookPath("osascript")
+ return err == nil
+}
+
+// Linux notify-send implementation
+type NotifySendProvider struct{}
+
+func (n *NotifySendProvider) Send(title, message string) error {
+ cmd := exec.Command("notify-send", title, message)
+ return cmd.Run()
+}
+
+func (n *NotifySendProvider) IsAvailable() bool {
+ _, err := exec.LookPath("notify-send")
+ return err == nil
+}
+
+// Windows PowerShell implementation
+type PowerShellProvider struct{}
+
+func (p *PowerShellProvider) Send(title, message string) error {
+ // SECURITY: Use environment variables to avoid PowerShell injection attacks
+ script := `Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show($env:FABRIC_MESSAGE, $env:FABRIC_TITLE)`
+ cmd := exec.Command("powershell", "-Command", script)
+
+ // Set environment variables for PowerShell to read safely
+ cmd.Env = append(os.Environ(), "FABRIC_TITLE="+title, "FABRIC_MESSAGE="+message)
+ return cmd.Run()
+}
+
+func (p *PowerShellProvider) IsAvailable() bool {
+ _, err := exec.LookPath("powershell")
+ return err == nil
+}
+
+// NoopProvider for unsupported platforms
+type NoopProvider struct{}
+
+func (n *NoopProvider) Send(title, message string) error {
+ // Silent no-op for unsupported platforms
+ return nil
+}
+
+func (n *NoopProvider) IsAvailable() bool {
+ return false
+}
diff --git a/internal/tools/notifications/notifications_test.go b/internal/tools/notifications/notifications_test.go
new file mode 100644
index 00000000..7fad3f3d
--- /dev/null
+++ b/internal/tools/notifications/notifications_test.go
@@ -0,0 +1,168 @@
+package notifications
+
+import (
+ "os/exec"
+ "runtime"
+ "testing"
+)
+
+func TestNewNotificationManager(t *testing.T) {
+ manager := NewNotificationManager()
+ if manager == nil {
+ t.Fatal("NewNotificationManager() returned nil")
+ }
+ if manager.provider == nil {
+ t.Fatal("NotificationManager provider is nil")
+ }
+}
+
+func TestNotificationManagerIsAvailable(t *testing.T) {
+ manager := NewNotificationManager()
+ // Should not panic
+ _ = manager.IsAvailable()
+}
+
+func TestNotificationManagerSend(t *testing.T) {
+ manager := NewNotificationManager()
+
+ // Test sending notification - this may fail on systems without notification tools
+ // but should not panic
+ err := manager.Send("Test Title", "Test Message")
+ if err != nil {
+ t.Logf("Notification send failed (expected on systems without notification tools): %v", err)
+ }
+}
+
+func TestTerminalNotifierProvider(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("Skipping macOS terminal-notifier test on non-macOS platform")
+ }
+
+ provider := &TerminalNotifierProvider{}
+
+ // Test availability - depends on whether terminal-notifier is installed
+ available := provider.IsAvailable()
+ t.Logf("terminal-notifier available: %v", available)
+
+ if available {
+ err := provider.Send("Test", "Test message")
+ if err != nil {
+ t.Logf("terminal-notifier send failed: %v", err)
+ }
+ }
+}
+
+func TestOSAScriptProvider(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("Skipping macOS osascript test on non-macOS platform")
+ }
+
+ provider := &OSAScriptProvider{}
+
+ // osascript should always be available on macOS
+ if !provider.IsAvailable() {
+ t.Error("osascript should be available on macOS")
+ }
+
+ // Test sending (may show actual notification)
+ err := provider.Send("Test", "Test message")
+ if err != nil {
+ t.Errorf("osascript send failed: %v", err)
+ }
+}
+
+func TestNotifySendProvider(t *testing.T) {
+ if runtime.GOOS != "linux" {
+ t.Skip("Skipping Linux notify-send test on non-Linux platform")
+ }
+
+ provider := &NotifySendProvider{}
+
+ // Test availability - depends on whether notify-send is installed
+ available := provider.IsAvailable()
+ t.Logf("notify-send available: %v", available)
+
+ if available {
+ err := provider.Send("Test", "Test message")
+ if err != nil {
+ t.Logf("notify-send send failed: %v", err)
+ }
+ }
+}
+
+func TestPowerShellProvider(t *testing.T) {
+ if runtime.GOOS != "windows" {
+ t.Skip("Skipping Windows PowerShell test on non-Windows platform")
+ }
+
+ provider := &PowerShellProvider{}
+
+ // PowerShell should be available on Windows
+ if !provider.IsAvailable() {
+ t.Error("PowerShell should be available on Windows")
+ }
+
+ // Note: This will show a message box if run
+ // In CI/CD, this might not work properly
+ err := provider.Send("Test", "Test message")
+ if err != nil {
+ t.Logf("PowerShell send failed (expected in headless environments): %v", err)
+ }
+}
+
+func TestNoopProvider(t *testing.T) {
+ provider := &NoopProvider{}
+
+ // Should always report as not available
+ if provider.IsAvailable() {
+ t.Error("NoopProvider should report as not available")
+ }
+
+ // Should never error
+ err := provider.Send("Test", "Test message")
+ if err != nil {
+ t.Errorf("NoopProvider send should never error, got: %v", err)
+ }
+}
+
+func TestProviderIsAvailable(t *testing.T) {
+ tests := []struct {
+ name string
+ provider NotificationProvider
+ command string
+ }{
+ {"TerminalNotifier", &TerminalNotifierProvider{}, "terminal-notifier"},
+ {"OSAScript", &OSAScriptProvider{}, "osascript"},
+ {"NotifySend", &NotifySendProvider{}, "notify-send"},
+ {"PowerShell", &PowerShellProvider{}, "powershell"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ available := tt.provider.IsAvailable()
+
+ // Cross-check with actual command availability
+ _, err := exec.LookPath(tt.command)
+ expectedAvailable := err == nil
+
+ if available != expectedAvailable {
+ t.Logf("Provider %s availability mismatch: provider=%v, command=%v",
+ tt.name, available, expectedAvailable)
+ // This is informational, not a failure, since system setup varies
+ }
+ })
+ }
+}
+
+func TestSendWithSpecialCharacters(t *testing.T) {
+ manager := NewNotificationManager()
+
+ // Test with special characters that might break shell commands
+ specialTitle := `Title with "quotes" and 'apostrophes'`
+ specialMessage := `Message with \backslashes and $variables and "quotes"`
+
+ err := manager.Send(specialTitle, specialMessage)
+ if err != nil {
+ t.Logf("Send with special characters failed (may be expected): %v", err)
+ }
+}
diff --git a/nix/pkgs/fabric/version.nix b/nix/pkgs/fabric/version.nix
index 3d8e3a99..6ce6273d 100644
--- a/nix/pkgs/fabric/version.nix
+++ b/nix/pkgs/fabric/version.nix
@@ -1 +1 @@
-"1.4.275"
+"1.4.276"