devex 0.3.5

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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/.obsidian/app.json +6 -0
  3. data/.obsidian/appearance.json +4 -0
  4. data/.obsidian/community-plugins.json +5 -0
  5. data/.obsidian/core-plugins.json +33 -0
  6. data/.obsidian/plugins/obsidian-minimal-settings/data.json +34 -0
  7. data/.obsidian/plugins/obsidian-minimal-settings/main.js +8 -0
  8. data/.obsidian/plugins/obsidian-minimal-settings/manifest.json +11 -0
  9. data/.obsidian/plugins/obsidian-style-settings/data.json +15 -0
  10. data/.obsidian/plugins/obsidian-style-settings/main.js +165 -0
  11. data/.obsidian/plugins/obsidian-style-settings/manifest.json +10 -0
  12. data/.obsidian/plugins/obsidian-style-settings/styles.css +243 -0
  13. data/.obsidian/plugins/table-editor-obsidian/data.json +6 -0
  14. data/.obsidian/plugins/table-editor-obsidian/main.js +236 -0
  15. data/.obsidian/plugins/table-editor-obsidian/manifest.json +17 -0
  16. data/.obsidian/plugins/table-editor-obsidian/styles.css +78 -0
  17. data/.obsidian/themes/AnuPpuccin/manifest.json +7 -0
  18. data/.obsidian/themes/AnuPpuccin/theme.css +9080 -0
  19. data/.obsidian/themes/Minimal/manifest.json +8 -0
  20. data/.obsidian/themes/Minimal/theme.css +2251 -0
  21. data/.rubocop.yml +231 -0
  22. data/CHANGELOG.md +97 -0
  23. data/LICENSE +21 -0
  24. data/README.md +314 -0
  25. data/Rakefile +13 -0
  26. data/devex-logo.jpg +0 -0
  27. data/docs/developing-tools.md +1000 -0
  28. data/docs/ref/agent-mode.md +46 -0
  29. data/docs/ref/cli-interface.md +60 -0
  30. data/docs/ref/configuration.md +46 -0
  31. data/docs/ref/design-philosophy.md +17 -0
  32. data/docs/ref/error-handling.md +38 -0
  33. data/docs/ref/io-handling.md +88 -0
  34. data/docs/ref/signals.md +141 -0
  35. data/docs/ref/temporal-software-theory.md +790 -0
  36. data/exe/dx +52 -0
  37. data/lib/devex/builtins/.index.rb +10 -0
  38. data/lib/devex/builtins/debug.rb +43 -0
  39. data/lib/devex/builtins/format.rb +44 -0
  40. data/lib/devex/builtins/gem.rb +77 -0
  41. data/lib/devex/builtins/lint.rb +61 -0
  42. data/lib/devex/builtins/test.rb +76 -0
  43. data/lib/devex/builtins/version.rb +156 -0
  44. data/lib/devex/cli.rb +340 -0
  45. data/lib/devex/context.rb +433 -0
  46. data/lib/devex/core/configuration.rb +136 -0
  47. data/lib/devex/core.rb +79 -0
  48. data/lib/devex/dirs.rb +210 -0
  49. data/lib/devex/dsl.rb +100 -0
  50. data/lib/devex/exec/controller.rb +245 -0
  51. data/lib/devex/exec/result.rb +229 -0
  52. data/lib/devex/exec.rb +662 -0
  53. data/lib/devex/loader.rb +136 -0
  54. data/lib/devex/output.rb +257 -0
  55. data/lib/devex/project_paths.rb +309 -0
  56. data/lib/devex/support/ansi.rb +437 -0
  57. data/lib/devex/support/core_ext.rb +560 -0
  58. data/lib/devex/support/global.rb +68 -0
  59. data/lib/devex/support/path.rb +357 -0
  60. data/lib/devex/support.rb +71 -0
  61. data/lib/devex/template_helpers.rb +136 -0
  62. data/lib/devex/templates/debug.erb +24 -0
  63. data/lib/devex/tool.rb +374 -0
  64. data/lib/devex/version.rb +5 -0
  65. data/lib/devex/working_dir.rb +99 -0
  66. data/lib/devex.rb +158 -0
  67. data/ruby-project-template/.gitignore +0 -0
  68. data/ruby-project-template/Gemfile +0 -0
  69. data/ruby-project-template/README.md +0 -0
  70. data/ruby-project-template/docs/README.md +0 -0
  71. data/sig/devex.rbs +4 -0
  72. metadata +122 -0
data/lib/devex/exec.rb ADDED
@@ -0,0 +1,662 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "exec/result"
4
+ require_relative "exec/controller"
5
+
6
+ module Devex
7
+ # External command execution with automatic environment orchestration.
8
+ #
9
+ # This module provides the primary interface for running external commands
10
+ # from devex tools. All methods handle the environment stack automatically:
11
+ #
12
+ # [dotenv] [mise exec --] [bundle exec] command
13
+ #
14
+ # This means `run "rspec"` automatically:
15
+ # - Activates mise versions if .mise.toml or .tool-versions present
16
+ # - Runs through bundle exec if Gemfile present and command looks like a gem
17
+ # - Cleans RUBYOPT/BUNDLE_* from devex's own bundler context
18
+ #
19
+ # Dotenv requires explicit opt-in:
20
+ # run "rspec", dotenv: true
21
+ #
22
+ # Wrapper control:
23
+ # run "rspec" # auto-detect mise and bundle
24
+ # run "rspec", mise: false # skip mise wrapping
25
+ # run "rspec", bundle: false # skip bundle exec
26
+ # run "rspec", raw: true # skip all wrappers
27
+ # run "rspec", dotenv: true # explicitly enable dotenv
28
+ #
29
+ # See ADR-001-external-commands-v2.md for full specification.
30
+ #
31
+ # @example Basic usage
32
+ # include Devex::Exec
33
+ #
34
+ # run "bundle", "install"
35
+ # run("test").exit_on_failure!
36
+ #
37
+ # if run? "which", "rubocop"
38
+ # run "rubocop", "--autocorrect"
39
+ # end
40
+ #
41
+ # result = capture "git", "rev-parse", "HEAD"
42
+ # puts result.stdout.strip
43
+ #
44
+ module Exec
45
+ # ─────────────────────────────────────────────────────────────
46
+ # Core Commands
47
+ # ─────────────────────────────────────────────────────────────
48
+
49
+ # Run a command, streaming output, waiting for completion.
50
+ #
51
+ # This is the workhorse method. Output streams to terminal by default.
52
+ # Returns a Result object for inspection; never raises on non-zero exit.
53
+ #
54
+ # @param cmd [Array<String>] Command and arguments
55
+ # @param env [Hash] Additional environment variables
56
+ # @param chdir [String, Path] Working directory
57
+ # @param raw [Boolean] Skip environment stack entirely (no wrappers)
58
+ # @param bundle [Symbol, Boolean] :auto (default), true, or false
59
+ # @param mise [Symbol, Boolean] :auto (default), true, or false
60
+ # @param dotenv [Boolean] false (default), true to enable dotenv wrapper
61
+ # @param clean_env [Boolean] Clean devex's bundler pollution (default: true)
62
+ # @param timeout [Float, nil] Seconds before killing
63
+ # @param out [Symbol] :inherit (default), :capture, :null
64
+ # @param err [Symbol] :inherit (default), :capture, :null, [:child, :out]
65
+ # @return [Result] Execution result
66
+ #
67
+ # @example Simple
68
+ # run "bundle", "install"
69
+ #
70
+ # @example Check result
71
+ # result = run "make", "test"
72
+ # exit 1 if result.failed?
73
+ #
74
+ # @example Chain with early exit
75
+ # run("lint").then { run("test") }.exit_on_failure!
76
+ #
77
+ # @note Use `cmd` alias in tools to avoid conflict with `def run` entry point
78
+ #
79
+ def run(*cmd, **) = execute_command(cmd, **)
80
+
81
+ # Alias for `run` - use this in tools to avoid conflict with `def run`
82
+ alias cmd run
83
+
84
+ # Test if a command succeeds (exit code 0).
85
+ #
86
+ # Output is discarded. Returns boolean directly.
87
+ #
88
+ # @param cmd [Array<String>] Command and arguments
89
+ # @return [Boolean] true if exit code is 0
90
+ #
91
+ # @example
92
+ # if run? "which", "rubocop"
93
+ # run "rubocop"
94
+ # end
95
+ #
96
+ def run?(*cmd, **) = execute_command(cmd, **, out: :null, err: :null).success?
97
+
98
+ # Alias for `run?` - use this in tools to avoid conflict with `def run`
99
+ alias cmd? run?
100
+
101
+ # Run a command and capture its output.
102
+ #
103
+ # Output is collected into Result.stdout and Result.stderr
104
+ # instead of streaming to terminal.
105
+ #
106
+ # @param cmd [Array<String>] Command and arguments
107
+ # @return [Result] Result with .stdout and .stderr populated
108
+ #
109
+ # @example
110
+ # result = capture "git", "rev-parse", "HEAD"
111
+ # commit = result.stdout.strip
112
+ #
113
+ def capture(*cmd, **) = execute_command(cmd, **, out: :capture, err: :capture)
114
+
115
+ # Start a command in the background without waiting.
116
+ #
117
+ # Returns immediately with a Controller for managing the process.
118
+ # By default, stdout/stderr go to /dev/null (configurable).
119
+ #
120
+ # @param cmd [Array<String>] Command and arguments
121
+ # @param name [String, nil] Optional identifier
122
+ # @param stdin [Symbol] :null (default), :pipe, :inherit
123
+ # @param stdout [Symbol] :null (default), :pipe, :inherit
124
+ # @param stderr [Symbol] :null (default), :pipe, :inherit
125
+ # @return [Controller] Handle for the background process
126
+ #
127
+ # @example
128
+ # server = spawn "rails", "server"
129
+ # # ... do other work ...
130
+ # server.kill(:TERM)
131
+ # server.result
132
+ #
133
+ def spawn(*cmd, name: nil, stdin: :null, stdout: :null, stderr: :null, **)
134
+ spawn_command(cmd, name: name, stdin: stdin, stdout: stdout, stderr: stderr, **)
135
+ end
136
+
137
+ # Replace the current process with a command.
138
+ #
139
+ # This never returns. The current Ruby process is replaced entirely.
140
+ # The bang (!) indicates this is irreversible.
141
+ #
142
+ # @param cmd [Array<String>] Command and arguments
143
+ # @return [void] Never returns
144
+ #
145
+ # @example
146
+ # exec! "vim", filename
147
+ # # This line never executes
148
+ #
149
+ def exec!(*cmd, **)
150
+ prepared = prepare_command(cmd, **)
151
+ Kernel.exec(prepared[:env], *prepared[:command], **prepared[:spawn_opts])
152
+ end
153
+
154
+ # ─────────────────────────────────────────────────────────────
155
+ # Shell Commands
156
+ # ─────────────────────────────────────────────────────────────
157
+
158
+ # Run a command through the shell.
159
+ #
160
+ # Use this when you need shell features: pipes, globs, variables.
161
+ # The string is passed to /bin/sh -c "...".
162
+ #
163
+ # @param command_string [String] Shell command
164
+ # @return [Result] Execution result
165
+ #
166
+ # @example
167
+ # shell "grep TODO **/*.rb | wc -l"
168
+ # shell "echo $HOME"
169
+ #
170
+ # @note Security: Never interpolate untrusted input
171
+ #
172
+ def shell(command_string, **) = execute_command(["/bin/sh", "-c", command_string], **, shell: true)
173
+
174
+ # Test if a shell command succeeds.
175
+ #
176
+ # @param command_string [String] Shell command
177
+ # @return [Boolean] true if exit code is 0
178
+ #
179
+ # @example
180
+ # if shell? "command -v docker"
181
+ # shell "docker compose up -d"
182
+ # end
183
+ #
184
+ def shell?(command_string, **) = shell(command_string, **, out: :null, err: :null).success?
185
+
186
+ # ─────────────────────────────────────────────────────────────
187
+ # Specialized Commands
188
+ # ─────────────────────────────────────────────────────────────
189
+
190
+ # Run Ruby with clean environment.
191
+ #
192
+ # Uses the project's Ruby version (via mise if configured).
193
+ # Cleans RUBYOPT and bundler pollution.
194
+ #
195
+ # @param args [Array<String>] Arguments to ruby
196
+ # @return [Result] Execution result
197
+ #
198
+ # @example
199
+ # ruby "-e", "puts RUBY_VERSION"
200
+ # ruby "script.rb", "--verbose"
201
+ #
202
+ def ruby(*args, **) = execute_command(["ruby", *args], **, clean_env: true)
203
+
204
+ # Run another tool programmatically.
205
+ #
206
+ # Propagates call tree so child tool knows it was invoked from parent.
207
+ # Inherits verbosity and format settings.
208
+ #
209
+ # @param tool_name [String] Name of the tool to run
210
+ # @param args [Array<String>] Arguments for the tool
211
+ # @param capture [Boolean] Capture output instead of streaming
212
+ # @return [Result] Execution result
213
+ #
214
+ # @example
215
+ # tool "lint", "--fix"
216
+ # tool "version", capture: true
217
+ #
218
+ def tool(tool_name, *args, capture: false, **opts)
219
+ # Get executable name and env prefix from CLI config if available
220
+ exe_name = respond_to?(:cli) && cli ? cli.executable_name : "dx"
221
+ env_prefix = respond_to?(:cli) && cli&.config ? cli.config.env_prefix : "DX"
222
+
223
+ # Propagate call tree
224
+ call_tree_var = "#{env_prefix}_CALL_TREE"
225
+ current_var = "#{env_prefix}_CURRENT_TOOL"
226
+ invoked_var = "#{env_prefix}_INVOKED_FROM_TOOL"
227
+
228
+ call_tree = ENV.fetch(call_tree_var, "")
229
+ current_tool = ENV.fetch(current_var, "")
230
+ new_tree = call_tree.empty? ? current_tool : "#{call_tree}:#{current_tool}"
231
+
232
+ env = opts[:env] || {}
233
+ env = env.merge(
234
+ call_tree_var => new_tree,
235
+ invoked_var => "1"
236
+ )
237
+
238
+ cmd = [exe_name, tool_name, *args]
239
+ if capture
240
+ execute_command(cmd, **opts, env: env, out: :capture, err: :capture)
241
+ else
242
+ execute_command(cmd, **opts, env: env)
243
+ end
244
+ end
245
+
246
+ # Test if a tool succeeds.
247
+ #
248
+ # @param tool_name [String] Name of the tool to run
249
+ # @param args [Array<String>] Arguments for the tool
250
+ # @return [Boolean] true if exit code is 0
251
+ #
252
+ def tool?(tool_name, *, **) = tool(tool_name, *, **, capture: true).success?
253
+
254
+ private
255
+
256
+ # ─────────────────────────────────────────────────────────────
257
+ # Command Execution Engine
258
+ # ─────────────────────────────────────────────────────────────
259
+
260
+ def execute_command(cmd, **opts)
261
+ prepared = prepare_command(cmd, **opts)
262
+ start_time = Time.now
263
+
264
+ begin
265
+ stdout_data, stderr_data, status = run_with_streams(prepared)
266
+ duration = Time.now - start_time
267
+
268
+ Result.from_status(
269
+ status,
270
+ command: prepared[:original_command],
271
+ duration: duration,
272
+ stdout: stdout_data,
273
+ stderr: stderr_data,
274
+ options: opts
275
+ )
276
+ rescue Errno::ENOENT, Errno::EACCES => e
277
+ Result.from_exception(
278
+ e,
279
+ command: prepared[:original_command],
280
+ duration: Time.now - start_time,
281
+ options: opts
282
+ )
283
+ end
284
+ end
285
+
286
+ def spawn_command(cmd, name:, stdin:, stdout:, stderr:, **opts)
287
+ prepared = prepare_command(cmd, **opts)
288
+
289
+ spawn_opts = prepared[:spawn_opts].dup
290
+ stdin_pipe = nil
291
+ stdout_pipe = nil
292
+ stderr_pipe = nil
293
+
294
+ # Configure stdin
295
+ case stdin
296
+ when :null then spawn_opts[:in] = "/dev/null"
297
+ when :inherit then spawn_opts[:in] = $stdin
298
+ when :pipe
299
+ stdin_read, stdin_write = IO.pipe
300
+ spawn_opts[:in] = stdin_read
301
+ stdin_pipe = stdin_write
302
+ end
303
+
304
+ # Configure stdout
305
+ case stdout
306
+ when :null then spawn_opts[:out] = "/dev/null"
307
+ when :inherit then spawn_opts[:out] = $stdout
308
+ when :pipe
309
+ stdout_read, stdout_write = IO.pipe
310
+ spawn_opts[:out] = stdout_write
311
+ stdout_pipe = stdout_read
312
+ end
313
+
314
+ # Configure stderr
315
+ case stderr
316
+ when :null then spawn_opts[:err] = "/dev/null"
317
+ when :inherit then spawn_opts[:err] = $stderr
318
+ when :pipe
319
+ stderr_read, stderr_write = IO.pipe
320
+ spawn_opts[:err] = stderr_write
321
+ stderr_pipe = stderr_read
322
+ when Array
323
+ # [:child, :out] merges stderr into stdout
324
+ spawn_opts[:err] = stderr if stderr[0] == :child
325
+ end
326
+
327
+ pid = Process.spawn(prepared[:env], *prepared[:command], **spawn_opts)
328
+
329
+ # Close parent's copy of write ends
330
+ stdin_read&.close
331
+ stdout_write&.close
332
+ stderr_write&.close
333
+
334
+ Controller.new(
335
+ pid: pid,
336
+ command: prepared[:original_command],
337
+ name: name,
338
+ stdin: stdin_pipe,
339
+ stdout: stdout_pipe,
340
+ stderr: stderr_pipe,
341
+ options: opts
342
+ )
343
+ end
344
+
345
+ def run_with_streams(prepared)
346
+ opts = prepared[:spawn_opts]
347
+ out_mode = prepared[:out_mode]
348
+ err_mode = prepared[:err_mode]
349
+ timeout = prepared[:timeout]
350
+
351
+ stdout_data = nil
352
+ stderr_data = nil
353
+
354
+ case [out_mode, err_mode]
355
+ when [:inherit, :inherit]
356
+ # Simple case: just run the command
357
+ pid = Process.spawn(prepared[:env], *prepared[:command], **opts)
358
+ status = wait_with_timeout(pid, timeout, prepared[:original_command])
359
+
360
+ when [:capture, :capture]
361
+ # Capture both streams
362
+ stdout_data, stderr_data, status = capture_streams(prepared, timeout)
363
+
364
+ when [:null, :null]
365
+ # Discard both
366
+ opts = opts.merge(out: "/dev/null", err: "/dev/null")
367
+ pid = Process.spawn(prepared[:env], *prepared[:command], **opts)
368
+ status = wait_with_timeout(pid, timeout, prepared[:original_command])
369
+
370
+ else
371
+ # Mixed modes - use Open3
372
+ stdout_data, stderr_data, status = capture_streams(prepared, timeout)
373
+ stdout_data = nil if out_mode == :null
374
+ stderr_data = nil if err_mode == :null
375
+ end
376
+
377
+ [stdout_data, stderr_data, status]
378
+ end
379
+
380
+ def capture_streams(prepared, timeout)
381
+ require "open3"
382
+
383
+ stdout_data = +""
384
+ stderr_data = +""
385
+
386
+ Open3.popen3(prepared[:env], *prepared[:command], **prepared[:spawn_opts]) do |stdin, stdout, stderr, wait_thr|
387
+ stdin.close
388
+
389
+ # Read both streams (could be improved with select for large outputs)
390
+ threads = []
391
+ threads << Thread.new { stdout_data << stdout.read }
392
+ threads << Thread.new { stderr_data << stderr.read }
393
+
394
+ if timeout
395
+ deadline = Time.now + timeout
396
+ remaining = timeout
397
+ loop do
398
+ if threads.all?(&:stop?)
399
+ threads.each(&:join)
400
+ break
401
+ end
402
+
403
+ remaining = deadline - Time.now
404
+ if remaining <= 0
405
+ begin
406
+ Process.kill(:TERM, wait_thr.pid)
407
+ rescue StandardError
408
+ nil
409
+ end
410
+ sleep 0.1
411
+ begin
412
+ Process.kill(:KILL, wait_thr.pid)
413
+ rescue StandardError
414
+ nil
415
+ end
416
+ threads.each do |t|
417
+ t.kill
418
+ rescue StandardError
419
+ nil
420
+ end
421
+ return [stdout_data, stderr_data, build_timeout_status(wait_thr)]
422
+ end
423
+
424
+ sleep 0.05
425
+ end
426
+ else
427
+ threads.each(&:join)
428
+ end
429
+
430
+ [stdout_data, stderr_data, wait_thr.value]
431
+ end
432
+ end
433
+
434
+ def wait_with_timeout(pid, timeout, _command)
435
+ return Process.wait2(pid)[1] unless timeout
436
+
437
+ deadline = Time.now + timeout
438
+ loop do
439
+ result, status = Process.wait2(pid, Process::WNOHANG)
440
+ return status if result
441
+
442
+ remaining = deadline - Time.now
443
+ if remaining <= 0
444
+ begin
445
+ Process.kill(:TERM, pid)
446
+ rescue StandardError
447
+ nil
448
+ end
449
+ sleep 0.1
450
+ begin
451
+ Process.kill(:KILL, pid)
452
+ rescue StandardError
453
+ nil
454
+ end
455
+ _, status = Process.wait2(pid)
456
+ return build_timeout_status_from_status(status)
457
+ end
458
+
459
+ sleep([0.05, remaining].min)
460
+ end
461
+ end
462
+
463
+ def build_timeout_status(wait_thr)
464
+ wait_thr.value
465
+ rescue StandardError
466
+ # Fake a timed-out status
467
+ TimeoutStatus.new
468
+ end
469
+
470
+ def build_timeout_status_from_status(_status) = TimeoutStatus.new
471
+
472
+ # Minimal status object for timeout cases
473
+ class TimeoutStatus
474
+ def pid; 0; end
475
+ def exited?; false; end
476
+ def exitstatus; nil; end
477
+ def signaled?; true; end
478
+ def termsig; 9; end # SIGKILL
479
+ end
480
+
481
+ # ─────────────────────────────────────────────────────────────
482
+ # Command Preparation
483
+ # ─────────────────────────────────────────────────────────────
484
+
485
+ def prepare_command(cmd, **opts)
486
+ cmd = cmd.flatten.map(&:to_s)
487
+ original_command = cmd.dup
488
+
489
+ env = (opts[:env] || {}).transform_keys(&:to_s).transform_values(&:to_s)
490
+ spawn_opts = {}
491
+
492
+ # Working directory
493
+ if opts[:chdir]
494
+ chdir = opts[:chdir]
495
+ chdir = chdir.to_s if chdir.respond_to?(:to_s) && !chdir.is_a?(String)
496
+ spawn_opts[:chdir] = chdir
497
+ end
498
+
499
+ # Environment stack (unless raw mode)
500
+ env, cmd = apply_environment_stack(env, cmd, opts) unless opts[:raw]
501
+
502
+ {
503
+ env: env,
504
+ command: cmd,
505
+ original_command: original_command,
506
+ spawn_opts: spawn_opts,
507
+ out_mode: opts.fetch(:out, :inherit),
508
+ err_mode: opts.fetch(:err, :inherit),
509
+ timeout: opts[:timeout]
510
+ }
511
+ end
512
+
513
+ # Apply the environment wrapper chain in order:
514
+ # [dotenv] [mise exec --] [bundle exec] command
515
+ #
516
+ # Each wrapper is applied from inside-out, so we process:
517
+ # 1. bundle exec (innermost, around the command)
518
+ # 2. mise exec -- (wraps bundle exec)
519
+ # 3. dotenv (outermost wrapper)
520
+ #
521
+ def apply_environment_stack(env, cmd, opts)
522
+ # Clean devex's bundler pollution (default: true unless clean_env: false)
523
+ env = clean_bundler_env(env) if opts.fetch(:clean_env, true) && defined?(Bundler)
524
+
525
+ # Bundle exec wrapping (if appropriate)
526
+ cmd = maybe_bundle_exec(cmd, opts) unless opts[:shell] || opts[:bundle] == false
527
+
528
+ # Mise exec wrapping (if detected, unless disabled)
529
+ cmd = maybe_mise_exec(cmd, opts) unless opts[:shell]
530
+
531
+ # Dotenv wrapping (explicit opt-in only)
532
+ cmd = with_dotenv(cmd, opts) if opts[:dotenv] == true
533
+
534
+ [env, cmd]
535
+ end
536
+
537
+ def clean_bundler_env(env)
538
+ # Keys that bundler sets that we want to clear
539
+ bundler_keys = %w[
540
+ BUNDLE_GEMFILE
541
+ BUNDLE_BIN_PATH
542
+ BUNDLE_PATH
543
+ BUNDLER_VERSION
544
+ BUNDLER_SETUP
545
+ RUBYOPT
546
+ RUBYLIB
547
+ GEM_HOME
548
+ GEM_PATH
549
+ ]
550
+
551
+ # Start with current env, remove bundler pollution
552
+ clean = ENV.to_h.dup
553
+ bundler_keys.each { |k| clean.delete(k) }
554
+
555
+ # Apply user's env additions
556
+ clean.merge(env)
557
+ end
558
+
559
+ def maybe_bundle_exec(cmd, opts)
560
+ return cmd if opts[:bundle] == false
561
+ return cmd if cmd.first == "bundle"
562
+ return cmd unless gemfile_present?
563
+
564
+ # Check if this looks like a gem command
565
+ if opts[:bundle] == true || looks_like_gem_command?(cmd.first)
566
+ ["bundle", "exec", *cmd]
567
+ else
568
+ cmd
569
+ end
570
+ end
571
+
572
+ def gemfile_present?
573
+ # Cache this for the process lifetime
574
+
575
+ # Check in working directory or project root
576
+ @gemfile_present ||= File.exist?("Gemfile") ||
577
+ (defined?(Devex::Dirs) && Devex::Dirs.in_project? &&
578
+ File.exist?(File.join(Devex::Dirs.project_dir.to_s, "Gemfile")))
579
+ end
580
+
581
+ # Heuristic: is this likely a Ruby gem executable?
582
+ def looks_like_gem_command?(cmd)
583
+ # Common gem commands
584
+ gem_commands = %w[
585
+ rake rspec rubocop standardrb steep rbs
586
+ rails sidekiq puma unicorn thin
587
+ bundler bundle
588
+ erb rdoc ri
589
+ yard
590
+ ]
591
+
592
+ return true if gem_commands.include?(cmd)
593
+
594
+ # Check if it's in bundle's bin stubs
595
+ # This is a simplification; could check actual Gemfile.lock
596
+ false
597
+ end
598
+
599
+ # ─────────────────────────────────────────────────────────────
600
+ # Mise Wrapper
601
+ # ─────────────────────────────────────────────────────────────
602
+
603
+ # Wrap command with `mise exec --` if mise is detected and enabled.
604
+ #
605
+ # @param cmd [Array<String>] Command to potentially wrap
606
+ # @param opts [Hash] Options (mise: :auto, true, or false)
607
+ # @return [Array<String>] Command, possibly wrapped
608
+ #
609
+ def maybe_mise_exec(cmd, opts)
610
+ return cmd if opts[:mise] == false
611
+ return cmd if cmd.first == "mise"
612
+
613
+ # :auto (default) - detect; true - always use mise
614
+ use_mise = case opts[:mise]
615
+ when true then true
616
+ when false then false
617
+ else mise_detected? # :auto or nil
618
+ end
619
+
620
+ return cmd unless use_mise
621
+
622
+ # Wrap with mise exec --
623
+ ["mise", "exec", "--", *cmd]
624
+ end
625
+
626
+ # Check if mise is configured in the project.
627
+ # Caches the result for the process lifetime.
628
+ #
629
+ def mise_detected?
630
+ # Use :unset sentinel since nil is a valid cache value
631
+ @mise_detected = detect_mise_files if @mise_detected.nil?
632
+ @mise_detected
633
+ end
634
+
635
+ def detect_mise_files
636
+ # Check in current directory first
637
+ return true if File.exist?(".mise.toml") || File.exist?(".tool-versions")
638
+
639
+ # Check in project root if we're in a project
640
+ if defined?(Devex::Dirs) && Devex::Dirs.in_project?
641
+ project_dir = Devex::Dirs.project_dir.to_s
642
+ File.exist?(File.join(project_dir, ".mise.toml")) ||
643
+ File.exist?(File.join(project_dir, ".tool-versions"))
644
+ else
645
+ false
646
+ end
647
+ end
648
+
649
+ # ─────────────────────────────────────────────────────────────
650
+ # Dotenv Wrapper
651
+ # ─────────────────────────────────────────────────────────────
652
+
653
+ # Wrap command with `dotenv` to load .env files.
654
+ # Only used when explicitly requested (dotenv: true).
655
+ #
656
+ # @param cmd [Array<String>] Command to wrap
657
+ # @param opts [Hash] Options (unused currently, for future .env path override)
658
+ # @return [Array<String>] Command wrapped with dotenv
659
+ #
660
+ def with_dotenv(cmd, _opts) = ["dotenv", *cmd]
661
+ end
662
+ end