diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..4d54dad --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.2 diff --git a/README.md b/README.md index 332c144..0dd4178 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,8 @@ Variables: CLI flags override environment variables. +When `--env-file` is omitted, `codex-notify` first looks for `.env` in the current working directory and then falls back to the tool's own project root. This helps hook mode when the executable is launched from another repository. + ## Usage Install dependencies first: @@ -157,6 +159,7 @@ Run `codex-notify` separately: ``` The entrypoint loads `bundler/setup`, so `bundle exec` is not required after `bundle install`. +If `rbenv` is available, the entrypoint re-execs itself with the Ruby version from this project's `.ruby-version`, even when launched from another repository. Monitor a specific session file: @@ -310,6 +313,7 @@ Notes: - `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` are the event names. - In hook mode, a prompt containing only `---` clears the saved Slack thread for that Codex session. The next user prompt starts a new Slack thread. - This executable pins `BUNDLE_GEMFILE` to its own project, so it can be launched from other repositories without resolving the wrong `Gemfile`. +- If `rbenv` is installed, the executable also re-execs with the Ruby version declared in this project's `.ruby-version`, so another repository's `.ruby-version` does not take precedence. - The hook implementation keeps normal successful runs quiet so Codex does not show extra debug-style output from the hook itself. Hook mode does not require `--no-alt-screen`, because it does not depend on session-log tailing. diff --git a/bin/codex-notify b/bin/codex-notify index 7d589da..fd9928e 100755 --- a/bin/codex-notify +++ b/bin/codex-notify @@ -1,11 +1,27 @@ #!/usr/bin/env ruby # frozen_string_literal: true -ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) +root = File.expand_path('..', __dir__) +ruby_version_file = File.join(root, '.ruby-version') + +if ENV['CODEX_NOTIFY_RBENV_REEXEC'] != '1' && File.exist?(ruby_version_file) + begin + if system('rbenv', 'version-name', out: File::NULL, err: File::NULL) + ENV['RBENV_DIR'] = root + ENV['BUNDLE_GEMFILE'] = File.join(root, 'Gemfile') + ENV['CODEX_NOTIFY_RBENV_REEXEC'] = '1' + exec('rbenv', 'exec', 'ruby', __FILE__, *ARGV) + end + rescue StandardError + nil + end +end + +ENV['BUNDLE_GEMFILE'] ||= File.join(root, 'Gemfile') require 'bundler/setup' -$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) +$LOAD_PATH.unshift(File.join(root, 'lib')) require 'codex_notify' diff --git a/bin/codex-notify-hook b/bin/codex-notify-hook index 465c072..b00881b 100755 --- a/bin/codex-notify-hook +++ b/bin/codex-notify-hook @@ -1,10 +1,26 @@ #!/usr/bin/env ruby # frozen_string_literal: true -ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) +root = File.expand_path('..', __dir__) +ruby_version_file = File.join(root, '.ruby-version') + +if ENV['CODEX_NOTIFY_RBENV_REEXEC'] != '1' && File.exist?(ruby_version_file) + begin + if system('rbenv', 'version-name', out: File::NULL, err: File::NULL) + ENV['RBENV_DIR'] = root + ENV['BUNDLE_GEMFILE'] = File.join(root, 'Gemfile') + ENV['CODEX_NOTIFY_RBENV_REEXEC'] = '1' + exec('rbenv', 'exec', 'ruby', __FILE__, *ARGV) + end + rescue StandardError + nil + end +end + +ENV['BUNDLE_GEMFILE'] ||= File.join(root, 'Gemfile') require 'bundler/setup' -$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) +$LOAD_PATH.unshift(File.join(root, 'lib')) require 'codex_notify/hook_cli' diff --git a/lib/codex_notify/config.rb b/lib/codex_notify/config.rb index 782999c..f96198e 100644 --- a/lib/codex_notify/config.rb +++ b/lib/codex_notify/config.rb @@ -9,6 +9,7 @@ module CodexNotify module Config DEFAULT_ENV_PATH = '.env' DEFAULT_SESSIONS_DIR = Pathname(File.expand_path('~/.codex/sessions')) + APP_ROOT = Pathname(__dir__).join('../..').expand_path Args = Struct.new( :env_file, @@ -28,8 +29,20 @@ module Config module_function - def load_env_file(path = DEFAULT_ENV_PATH, override: false) + def app_root + APP_ROOT + end + + def resolve_env_path(path = DEFAULT_ENV_PATH) env_path = Pathname(path) + return env_path if env_path.absolute? + + [Pathname(Dir.pwd).join(env_path), app_root.join(env_path)].find(&:exist?) + end + + def load_env_file(path = DEFAULT_ENV_PATH, override: false) + env_path = resolve_env_path(path) + return unless env_path return unless env_path.exist? loader = override ? Dotenv.method(:overload) : Dotenv.method(:load) diff --git a/lib/codex_notify/hook_config.rb b/lib/codex_notify/hook_config.rb index 01e8ef3..65b5628 100644 --- a/lib/codex_notify/hook_config.rb +++ b/lib/codex_notify/hook_config.rb @@ -9,6 +9,7 @@ module CodexNotify module HookConfig DEFAULT_ENV_PATH = '.env' DEFAULT_STATE_PATH = Pathname(File.expand_path('~/.codex-notify-hook/state.json')) + APP_ROOT = Pathname(__dir__).join('../..').expand_path Args = Struct.new( :env_file, @@ -23,8 +24,20 @@ module HookConfig module_function - def load_env_file(path = DEFAULT_ENV_PATH, override: false) + def app_root + APP_ROOT + end + + def resolve_env_path(path = DEFAULT_ENV_PATH) env_path = Pathname(path) + return env_path if env_path.absolute? + + [Pathname(Dir.pwd).join(env_path), app_root.join(env_path)].find(&:exist?) + end + + def load_env_file(path = DEFAULT_ENV_PATH, override: false) + env_path = resolve_env_path(path) + return unless env_path return unless env_path.exist? loader = override ? Dotenv.method(:overload) : Dotenv.method(:load) diff --git a/test/test_config.rb b/test/test_config.rb index c07932b..1ea7d02 100644 --- a/test/test_config.rb +++ b/test/test_config.rb @@ -62,6 +62,33 @@ def test_parse_args_uses_env_file_defaults end end + def test_load_env_file_falls_back_to_app_root_for_relative_path + with_tmpdir do |dir| + env_file = dir.join('.env') + env_file.write("SLACK_BOT_TOKEN=xoxb-app-root\nSLACK_CHANNEL=CROOT\n") + ENV.delete('SLACK_BOT_TOKEN') + ENV.delete('SLACK_CHANNEL') + + original = Config.method(:app_root) + Dir.mktmpdir do |cwd| + Dir.chdir(cwd) do + with_silenced_warnings do + Config.singleton_class.send(:define_method, :app_root) { dir } + end + + Config.load_env_file('.env') + end + end + ensure + with_silenced_warnings do + Config.singleton_class.send(:define_method, :app_root, original) + end + end + + assert_equal 'xoxb-app-root', ENV['SLACK_BOT_TOKEN'] + assert_equal 'CROOT', ENV['SLACK_CHANNEL'] + end + def test_parse_args_prefers_cli_user_name_over_env ENV['CODEX_NOTIFY_USER_NAME'] = 'env-user' diff --git a/test/test_hook_config.rb b/test/test_hook_config.rb new file mode 100644 index 0000000..ddcc5ec --- /dev/null +++ b/test/test_hook_config.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'tmpdir' +require_relative 'test_helper' + +class CodexNotifyHookConfigTest < Minitest::Test + HookConfig = CodexNotify::HookConfig + + def setup + @env_backup = ENV.to_h + end + + def teardown + ENV.replace(@env_backup) + end + + def test_parse_args_falls_back_to_app_root_env_for_relative_path + with_tmpdir do |dir| + env_file = dir.join('.env') + env_file.write("SLACK_BOT_TOKEN=xoxb-hook\nSLACK_CHANNEL=CHOOK\nCODEX_NOTIFY_USER_NAME=hook-user\n") + ENV.delete('SLACK_BOT_TOKEN') + ENV.delete('SLACK_CHANNEL') + ENV.delete('CODEX_NOTIFY_USER_NAME') + + original = HookConfig.method(:app_root) + Dir.mktmpdir do |cwd| + Dir.chdir(cwd) do + with_silenced_warnings do + HookConfig.singleton_class.send(:define_method, :app_root) { dir } + end + + args = HookConfig.parse_args([]) + assert_equal 'xoxb-hook', args.token + assert_equal 'CHOOK', args.channel + assert_equal 'hook-user', args.user_name + end + end + ensure + with_silenced_warnings do + HookConfig.singleton_class.send(:define_method, :app_root, original) + end + end + end + + private + + def with_tmpdir + Dir.mktmpdir do |dir| + yield Pathname(dir) + end + end + + def with_silenced_warnings + original_verbose = $VERBOSE + $VERBOSE = nil + yield + ensure + $VERBOSE = original_verbose + end +end