vagrant-claude-sandbox 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0c3f6d76bc6a8854641197815ae4e7a1b5685a322576d72969d8e00b82b39d7
4
- data.tar.gz: 67eb291fdbefa65b04a48d684e89658955c7819d9f49df469a27d1b8b035cd38
3
+ metadata.gz: 83aa818f90a8658daa51e1666f727910359e687032aa9e0c2686667ab82c5e57
4
+ data.tar.gz: a683a780d3770714c1be586f7066a3dfa102a858c5cc2bb123da9e936d2fb43a
5
5
  SHA512:
6
- metadata.gz: 0da4554053ffc441c32e1f97e0be973aead1bc98b8a77d62956987b4f4f9413709e935c48731bea51740f52f20ef78f44331ca422ad86c3c082173776af26d68
7
- data.tar.gz: bf1b40b5839f36a84180436c120af389eb7b3d92aadacf3172520e2f5b8ec01a4396230f7c237fb1450a79f0400ed171becf14716914fdc0be18d492d816641b
6
+ metadata.gz: 45117ae9e15c7101215e393ce4bb7e70c05c0e5b37885b648f1c15253176ffd631b5638b8bcd5d860a9d52fadeb9e47ff2cde209c0028afd4f40571889b789d2
7
+ data.tar.gz: 8083c2e00adf8dbc09f6b758a53d64bf9db7527e2dca70d74df3520b8a3719fa0329e439a261dcd27b019ccc952366cc8be0a037c08eb9b0f996f9bfd34ddc75
data/README.md CHANGED
@@ -125,6 +125,14 @@ end
125
125
 
126
126
  **Security note:** Only use mirrors from trusted sources. The official Ubuntu mirror is always the safest option.
127
127
 
128
+ ## Notifications
129
+
130
+ Vagrant uses the `vagrant-notify-server` gem to provide desktop notifications from Claude.
131
+ To display notifications on your OS, [install `terminal-notifier` on MacOS](https://formulae.brew.sh/formula/terminal-notifier) or `libnotify-bin` on Linux (untested).
132
+
133
+ By default notifications appear when Claude completes tasks or needs input.
134
+ To customize notification behavior: Copy lib/vagrant-claude-sandbox/notification_config.yml.example to ~/.vagrant-claude-sandbox/notification_config.yml to configure which notifications appear.
135
+
128
136
  ## Development and Testing
129
137
 
130
138
  ### Running Tests
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Add lib directory to load path
4
+ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
5
+
6
+ require 'vagrant-claude-sandbox/notification_server'
7
+
8
+ # If port specified on command line, use it. Otherwise, let auto-detection work
9
+ # (reads from .vagrant-notification-port if present, otherwise uses default)
10
+ port = ARGV[0] ? ARGV[0].to_i : nil
11
+ server = VagrantPlugins::ClaudeSandbox::NotificationServer.new(port)
12
+ server.start
@@ -27,7 +27,8 @@ module VagrantPlugins
27
27
  workspace_path = config.workspace_path || "/agent-workspace"
28
28
 
29
29
  # Build the command to run
30
- command = "cd #{workspace_path}; . ~/.nvm/nvm.sh && exec claude --dangerously-skip-permissions --chrome"
30
+ # Source nvm only if needed (claude might already be in PATH)
31
+ command = "cd #{workspace_path}; [ -f ~/.nvm/nvm.sh ] && . ~/.nvm/nvm.sh; exec claude --dangerously-skip-permissions --chrome"
31
32
 
32
33
  # Execute SSH with the command
33
34
  machine.action(:ssh_run, ssh_run_command: command, ssh_opts: { extra_args: ["-t"] })
@@ -33,7 +33,7 @@ module VagrantPlugins
33
33
  @claude_config_path = File.expand_path("~/.claude/") if @claude_config_path == UNSET_VALUE
34
34
  @skip_claude_cli_install = false if @skip_claude_cli_install == UNSET_VALUE
35
35
  @additional_packages = [] if @additional_packages == UNSET_VALUE
36
- @provider = "virtualbox" if @provider == UNSET_VALUE
36
+ @provider = "docker" if @provider == UNSET_VALUE
37
37
  @docker_image = nil if @docker_image == UNSET_VALUE
38
38
  @ubuntu_mirror = nil if @ubuntu_mirror == UNSET_VALUE
39
39
  end
@@ -72,7 +72,7 @@ module VagrantPlugins
72
72
  # Vagrant will choose the appropriate one based on:
73
73
  # 1. --provider flag from command line
74
74
  # 2. Existing .vagrant directory state
75
- # 3. Default provider (VirtualBox)
75
+ # 3. Default provider (Docker)
76
76
  apply_virtualbox_config!(root_config)
77
77
  apply_docker_config!(root_config)
78
78
 
@@ -96,8 +96,49 @@ module VagrantPlugins
96
96
  destination: "/tmp/claude-config"
97
97
  end
98
98
 
99
+ # Copy claude-notify script to VM
100
+ claude_notify_script = File.expand_path("../scripts/claude-notify", __FILE__)
101
+ if File.exist?(claude_notify_script)
102
+ root_config.vm.provision "file",
103
+ source: claude_notify_script,
104
+ destination: "/tmp/claude-notify"
105
+ end
106
+
99
107
  # Configure SSH port with auto-correction for conflicts
100
108
  root_config.vm.network :forwarded_port, guest: 22, host: 2200, id: "ssh", auto_correct: true
109
+
110
+ # Forward notification port from host to guest (guest can send notifications to host)
111
+ # Use auto_correct to handle port conflicts
112
+ root_config.vm.network :forwarded_port, guest: 29325, host: 29325, id: "notifications", host_ip: "127.0.0.1", auto_correct: true
113
+
114
+ # Write the actual notification port to a file after VM is up so scripts can read it
115
+ root_config.trigger.after :up do |trigger|
116
+ trigger.ruby do |env, machine|
117
+ # Get the actual forwarded port for notifications
118
+ ports = machine.provider.driver.read_forwarded_ports rescue []
119
+ notification_port = nil
120
+
121
+ # Find the notification port mapping
122
+ ports.each do |port_info|
123
+ # Format varies by provider, handle both
124
+ if port_info.is_a?(Array) && port_info.length >= 4
125
+ # VirtualBox format: [name, guest_port, host_port, host_ip]
126
+ notification_port = port_info[2] if port_info[1] == 29325
127
+ elsif port_info.is_a?(Hash)
128
+ notification_port = port_info[:host] if port_info[:guest] == 29325
129
+ end
130
+ end
131
+
132
+ # Default to 29325 if we couldn't detect
133
+ notification_port ||= 29325
134
+
135
+ # Write port to workspace config file
136
+ workspace = machine.config.claude_sandbox.workspace_path rescue "/agent-workspace"
137
+ port_file = File.join(Dir.pwd, ".vagrant-notification-port")
138
+ File.write(port_file, notification_port.to_s)
139
+ machine.ui.info("Notification server port: #{notification_port}")
140
+ end
141
+ end
101
142
  end
102
143
 
103
144
  def apply_virtualbox_config!(root_config)
@@ -172,6 +213,10 @@ module VagrantPlugins
172
213
  lsb-release \
173
214
  git \
174
215
  unzip \
216
+ libnotify-bin \
217
+ dbus-x11 \
218
+ inotify-tools \
219
+ netcat-openbsd \
175
220
  #{additional_packages}
176
221
 
177
222
  #{docker_install_script}
@@ -223,6 +268,86 @@ module VagrantPlugins
223
268
  echo "Claude plugins and skills loaded successfully!"
224
269
  fi
225
270
 
271
+ # Set up notification forwarding to host
272
+ echo "Setting up notification forwarding to host..."
273
+
274
+ # Backup original notify-send if it exists
275
+ if [ -f /usr/bin/notify-send ]; then
276
+ mv /usr/bin/notify-send /usr/bin/notify-send.real
277
+ fi
278
+
279
+ # Create wrapper script that forwards to host
280
+ cat > /usr/bin/notify-send << 'NOTIFY_EOF'
281
+ #!/bin/bash
282
+ # Wrapper script to forward notifications to host
283
+
284
+ TITLE=""
285
+ MESSAGE=""
286
+
287
+ # Read port from config file (written by vagrant trigger), default to 29325
288
+ if [ -f "/agent-workspace/.vagrant-notification-port" ]; then
289
+ HOST_PORT=$(cat /agent-workspace/.vagrant-notification-port)
290
+ else
291
+ HOST_PORT=29325
292
+ fi
293
+
294
+ # Parse notify-send arguments (simplified)
295
+ while [[ $# -gt 0 ]]; do
296
+ case $1 in
297
+ -u|--urgency)
298
+ shift 2
299
+ ;;
300
+ -t|--expire-time)
301
+ shift 2
302
+ ;;
303
+ -i|--icon)
304
+ shift 2
305
+ ;;
306
+ -c|--category)
307
+ shift 2
308
+ ;;
309
+ *)
310
+ if [ -z "$TITLE" ]; then
311
+ TITLE="$1"
312
+ elif [ -z "$MESSAGE" ]; then
313
+ MESSAGE="$1"
314
+ fi
315
+ shift
316
+ ;;
317
+ esac
318
+ done
319
+
320
+ # Default message if not provided
321
+ [ -z "$MESSAGE" ] && MESSAGE="$TITLE" && TITLE="Notification"
322
+
323
+ # Try host.docker.internal first (Docker), then fall back to 10.0.2.2 (VirtualBox)
324
+ for HOST_IP in host.docker.internal 10.0.2.2; do
325
+ if echo "NOTIFY|$TITLE|$MESSAGE" | nc -w 1 $HOST_IP $HOST_PORT > /dev/null 2>&1; then
326
+ exit 0
327
+ fi
328
+ done
329
+
330
+ # Fall back to local notification if host server not available
331
+ if [ -f /usr/bin/notify-send.real ]; then
332
+ exec /usr/bin/notify-send.real "$TITLE" "$MESSAGE"
333
+ fi
334
+
335
+ # If all else fails, just log it
336
+ logger "Notification: $TITLE - $MESSAGE"
337
+ NOTIFY_EOF
338
+
339
+ chmod +x /usr/bin/notify-send
340
+ echo "Notification forwarding configured!"
341
+
342
+ # Install claude-notify helper script
343
+ if [ -f "/tmp/claude-notify" ]; then
344
+ echo "Installing claude-notify helper script..."
345
+ mv /tmp/claude-notify /usr/local/bin/claude-notify
346
+ chmod +x /usr/local/bin/claude-notify
347
+ chown root:root /usr/local/bin/claude-notify
348
+ echo "claude-notify installed successfully!"
349
+ fi
350
+
226
351
  echo "Claude sandbox environment setup complete!"
227
352
  SHELL
228
353
 
@@ -0,0 +1,29 @@
1
+ # Notification Configuration for vagrant-claude-sandbox
2
+ # Copy this file to ~/.vagrant-claude-sandbox/notification_config.yml to customize
3
+
4
+ # Which notification types to show
5
+ # Types: info, success, error, warning, needs_input, task_complete, task_start
6
+ show_types:
7
+ - task_complete
8
+ - needs_input
9
+ - error
10
+ - warning
11
+
12
+ # Default timeout in seconds (0 = permanent until dismissed)
13
+ default_timeout: 0
14
+
15
+ # Override timeout for specific types (in seconds)
16
+ type_timeouts:
17
+ info: 10
18
+ success: 15
19
+ error: 0 # Errors stay until dismissed
20
+ warning: 0 # Warnings stay until dismissed
21
+ needs_input: 0 # User input needed stays until dismissed
22
+ task_complete: 0 # Task completions stay until dismissed
23
+ task_start: 5 # Task starts auto-dismiss after 5s
24
+
25
+ # Enable sound for notifications
26
+ enable_sound: false
27
+
28
+ # Only show notifications with URLs (e.g., link to conversation)
29
+ require_url: false
@@ -0,0 +1,189 @@
1
+ require 'socket'
2
+ require 'json'
3
+ require 'yaml'
4
+
5
+ module VagrantPlugins
6
+ module ClaudeSandbox
7
+ class NotificationServer
8
+ DEFAULT_PORT = 29325
9
+ PORT_FILE = ".vagrant-notification-port"
10
+ DEFAULT_CONFIG = {
11
+ 'show_types' => ['task_complete', 'needs_input', 'error', 'warning'],
12
+ 'default_timeout' => 0,
13
+ 'type_timeouts' => {
14
+ 'info' => 10,
15
+ 'success' => 15,
16
+ 'error' => 0,
17
+ 'warning' => 0,
18
+ 'needs_input' => 0,
19
+ 'task_complete' => 0,
20
+ 'task_start' => 5
21
+ },
22
+ 'enable_sound' => false,
23
+ 'require_url' => false
24
+ }
25
+
26
+ def initialize(port = nil)
27
+ @port = port || detect_port
28
+ @server = nil
29
+ @config = load_config
30
+ end
31
+
32
+ def detect_port
33
+ # Check for port file in current directory (written by vagrant trigger)
34
+ if File.exist?(PORT_FILE)
35
+ port = File.read(PORT_FILE).strip.to_i
36
+ return port if port > 0
37
+ end
38
+ DEFAULT_PORT
39
+ end
40
+
41
+ def load_config
42
+ config_path = File.expand_path('~/.vagrant-claude-sandbox/notification_config.yml')
43
+ if File.exist?(config_path)
44
+ begin
45
+ loaded = YAML.load_file(config_path)
46
+ DEFAULT_CONFIG.merge(loaded || {})
47
+ rescue => e
48
+ puts "Warning: Failed to load config from #{config_path}: #{e.message}"
49
+ puts "Using default configuration"
50
+ DEFAULT_CONFIG
51
+ end
52
+ else
53
+ DEFAULT_CONFIG
54
+ end
55
+ end
56
+
57
+ def start
58
+ @server = TCPServer.new('127.0.0.1', @port)
59
+ puts "Notification server listening on port #{@port}..."
60
+ puts "VM can now send notifications to this host."
61
+ puts ""
62
+ puts "Configuration:"
63
+ puts " Show types: #{@config['show_types'].join(', ')}"
64
+ puts " Default timeout: #{@config['default_timeout'] == 0 ? 'permanent' : "#{@config['default_timeout']}s"}"
65
+ puts " Require URL: #{@config['require_url']}"
66
+ puts ""
67
+ puts "Press Ctrl+C to stop."
68
+
69
+ trap("INT") do
70
+ puts "\nShutting down notification server..."
71
+ @server.close if @server
72
+ exit
73
+ end
74
+
75
+ loop do
76
+ client = @server.accept
77
+ handle_client(client)
78
+ end
79
+ rescue Errno::EADDRINUSE
80
+ puts "Error: Port #{@port} is already in use."
81
+ puts "Another notification server may already be running."
82
+ exit 1
83
+ rescue => e
84
+ puts "Error starting notification server: #{e.message}"
85
+ exit 1
86
+ end
87
+
88
+ private
89
+
90
+ def handle_client(client)
91
+ request = client.gets
92
+ return unless request
93
+
94
+ # Parse protocol: NOTIFY|title|message|url|timeout|type (url, timeout, type optional)
95
+ parts = request.strip.split('|', 6)
96
+ if parts[0] == 'NOTIFY' && parts.length >= 3
97
+ title = parts[1]
98
+ message = parts[2]
99
+ url = parts[3] if parts.length >= 4 && !parts[3].to_s.strip.empty?
100
+ timeout_str = parts[4] if parts.length >= 5
101
+ timeout = timeout_str.to_i if timeout_str && !timeout_str.strip.empty?
102
+ type = parts[5] if parts.length >= 6
103
+ type = 'info' if !type || type.strip.empty?
104
+ type = type.strip if type
105
+
106
+ # Filter based on configuration
107
+ if should_show_notification?(type, url)
108
+ # Apply configured timeout
109
+ timeout = get_timeout_for_type(type) if timeout.nil?
110
+ send_notification(title, message, url, timeout, type)
111
+ client.puts "OK"
112
+ else
113
+ client.puts "FILTERED"
114
+ end
115
+ else
116
+ client.puts "ERROR: Invalid format. Use: NOTIFY|title|message|url|timeout|type"
117
+ end
118
+ ensure
119
+ client.close
120
+ end
121
+
122
+ def should_show_notification?(type, url)
123
+ # Check if this type should be shown
124
+ return false unless @config['show_types'].include?(type)
125
+
126
+ # Check if URL is required
127
+ return false if @config['require_url'] && (url.nil? || url.empty?)
128
+
129
+ true
130
+ end
131
+
132
+ def get_timeout_for_type(type)
133
+ @config['type_timeouts'][type] || @config['default_timeout']
134
+ end
135
+
136
+ def send_notification(title, message, url = nil, timeout = nil, type = 'info')
137
+ # Detect OS and send notification
138
+ case RbConfig::CONFIG['host_os']
139
+ when /darwin|mac os/
140
+ send_macos_notification(title, message, url, timeout, type)
141
+ when /linux/
142
+ send_linux_notification(title, message, url, timeout, type)
143
+ else
144
+ puts "[#{type}] #{title}: #{message}"
145
+ puts " URL: #{url}" if url
146
+ puts " Timeout: #{timeout}s" if timeout
147
+ end
148
+ end
149
+
150
+ def send_macos_notification(title, message, url = nil, timeout = nil, type = 'info')
151
+ # Try terminal-notifier first (more features including clickable notifications)
152
+ if system('which terminal-notifier > /dev/null 2>&1')
153
+ args = ['-title', title, '-message', message]
154
+ args += ['-sound', 'default'] if @config['enable_sound']
155
+ args += ['-open', url] if url
156
+ # terminal-notifier timeout is in seconds, 0 means "stick around until dismissed"
157
+ args += ['-timeout', timeout.to_s] if timeout && timeout > 0
158
+ system('terminal-notifier', *args)
159
+ else
160
+ # Fall back to osascript (not clickable, no timeout control)
161
+ escaped_title = title.gsub('"', '\"')
162
+ escaped_message = message.gsub('"', '\"')
163
+ system("osascript -e 'display notification \"#{escaped_message}\" with title \"#{escaped_title}\"'")
164
+ puts " (Install terminal-notifier for clickable notifications and timeout control: brew install terminal-notifier)" if url || timeout
165
+ end
166
+ end
167
+
168
+ def send_linux_notification(title, message, url = nil, timeout = nil, type = 'info')
169
+ # notify-send supports -t for timeout in milliseconds
170
+ args = [title]
171
+
172
+ # Add URL to message if provided
173
+ if url
174
+ full_message = "#{message}\n\n🔗 #{url}"
175
+ args << full_message
176
+ else
177
+ args << message
178
+ end
179
+
180
+ # Add timeout if specified (convert seconds to milliseconds, 0 means no timeout)
181
+ if timeout && timeout > 0
182
+ args += ['-t', (timeout * 1000).to_s]
183
+ end
184
+
185
+ system('notify-send', *args)
186
+ end
187
+ end
188
+ end
189
+ end
@@ -1,4 +1,8 @@
1
1
  require_relative "path_fixer"
2
+ require 'fileutils'
3
+
4
+ # Set Docker as the default provider unless already specified
5
+ ENV['VAGRANT_DEFAULT_PROVIDER'] ||= 'docker'
2
6
 
3
7
  # Fix Docker PATH issues immediately when plugin loads (before Vagrant initialization)
4
8
  VagrantPlugins::ClaudeSandbox::PathFixer.fix_docker_path!
@@ -18,6 +22,40 @@ module VagrantPlugins
18
22
  require_relative "command"
19
23
  Command
20
24
  end
25
+
26
+ # Check for required plugins on environment load
27
+ action_hook(:check_dependencies, :environment_load) do |hook|
28
+ hook.after(Vagrant::Action::Builtin::HandleBox, Action::CheckDependencies)
29
+ end
30
+ end
31
+
32
+ # Action to check for vagrant-notify-forwarder plugin
33
+ module Action
34
+ class CheckDependencies
35
+ def initialize(app, env)
36
+ @app = app
37
+ end
38
+
39
+ def call(env)
40
+ # Check if vagrant-notify-forwarder is installed
41
+ unless plugin_installed?("vagrant-notify-forwarder")
42
+ env[:ui].warn("vagrant-notify-forwarder plugin is not installed.")
43
+ env[:ui].warn("This plugin enables real-time filesystem change notifications from host to guest,")
44
+ env[:ui].warn("improving performance for development tools like webpack, nodemon, etc.")
45
+ env[:ui].warn("")
46
+ env[:ui].info("Install it with: vagrant plugin install vagrant-notify-forwarder")
47
+ env[:ui].warn("")
48
+ end
49
+
50
+ @app.call(env)
51
+ end
52
+
53
+ private
54
+
55
+ def plugin_installed?(plugin_name)
56
+ Vagrant::Plugin::Manager.instance.installed_plugins.key?(plugin_name)
57
+ end
58
+ end
21
59
  end
22
60
  end
23
61
  end
@@ -0,0 +1,300 @@
1
+ #!/bin/bash
2
+ # Claude Code notification helper
3
+ # Sends rich notifications with project context and optional URLs
4
+
5
+ # Don't use set -e because we handle non-zero exit codes explicitly
6
+ set +e
7
+
8
+ # Configuration
9
+ # Read port from config file (written by vagrant trigger), default to 29325
10
+ if [ -f "/agent-workspace/.vagrant-notification-port" ]; then
11
+ HOST_PORT=$(cat /agent-workspace/.vagrant-notification-port)
12
+ else
13
+ HOST_PORT=29325
14
+ fi
15
+ NOTIFICATION_TIMEOUT=1
16
+
17
+ # Colors for terminal output
18
+ RED='\033[0;31m'
19
+ GREEN='\033[0;32m'
20
+ YELLOW='\033[1;33m'
21
+ NC='\033[0m' # No Color
22
+
23
+ # Usage information
24
+ usage() {
25
+ cat << EOF
26
+ Usage: claude-notify [OPTIONS] TITLE [MESSAGE]
27
+
28
+ Send a notification from Claude Code to the host machine.
29
+
30
+ OPTIONS:
31
+ -u, --url URL Add clickable URL to notification
32
+ -s, --session ID Add session/conversation ID
33
+ -p, --project PATH Project path (default: current directory)
34
+ -t, --timeout SECONDS How long notification stays visible (default: from config)
35
+ --type TYPE Notification type (default: auto-detect)
36
+ -h, --help Show this help message
37
+
38
+ NOTIFICATION TYPES:
39
+ info - General information (default)
40
+ success - Success messages
41
+ error - Error messages (high priority)
42
+ warning - Warning messages (high priority)
43
+ needs_input - Claude needs user input (high priority)
44
+ task_complete - Task completed (default shown)
45
+ task_start - Task started (default filtered)
46
+
47
+ DEFAULT BEHAVIOR:
48
+ - Only task_complete, needs_input, error, warning shown
49
+ - Notifications are permanent (stay until dismissed)
50
+ - Configure in ~/.vagrant-claude-sandbox/notification_config.yml
51
+
52
+ EXAMPLES:
53
+ # Task completion (shown by default)
54
+ claude-notify "Build Complete" "All tests passed!"
55
+
56
+ # Task completion with URL
57
+ claude-notify --type task_complete -u "https://claude.ai/chat/abc" "Done" "Ready for review"
58
+
59
+ # Needs input (shown by default)
60
+ claude-notify --type needs_input "Input Required" "Which API endpoint to use?"
61
+
62
+ # Task start (filtered by default)
63
+ claude-notify --type task_start "Building" "Starting build process..."
64
+
65
+ # Error (shown by default, permanent)
66
+ claude-notify --type error "Build Failed" "Check line 42"
67
+
68
+ ENVIRONMENT VARIABLES:
69
+ CLAUDE_SESSION_ID Current Claude conversation ID
70
+ CLAUDE_SESSION_URL Direct URL to current conversation
71
+ CLAUDE_PROJECT_PATH Current project directory
72
+
73
+ EOF
74
+ exit 0
75
+ }
76
+
77
+ # Parse arguments
78
+ TITLE=""
79
+ MESSAGE=""
80
+ URL=""
81
+ SESSION_ID=""
82
+ TIMEOUT=""
83
+ TYPE=""
84
+
85
+ # Default to workspace directory if we're in /home/vagrant
86
+ if [ "$PWD" = "/home/vagrant" ] && [ -d "/agent-workspace" ]; then
87
+ PROJECT_PATH="${CLAUDE_PROJECT_PATH:-/agent-workspace}"
88
+ else
89
+ PROJECT_PATH="${CLAUDE_PROJECT_PATH:-$PWD}"
90
+ fi
91
+
92
+ while [[ $# -gt 0 ]]; do
93
+ case $1 in
94
+ -h|--help)
95
+ usage
96
+ ;;
97
+ -u|--url)
98
+ URL="$2"
99
+ shift 2
100
+ ;;
101
+ -s|--session)
102
+ SESSION_ID="$2"
103
+ shift 2
104
+ ;;
105
+ -p|--project)
106
+ PROJECT_PATH="$2"
107
+ shift 2
108
+ ;;
109
+ -t|--timeout)
110
+ TIMEOUT="$2"
111
+ shift 2
112
+ ;;
113
+ --type)
114
+ TYPE="$2"
115
+ shift 2
116
+ ;;
117
+ *)
118
+ if [ -z "$TITLE" ]; then
119
+ TITLE="$1"
120
+ elif [ -z "$MESSAGE" ]; then
121
+ MESSAGE="$1"
122
+ else
123
+ echo -e "${RED}Error: Too many arguments${NC}" >&2
124
+ echo "Run 'claude-notify --help' for usage information" >&2
125
+ exit 1
126
+ fi
127
+ shift
128
+ ;;
129
+ esac
130
+ done
131
+
132
+ # Validate required arguments
133
+ if [ -z "$TITLE" ]; then
134
+ echo -e "${RED}Error: TITLE is required${NC}" >&2
135
+ echo "Run 'claude-notify --help' for usage information" >&2
136
+ exit 1
137
+ fi
138
+
139
+ # Use title as message if no message provided
140
+ if [ -z "$MESSAGE" ]; then
141
+ MESSAGE="$TITLE"
142
+ fi
143
+
144
+ # Try to get session info from environment if not provided
145
+ if [ -z "$URL" ]; then
146
+ if [ -n "$CLAUDE_SESSION_URL" ]; then
147
+ URL="$CLAUDE_SESSION_URL"
148
+ elif [ -n "$SESSION_ID" ]; then
149
+ # Construct URL from session ID
150
+ URL="https://claude.ai/chat/${SESSION_ID}"
151
+ elif [ -n "$CLAUDE_SESSION_ID" ]; then
152
+ URL="https://claude.ai/chat/${CLAUDE_SESSION_ID}"
153
+ fi
154
+ fi
155
+
156
+ # Detect project name intelligently
157
+ detect_project_name() {
158
+ local path="$1"
159
+ local name=""
160
+
161
+ # Try git remote first
162
+ if [ -d "$path/.git" ]; then
163
+ name=$(cd "$path" && git remote get-url origin 2>/dev/null | sed -E 's/.*\/(.+)(\.git)?$/\1/' | sed 's/\.git$//')
164
+ fi
165
+
166
+ # Try package.json
167
+ if [ -z "$name" ] && [ -f "$path/package.json" ]; then
168
+ name=$(grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' "$path/package.json" 2>/dev/null | head -1 | sed 's/"name"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/')
169
+ fi
170
+
171
+ # Try Cargo.toml
172
+ if [ -z "$name" ] && [ -f "$path/Cargo.toml" ]; then
173
+ name=$(grep -o 'name[[:space:]]*=[[:space:]]*"[^"]*"' "$path/Cargo.toml" 2>/dev/null | head -1 | sed 's/name[[:space:]]*=[[:space:]]*"\([^"]*\)"/\1/')
174
+ fi
175
+
176
+ # Fall back to directory name
177
+ if [ -z "$name" ]; then
178
+ name=$(basename "$path")
179
+ fi
180
+
181
+ echo "$name"
182
+ }
183
+
184
+ # Auto-detect notification type from message content
185
+ detect_type() {
186
+ local title="$1"
187
+ local message="$2"
188
+ local combined="$title $message"
189
+
190
+ # Check for needs input keywords
191
+ if echo "$combined" | grep -qiE "(input|choose|select|confirm|approve|review|check|question)"; then
192
+ echo "needs_input"
193
+ return
194
+ fi
195
+
196
+ # Check for error keywords
197
+ if echo "$combined" | grep -qiE "(error|fail|failed|exception|critical|crash)"; then
198
+ echo "error"
199
+ return
200
+ fi
201
+
202
+ # Check for warning keywords
203
+ if echo "$combined" | grep -qiE "(warning|warn|deprecated|caution|alert)"; then
204
+ echo "warning"
205
+ return
206
+ fi
207
+
208
+ # Check for task complete keywords
209
+ if echo "$combined" | grep -qiE "(complete|completed|done|finished|success|passed|ready)"; then
210
+ echo "task_complete"
211
+ return
212
+ fi
213
+
214
+ # Check for task start keywords
215
+ if echo "$combined" | grep -qiE "(start|starting|begin|beginning|running|processing)"; then
216
+ echo "task_start"
217
+ return
218
+ fi
219
+
220
+ # Check for success keywords
221
+ if echo "$combined" | grep -qiE "(success|successful|ok|okay)"; then
222
+ echo "success"
223
+ return
224
+ fi
225
+
226
+ # Default to info
227
+ echo "info"
228
+ }
229
+
230
+ # Enhance message with project context
231
+ PROJECT_NAME=$(detect_project_name "$PROJECT_PATH")
232
+ # Don't use newlines - they don't render properly
233
+ # Add project name to the title instead
234
+ ENHANCED_TITLE="[${PROJECT_NAME}] ${TITLE}"
235
+ ENHANCED_MESSAGE="${MESSAGE}"
236
+
237
+ # Auto-detect type if not specified
238
+ if [ -z "$TYPE" ]; then
239
+ TYPE=$(detect_type "$TITLE" "$MESSAGE")
240
+ fi
241
+
242
+ # Timeout defaults to empty (server will use configured default)
243
+ if [ -z "$TIMEOUT" ]; then
244
+ TIMEOUT=""
245
+ fi
246
+
247
+ # Determine host IP (Docker vs VirtualBox)
248
+ detect_host() {
249
+ # Try host.docker.internal first (Docker)
250
+ if getent hosts host.docker.internal > /dev/null 2>&1; then
251
+ echo "host.docker.internal"
252
+ return 0
253
+ fi
254
+ # Fall back to VirtualBox gateway
255
+ echo "10.0.2.2"
256
+ }
257
+
258
+ HOST_IP=$(detect_host)
259
+
260
+ # Send notification
261
+ send_notification() {
262
+ local payload="NOTIFY|${ENHANCED_TITLE}|${ENHANCED_MESSAGE}|${URL:-}|${TIMEOUT}|${TYPE}"
263
+
264
+ local response=$(echo "$payload" | nc -w "$NOTIFICATION_TIMEOUT" "$HOST_IP" "$HOST_PORT" 2>&1)
265
+
266
+ if [ "$response" = "OK" ]; then
267
+ return 0
268
+ elif [ "$response" = "FILTERED" ]; then
269
+ return 2 # Filtered by server config
270
+ else
271
+ return 1 # Connection failed
272
+ fi
273
+ }
274
+
275
+ # Try to send notification
276
+ send_notification
277
+ result=$?
278
+
279
+ if [ $result -eq 0 ]; then
280
+ echo -e "${GREEN}✓${NC} Notification sent to host [${TYPE}]"
281
+ [ -n "$URL" ] && echo -e " ${YELLOW}🔗${NC} Clickable URL: $URL"
282
+ [ -n "$TIMEOUT" ] && echo -e " ⏱ Timeout: ${TIMEOUT}s"
283
+ exit 0
284
+ elif [ $result -eq 2 ]; then
285
+ echo -e "${YELLOW}○${NC} Notification filtered by server config [${TYPE}]"
286
+ exit 0
287
+ else
288
+ echo -e "${YELLOW}⚠${NC} Could not reach notification server on host" >&2
289
+ echo -e " Make sure ${GREEN}vagrant-notify-server${NC} is running on your host machine" >&2
290
+ echo -e " Notification: ${TITLE} - ${MESSAGE}" >&2
291
+
292
+ # Fall back to local notification if available
293
+ if [ -f /usr/bin/notify-send.real ]; then
294
+ /usr/bin/notify-send.real "$TITLE" "$MESSAGE" 2>/dev/null || true
295
+ fi
296
+
297
+ # Log to syslog as last resort
298
+ logger "Claude Notification: $TITLE - $MESSAGE"
299
+ exit 1
300
+ fi
@@ -1,5 +1,5 @@
1
1
  module VagrantPlugins
2
2
  module ClaudeSandbox
3
- VERSION = "0.2.0"
3
+ VERSION = "0.3.1"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vagrant-claude-sandbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bero
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-26 00:00:00.000000000 Z
11
+ date: 2026-02-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -41,18 +41,23 @@ dependencies:
41
41
  description: Provides a pre-configured sandbox environment for running Claude Code
42
42
  in an isolated VM with full plugin and skills support
43
43
  email:
44
- executables: []
44
+ executables:
45
+ - vagrant-notify-server
45
46
  extensions: []
46
47
  extra_rdoc_files: []
47
48
  files:
48
49
  - LICENSE
49
50
  - README.md
51
+ - bin/vagrant-notify-server
50
52
  - lib/docker/Dockerfile
51
53
  - lib/vagrant-claude-sandbox.rb
52
54
  - lib/vagrant-claude-sandbox/command.rb
53
55
  - lib/vagrant-claude-sandbox/config.rb
56
+ - lib/vagrant-claude-sandbox/notification_config.yml.example
57
+ - lib/vagrant-claude-sandbox/notification_server.rb
54
58
  - lib/vagrant-claude-sandbox/path_fixer.rb
55
59
  - lib/vagrant-claude-sandbox/plugin.rb
60
+ - lib/vagrant-claude-sandbox/scripts/claude-notify
56
61
  - lib/vagrant-claude-sandbox/version.rb
57
62
  homepage: https://github.com/bgrgicak/vagrant-claude-sandbox
58
63
  licenses: