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
@@ -0,0 +1,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devex
4
+ # Runtime context detection for CLI applications.
5
+ #
6
+ # Detects whether we're running in a terminal, CI, agent mode, etc.
7
+ # This informs output formatting, interactivity, and behavior.
8
+ #
9
+ # Detection hierarchy (highest to lowest priority):
10
+ # 1. Programmatic overrides (for testing)
11
+ # 2. Explicit environment variables ({PREFIX}_AGENT_MODE, etc.)
12
+ # 3. CI environment detection
13
+ # 4. Terminal/stream auto-detection
14
+ #
15
+ # Also tracks:
16
+ # - Task invocation call tree (which task invoked which)
17
+ # - Environment (development, test, staging, production)
18
+ #
19
+ # @example Core usage with custom env prefix
20
+ # config = Devex::Core::Configuration.new(env_prefix: "MYCLI")
21
+ # Devex::Context.configure(config)
22
+ # # Now checks MYCLI_AGENT_MODE, MYCLI_ENV, etc.
23
+ #
24
+ # See docs/ref/agent-mode.md and docs/ref/io-handling.md for rationale.
25
+ #
26
+ module Context
27
+ # Default environment variable names (dx-specific, for backward compatibility)
28
+ DEFAULT_ENV_AGENT_MODE = %w[DX_AGENT_MODE DEVEX_AGENT_MODE].freeze
29
+ DEFAULT_ENV_BATCH = %w[DX_BATCH DEVEX_BATCH].freeze
30
+ DEFAULT_ENV_INTERACTIVE = %w[DX_INTERACTIVE DEVEX_INTERACTIVE].freeze
31
+ DEFAULT_ENV_NO_COLOR = %w[NO_COLOR DX_NO_COLOR].freeze
32
+ DEFAULT_ENV_FORCE_COLOR = %w[FORCE_COLOR DX_FORCE_COLOR].freeze
33
+ DEFAULT_ENV_ENVIRONMENT = %w[DX_ENV DEVEX_ENV RAILS_ENV RACK_ENV].freeze
34
+ DEFAULT_ENV_CALL_TREE = "DX_CALL_TREE"
35
+
36
+ # Backward compatibility aliases
37
+ ENV_AGENT_MODE = DEFAULT_ENV_AGENT_MODE
38
+ ENV_BATCH = DEFAULT_ENV_BATCH
39
+ ENV_INTERACTIVE = DEFAULT_ENV_INTERACTIVE
40
+ ENV_NO_COLOR = DEFAULT_ENV_NO_COLOR
41
+ ENV_FORCE_COLOR = DEFAULT_ENV_FORCE_COLOR
42
+ ENV_ENVIRONMENT = DEFAULT_ENV_ENVIRONMENT
43
+ ENV_CALL_TREE = DEFAULT_ENV_CALL_TREE
44
+
45
+ DEFAULT_ENVIRONMENT = "development"
46
+
47
+ # Canonical environment names and their aliases
48
+ ENVIRONMENT_ALIASES = {
49
+ "dev" => "development",
50
+ "develop" => "development",
51
+ "test" => "test",
52
+ "testing" => "test",
53
+ "stage" => "staging",
54
+ "stg" => "staging",
55
+ "prod" => "production",
56
+ "live" => "production"
57
+ }.freeze
58
+
59
+ # Common CI environment variables
60
+ CI_ENV_VARS = %w[
61
+ CI
62
+ CONTINUOUS_INTEGRATION
63
+ GITHUB_ACTIONS
64
+ GITLAB_CI
65
+ CIRCLECI
66
+ TRAVIS
67
+ JENKINS_URL
68
+ BUILDKITE
69
+ DRONE
70
+ TEAMCITY_VERSION
71
+ ].freeze
72
+
73
+ # Thread-local call stack for tracking task invocations within a process
74
+ # Format: Array of task names, e.g., ["pre-commit", "test"]
75
+ @call_stack = []
76
+ @call_stack_mutex = Mutex.new
77
+
78
+ # Programmatic overrides for testing and debugging
79
+ # Keys: :agent_mode, :interactive, :color, :ci, :terminal, :env
80
+ @overrides = {}
81
+ @overrides_mutex = Mutex.new
82
+
83
+ # Configuration for custom env prefix (nil = use defaults)
84
+ @config = nil
85
+ @config_mutex = Mutex.new
86
+
87
+ class << self
88
+ # Configure Context with a Core::Configuration
89
+ # @param config [Core::Configuration, nil] configuration with env_prefix
90
+ def configure(config)
91
+ @config_mutex.synchronize { @config = config }
92
+ reset_env! # Clear cached environment
93
+ end
94
+
95
+ # Get current configuration
96
+ # @return [Core::Configuration, nil]
97
+ def configuration
98
+ @config_mutex.synchronize { @config }
99
+ end
100
+
101
+ # Reset configuration (for testing)
102
+ def reset_configuration!
103
+ @config_mutex.synchronize { @config = nil }
104
+ reset_env!
105
+ end
106
+
107
+ # Is stdout connected to a terminal?
108
+ def stdout_tty? = $stdout.tty?
109
+
110
+ # Is stderr connected to a terminal?
111
+ def stderr_tty? = $stderr.tty?
112
+
113
+ # Is stdin connected to a terminal?
114
+ def stdin_tty? = $stdin.tty?
115
+
116
+ # Are we in a full interactive terminal? (all three streams are ttys)
117
+ def terminal?
118
+ override = override_or(:terminal)
119
+ return override unless override.nil?
120
+
121
+ stdin_tty? && stdout_tty? && stderr_tty?
122
+ end
123
+
124
+ # Are stdout and stderr merged (pointing to same file descriptor)?
125
+ # This happens with 2>&1 redirection, common in agent/scripted usage.
126
+ #
127
+ # IMPORTANT: When both streams are TTYs pointing to the same terminal device,
128
+ # that's normal terminal behavior, NOT merging. We only consider streams
129
+ # "merged" when they're redirected to the same non-TTY destination.
130
+ def streams_merged?
131
+ # If either is a TTY, streams aren't "merged" in the problematic sense
132
+ # (a terminal naturally has stdout/stderr going to the same device)
133
+ return false if $stdout.tty? || $stderr.tty?
134
+
135
+ return false unless $stdout.respond_to?(:stat) && $stderr.respond_to?(:stat)
136
+
137
+ begin
138
+ stdout_stat = $stdout.stat
139
+ stderr_stat = $stderr.stat
140
+ stdout_stat.dev == stderr_stat.dev && stdout_stat.ino == stderr_stat.ino
141
+ rescue IOError, Errno::EBADF
142
+ # If we can't stat the streams, assume not merged
143
+ false
144
+ end
145
+ end
146
+
147
+ # Is agent mode explicitly enabled via environment?
148
+ def agent_mode_env?
149
+ env_vars_for(:agent_mode).any? { |var| truthy_env?(var) }
150
+ end
151
+
152
+ # Is batch mode explicitly enabled via environment?
153
+ def batch_mode_env?
154
+ env_vars_for(:batch).any? { |var| truthy_env?(var) }
155
+ end
156
+
157
+ # Is interactive mode explicitly forced via environment?
158
+ def interactive_forced?
159
+ env_vars_for(:interactive).any? { |var| truthy_env?(var) }
160
+ end
161
+
162
+ # Are we running in a CI environment?
163
+ def ci?
164
+ override = override_or(:ci)
165
+ return override unless override.nil?
166
+
167
+ CI_ENV_VARS.any? { |var| ENV.key?(var) && ENV[var] != "" && ENV[var] != "false" }
168
+ end
169
+
170
+ # Is color output explicitly disabled?
171
+ def no_color?
172
+ env_vars_for(:no_color).any? { |var| ENV.key?(var) }
173
+ end
174
+
175
+ # Is color output explicitly forced on?
176
+ def force_color?
177
+ env_vars_for(:force_color).any? { |var| truthy_env?(var) }
178
+ end
179
+
180
+ # Is data being piped in or out?
181
+ # True if stdin is not a tty (data piped in) OR stdout is not a tty (data piped out)
182
+ def piped? = !stdin_tty? || !stdout_tty?
183
+
184
+ # --- Composite detection methods ---
185
+
186
+ # Should we behave as if an AI agent is invoking us?
187
+ # True if:
188
+ # - Agent mode explicitly set, OR
189
+ # - Streams are merged (2>&1), OR
190
+ # - Not a terminal AND not explicitly interactive
191
+ def agent_mode?
192
+ override = override_or(:agent_mode)
193
+ return override unless override.nil?
194
+
195
+ return true if agent_mode_env?
196
+ return false if interactive_forced?
197
+ return true if streams_merged?
198
+ return true if !terminal? && !ci? # Non-tty, non-CI likely means agent
199
+
200
+ false
201
+ end
202
+
203
+ # Should we allow interactive prompts and rich output?
204
+ # True only when we have a real terminal and nothing forces non-interactive
205
+ def interactive?
206
+ override = override_or(:interactive)
207
+ return override unless override.nil?
208
+
209
+ return true if interactive_forced?
210
+ return false if agent_mode_env? || batch_mode_env? || ci?
211
+
212
+ terminal?
213
+ end
214
+
215
+ # Should we use colors in output?
216
+ def color?
217
+ override = override_or(:color)
218
+ return override unless override.nil?
219
+
220
+ return false if no_color?
221
+ return true if force_color?
222
+
223
+ # Default: color if stdout is a tty and not in agent mode
224
+ stdout_tty? && !agent_mode?
225
+ end
226
+
227
+ # --- Environment detection (Rails-style) ---
228
+
229
+ # Get the current environment name (development, test, staging, production)
230
+ # Checks DX_ENV, DEVEX_ENV, RAILS_ENV, RACK_ENV in that order
231
+ def env = @env ||= detect_environment
232
+
233
+ # Reset cached environment (useful for testing)
234
+ def reset_env! = @env = nil
235
+
236
+ def development? = env == "development"
237
+
238
+ def test? = env == "test"
239
+
240
+ def staging? = env == "staging"
241
+
242
+ def production? = env == "production"
243
+
244
+ # Is this a "safe" environment where destructive operations are okay?
245
+ # Development and test are considered safe; staging and production are not.
246
+ def safe_env? = %w[development test].include?(env)
247
+
248
+ # --- Call tree tracking ---
249
+
250
+ # Get the full call tree as an array of task names
251
+ # Combines inherited tree from parent process (via env) with current process stack
252
+ def call_tree = inherited_tree + current_call_stack
253
+
254
+ # Get just the current process's call stack
255
+ def current_call_stack = @call_stack_mutex.synchronize { @call_stack.dup }
256
+
257
+ # Get the call tree inherited from parent process via environment
258
+ def inherited_tree
259
+ tree_str = ENV.fetch(call_tree_env_var, nil)
260
+ return [] if tree_str.nil? || tree_str.empty?
261
+
262
+ tree_str.split(":")
263
+ end
264
+
265
+ # Is this task being invoked from another task?
266
+ def invoked_from_task? = !call_tree.empty?
267
+
268
+ # Get the name of the task that invoked this one (immediate parent)
269
+ def invoking_task
270
+ tree = call_tree
271
+ tree[-1] if tree.any?
272
+ end
273
+
274
+ # Get the root task that started the chain
275
+ def root_task
276
+ tree = call_tree
277
+ tree[0] if tree.any?
278
+ end
279
+
280
+ # Push a task onto the call stack (called when a task starts)
281
+ def push_task(task_name) = @call_stack_mutex.synchronize { @call_stack.push(task_name) }
282
+
283
+ # Pop a task from the call stack (called when a task completes)
284
+ def pop_task = @call_stack_mutex.synchronize { @call_stack.pop }
285
+
286
+ # Execute a block with a task on the call stack
287
+ def with_task(task_name)
288
+ push_task(task_name)
289
+ yield
290
+ ensure
291
+ pop_task
292
+ end
293
+
294
+ # Reset the call stack (useful for testing)
295
+ def reset_call_stack! = @call_stack_mutex.synchronize { @call_stack.clear }
296
+
297
+ # Summary of current context for debugging/logging
298
+ def summary
299
+ {
300
+ terminal: terminal?,
301
+ stdin_tty: stdin_tty?,
302
+ stdout_tty: stdout_tty?,
303
+ stderr_tty: stderr_tty?,
304
+ streams_merged: streams_merged?,
305
+ ci: ci?,
306
+ piped: piped?,
307
+ agent_mode: agent_mode?,
308
+ interactive: interactive?,
309
+ color: color?,
310
+ env: env,
311
+ call_tree: call_tree,
312
+ invoked_from_task: invoked_from_task?
313
+ }
314
+ end
315
+
316
+ # Machine-readable context for passing to subprocesses
317
+ # Include call tree so child processes know their invocation chain
318
+ def to_env
319
+ prefix = env_prefix
320
+ tree = call_tree
321
+ {
322
+ "#{prefix}_AGENT_MODE" => agent_mode? ? "1" : "0",
323
+ "#{prefix}_INTERACTIVE" => interactive? ? "1" : "0",
324
+ "#{prefix}_CI" => ci? ? "1" : "0",
325
+ "#{prefix}_ENV" => env,
326
+ call_tree_env_var => tree.any? ? tree.join(":") : nil
327
+ }.compact
328
+ end
329
+
330
+ # Get the configured env prefix (or default)
331
+ def env_prefix
332
+ cfg = configuration
333
+ cfg&.env_prefix || "DX"
334
+ end
335
+
336
+ # Get the call tree environment variable name
337
+ def call_tree_env_var
338
+ "#{env_prefix}_CALL_TREE"
339
+ end
340
+
341
+ # --- Programmatic overrides for testing ---
342
+
343
+ # Set an override value
344
+ # Valid keys: :agent_mode, :interactive, :color, :ci, :terminal, :env
345
+ def set_override(key, value) = @overrides_mutex.synchronize { @overrides[key] = value }
346
+
347
+ # Clear a specific override
348
+ def clear_override(key) = @overrides_mutex.synchronize { @overrides.delete(key) }
349
+
350
+ # Clear all overrides
351
+ def clear_all_overrides! = @overrides_mutex.synchronize { @overrides.clear }
352
+
353
+ # Get current overrides (for debugging)
354
+ def overrides = @overrides_mutex.synchronize { @overrides.dup }
355
+
356
+ # Execute a block with temporary overrides
357
+ # Example: Context.with_overrides(agent_mode: true, color: false) { ... }
358
+ def with_overrides(**overrides_hash)
359
+ old_overrides = @overrides_mutex.synchronize { @overrides.dup }
360
+ @overrides_mutex.synchronize { @overrides.merge!(overrides_hash) }
361
+ yield
362
+ ensure
363
+ @overrides_mutex.synchronize do
364
+ @overrides.clear
365
+ @overrides.merge!(old_overrides)
366
+ end
367
+ end
368
+
369
+ private
370
+
371
+ # Check for override first, then fall back to detection
372
+ def override_or(key)
373
+ @overrides_mutex.synchronize do
374
+ return @overrides[key] if @overrides.key?(key)
375
+ end
376
+ nil
377
+ end
378
+
379
+ def truthy_env?(var)
380
+ val = ENV.fetch(var, nil)
381
+ val && !val.empty? && val != "0" && val.downcase != "false"
382
+ end
383
+
384
+ # Get environment variable names to check for a given setting.
385
+ # If configuration is set, uses configured prefix only.
386
+ # Otherwise, falls back to default (dx-specific) names.
387
+ def env_vars_for(setting)
388
+ cfg = configuration
389
+ if cfg
390
+ # Use only the configured prefix
391
+ prefix = cfg.env_prefix
392
+ case setting
393
+ when :agent_mode then ["#{prefix}_AGENT_MODE"]
394
+ when :batch then ["#{prefix}_BATCH"]
395
+ when :interactive then ["#{prefix}_INTERACTIVE"]
396
+ when :no_color then ["NO_COLOR", "#{prefix}_NO_COLOR"]
397
+ when :force_color then ["FORCE_COLOR", "#{prefix}_FORCE_COLOR"]
398
+ when :env then ["#{prefix}_ENV", "RAILS_ENV", "RACK_ENV"]
399
+ else []
400
+ end
401
+ else
402
+ # Default (dx-specific) names for backward compatibility
403
+ case setting
404
+ when :agent_mode then DEFAULT_ENV_AGENT_MODE
405
+ when :batch then DEFAULT_ENV_BATCH
406
+ when :interactive then DEFAULT_ENV_INTERACTIVE
407
+ when :no_color then DEFAULT_ENV_NO_COLOR
408
+ when :force_color then DEFAULT_ENV_FORCE_COLOR
409
+ when :env then DEFAULT_ENV_ENVIRONMENT
410
+ else []
411
+ end
412
+ end
413
+ end
414
+
415
+ def detect_environment
416
+ # Check override first
417
+ override = override_or(:env)
418
+ return override if override
419
+
420
+ env_vars_for(:env).each do |var|
421
+ val = ENV.fetch(var, nil)
422
+ next if val.nil? || val.empty?
423
+
424
+ # Normalize the value
425
+ normalized = val.downcase.strip
426
+ return ENVIRONMENT_ALIASES.fetch(normalized, normalized)
427
+ end
428
+
429
+ DEFAULT_ENVIRONMENT
430
+ end
431
+ end
432
+ end
433
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devex
4
+ module Core
5
+ # Configuration for a CLI application built on Devex::Core.
6
+ #
7
+ # Holds all the customizable values that distinguish one CLI from another:
8
+ # executable name, flag prefixes, project markers, environment variable
9
+ # prefixes, and path conventions.
10
+ #
11
+ # @example Building a custom CLI
12
+ # config = Devex::Core::Configuration.new(
13
+ # executable_name: "mycli",
14
+ # project_markers: %w[.mycli.yml .git Gemfile],
15
+ # env_prefix: "MYCLI"
16
+ # )
17
+ # cli = Devex::Core::CLI.new(config: config)
18
+ #
19
+ class Configuration
20
+ # Name of the executable (used in help text, error messages)
21
+ # @return [String]
22
+ attr_accessor :executable_name
23
+
24
+ # Prefix for framework flags (--{prefix}-version, --{prefix}-agent-mode)
25
+ # Defaults to executable_name if not set
26
+ # @return [String]
27
+ attr_writer :flag_prefix
28
+
29
+ # Files/directories that indicate project root, checked in order
30
+ # @return [Array<String>]
31
+ attr_accessor :project_markers
32
+
33
+ # Primary config file name (e.g., ".dx.yml", ".mycli.yml")
34
+ # Set to nil to disable config file support
35
+ # @return [String, nil]
36
+ attr_accessor :config_file
37
+
38
+ # Directory for organized mode (e.g., ".dx", ".mycli")
39
+ # Set to nil to disable organized mode support
40
+ # @return [String, nil]
41
+ attr_accessor :organized_dir
42
+
43
+ # Default tools directory name
44
+ # @return [String]
45
+ attr_accessor :tools_dir
46
+
47
+ # Prefix for environment variables ({PREFIX}_AGENT_MODE, {PREFIX}_ENV)
48
+ # Defaults to executable_name.upcase if not set
49
+ # @return [String]
50
+ attr_writer :env_prefix
51
+
52
+ # Custom path conventions for ProjectPaths
53
+ # Merged with defaults; keys are symbols, values are strings or arrays
54
+ # @return [Hash{Symbol => String, Array<String>}]
55
+ attr_accessor :path_conventions
56
+
57
+ # File that triggers delegation to bundled version (e.g., ".dx-use-local")
58
+ # Set to nil to disable delegation support
59
+ # @return [String, nil]
60
+ attr_accessor :delegation_file
61
+
62
+ def initialize(
63
+ executable_name: "cli",
64
+ flag_prefix: nil,
65
+ project_markers: %w[.git Gemfile Rakefile],
66
+ config_file: nil,
67
+ organized_dir: nil,
68
+ tools_dir: "tools",
69
+ env_prefix: nil,
70
+ path_conventions: {},
71
+ delegation_file: nil
72
+ )
73
+ @executable_name = executable_name
74
+ @flag_prefix = flag_prefix
75
+ @project_markers = project_markers
76
+ @config_file = config_file
77
+ @organized_dir = organized_dir
78
+ @tools_dir = tools_dir
79
+ @env_prefix = env_prefix
80
+ @path_conventions = path_conventions
81
+ @delegation_file = delegation_file
82
+ end
83
+
84
+ # Flag prefix, defaulting to executable_name
85
+ # @return [String]
86
+ def flag_prefix
87
+ @flag_prefix || executable_name
88
+ end
89
+
90
+ # Environment variable prefix, defaulting to executable_name.upcase
91
+ # @return [String]
92
+ def env_prefix
93
+ @env_prefix || executable_name.upcase.tr("-", "_")
94
+ end
95
+
96
+ # Generate an environment variable name with this config's prefix
97
+ # @param name [String, Symbol] variable name (e.g., :agent_mode)
98
+ # @return [String] full env var name (e.g., "DX_AGENT_MODE")
99
+ def env_var(name)
100
+ "#{env_prefix}_#{name.to_s.upcase}"
101
+ end
102
+
103
+ # Generate a flag name with this config's prefix
104
+ # @param name [String, Symbol] flag name (e.g., :version)
105
+ # @return [String] full flag name (e.g., "--dx-version")
106
+ def flag(name)
107
+ "--#{flag_prefix}-#{name.to_s.tr('_', '-')}"
108
+ end
109
+
110
+ # Duplicate this configuration with overrides
111
+ # @param overrides [Hash] values to override
112
+ # @return [Configuration] new configuration instance
113
+ def with(**overrides)
114
+ self.class.new(
115
+ executable_name: overrides.fetch(:executable_name, executable_name),
116
+ flag_prefix: overrides.fetch(:flag_prefix, @flag_prefix),
117
+ project_markers: overrides.fetch(:project_markers, project_markers.dup),
118
+ config_file: overrides.fetch(:config_file, config_file),
119
+ organized_dir: overrides.fetch(:organized_dir, organized_dir),
120
+ tools_dir: overrides.fetch(:tools_dir, tools_dir),
121
+ env_prefix: overrides.fetch(:env_prefix, @env_prefix),
122
+ path_conventions: overrides.fetch(:path_conventions, path_conventions.dup),
123
+ delegation_file: overrides.fetch(:delegation_file, delegation_file)
124
+ )
125
+ end
126
+
127
+ # Freeze this configuration (and nested structures)
128
+ # @return [self]
129
+ def freeze
130
+ @project_markers.freeze
131
+ @path_conventions.freeze
132
+ super
133
+ end
134
+ end
135
+ end
136
+ end
data/lib/devex/core.rb ADDED
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Devex::Core - The CLI framework without dx-specific configuration.
4
+ #
5
+ # Use this entry point to build your own CLI application using the devex
6
+ # framework. This loads all framework components but does NOT load:
7
+ # - dx-specific builtins (version, test, lint, etc.)
8
+ # - dx-specific configuration (.dx.yml, .dx/ directory conventions)
9
+ #
10
+ # @example Building a custom CLI
11
+ # require "devex/core"
12
+ #
13
+ # config = Devex::Core::Configuration.new(
14
+ # executable_name: "mycli",
15
+ # flag_prefix: "mycli",
16
+ # project_markers: %w[.mycli.yml .git Gemfile],
17
+ # env_prefix: "MYCLI"
18
+ # )
19
+ #
20
+ # cli = Devex::Core::CLI.new(config: config)
21
+ # cli.load_tools("/path/to/my/tools")
22
+ # exit cli.run(ARGV)
23
+ #
24
+ # For the full dx CLI with builtins, use: require "devex"
25
+
26
+ # Load support library first (no dependencies)
27
+ require_relative "support/path"
28
+ require_relative "support/ansi"
29
+ require_relative "support/core_ext"
30
+
31
+ # Load version
32
+ require_relative "version"
33
+
34
+ # Load configuration class
35
+ require_relative "core/configuration"
36
+
37
+ # Load framework components in dependency order
38
+ require_relative "context"
39
+ require_relative "output"
40
+ require_relative "template_helpers"
41
+ require_relative "exec" # Must be before tool.rb (ExecutionContext includes Exec)
42
+ require_relative "tool"
43
+ require_relative "dsl"
44
+ require_relative "loader"
45
+ require_relative "cli"
46
+ require_relative "dirs"
47
+ require_relative "project_paths"
48
+ require_relative "working_dir"
49
+
50
+ module Devex
51
+ # Core module - re-exports framework classes for convenient access.
52
+ #
53
+ # All classes are also available directly under Devex:: namespace.
54
+ # The Core module provides a clean namespace for users who want to
55
+ # be explicit about using the framework vs the dx application.
56
+ #
57
+ # Note: Configuration is already defined in core/configuration.rb
58
+ # and is available as Devex::Core::Configuration.
59
+ #
60
+ module Core
61
+ # Re-export key classes at Core level for convenience
62
+ # (Configuration is already defined in core/configuration.rb)
63
+ CLI = Devex::CLI
64
+ Tool = Devex::Tool
65
+ Dirs = Devex::Dirs
66
+ ProjectPaths = Devex::ProjectPaths
67
+ WorkingDir = Devex::WorkingDir
68
+ Context = Devex::Context
69
+ Output = Devex::Output
70
+ Exec = Devex::Exec
71
+
72
+ # Support library
73
+ Path = Devex::Support::Path
74
+ ANSI = Devex::Support::ANSI
75
+
76
+ # Version
77
+ VERSION = Devex::VERSION
78
+ end
79
+ end