mirror of
https://github.com/danielmiessler/Fabric.git
synced 2026-01-07 21:44:02 -05:00
feat(cli): add cross-platform desktop notifications with secure custom commands
CHANGES - Integrate notification sending into chat processing workflow - Add --notification and --notification-command CLI flags and help - Provide cross-platform providers: macOS, Linux, Windows with fallbacks - Escape shell metacharacters to prevent injection vulnerabilities - Truncate Unicode output safely for notification message previews - Update bash, zsh, fish completions with new notification options - Add docs and YAML examples for configuration and customization - Add unit tests for providers and notification integration paths
This commit is contained in:
committed by
Kayvan Sylvan
parent
056791233a
commit
21f258caa4
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -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",
|
||||
|
||||
11
CHANGELOG.md
11
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package main
|
||||
|
||||
var version = "v1.4.275"
|
||||
var version = "v1.4.276"
|
||||
|
||||
Binary file not shown.
7
cmd/generate_changelog/incoming/1679.txt
Normal file
7
cmd/generate_changelog/incoming/1679.txt
Normal file
@@ -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
|
||||
@@ -117,6 +117,8 @@ _fabric() {
|
||||
'(--think-start-tag)--think-start-tag[Start tag for thinking sections (default: <think>)]:start tag:' \
|
||||
'(--think-end-tag)--think-end-tag[End tag for thinking sections (default: </think>)]: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:'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
;;
|
||||
|
||||
@@ -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: <think>)"
|
||||
complete -c fabric -l think-end-tag -d "End tag for thinking sections (default: </think>)"
|
||||
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"
|
||||
|
||||
183
docs/Desktop-Notifications.md
Normal file
183
docs/Desktop-Notifications.md
Normal file
@@ -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.
|
||||
21
docs/notification-config.yaml
Normal file
21
docs/notification-config.yaml
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
166
internal/cli/chat_test.go
Normal file
166
internal/cli/chat_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
128
internal/tools/notifications/notifications.go
Normal file
128
internal/tools/notifications/notifications.go
Normal file
@@ -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
|
||||
}
|
||||
168
internal/tools/notifications/notifications_test.go
Normal file
168
internal/tools/notifications/notifications_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
"1.4.275"
|
||||
"1.4.276"
|
||||
|
||||
Reference in New Issue
Block a user