Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ruby-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
4.0.2
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
20 changes: 18 additions & 2 deletions bin/codex-notify
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
20 changes: 18 additions & 2 deletions bin/codex-notify-hook
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
15 changes: 14 additions & 1 deletion lib/codex_notify/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion lib/codex_notify/hook_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions test/test_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
60 changes: 60 additions & 0 deletions test/test_hook_config.rb
Original file line number Diff line number Diff line change
@@ -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
Loading