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/tool.rb ADDED
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devex
4
+ # Represents a CLI tool/command with its metadata, flags, args, and subtools
5
+ class Tool
6
+ attr_accessor :builtin, :desc, :long_desc, :run_block, :source_code, :source_file, :source_proc
7
+ attr_reader :name, :parent, :subtools, :flags, :args, :mixins
8
+
9
+ # Global flag specs that tools cannot override
10
+ # These are handled by CLI before tool flag parsing
11
+ RESERVED_FLAG_SPECS = %w[
12
+ -f --format
13
+ -v --verbose --no-verbose
14
+ -q --quiet --no-quiet
15
+ --color --no-color
16
+ --dx-version --dx-from-dir
17
+ ].freeze
18
+
19
+ def initialize(name, parent: nil)
20
+ @name = name
21
+ @parent = parent
22
+ @desc = nil
23
+ @long_desc = nil
24
+ @flags = []
25
+ @args = []
26
+ @subtools = {}
27
+ @run_block = nil
28
+ @mixins = []
29
+ @builtin = nil # reference to builtin tool if this is an override
30
+ @source_code = nil
31
+ @source_file = nil
32
+ @source_proc = nil
33
+ end
34
+
35
+ # Full command path (e.g., ["version", "bump"])
36
+ def full_path
37
+ if parent
38
+ parent.full_path + [name]
39
+ else
40
+ (name ? [name] : [])
41
+ end
42
+ end
43
+
44
+ # Full command string (e.g., "version bump")
45
+ def full_name = full_path.join(" ")
46
+
47
+ # Define a flag
48
+ # Examples:
49
+ # flag :verbose, "-v", "--verbose", desc: "Enable verbose output"
50
+ # flag :file, "-f FILE", "--file=FILE", desc: "Input file"
51
+ def flag(name, *specs, desc: nil, default: nil) = @flags << Flag.new(name, specs, desc: desc, default: default)
52
+
53
+ # Check that flag specs don't conflict with global flags (called at execute time)
54
+ def validate_flags!
55
+ @flags.each do |flag|
56
+ flag.specs.each do |spec|
57
+ # Extract the flag part (before space or =)
58
+ flag_part = spec.split(/[\s=]/).first
59
+ if RESERVED_FLAG_SPECS.include?(flag_part)
60
+ raise Error, <<~ERR.chomp
61
+ Tool '#{full_name}': flag '#{flag_part}' conflicts with global flag
62
+
63
+ Global flags like #{flag_part} are handled before tool execution.
64
+ Your tool can access the global value via:
65
+ verbose? # for -v/--verbose
66
+ global_options[:format] # for -f/--format
67
+ global_options[:quiet] # for -q/--quiet
68
+
69
+ To fix: remove this flag definition or use a different flag name.
70
+ ERR
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ # Define a required positional argument
77
+ def required_arg(name, desc: nil) = @args << Arg.new(name, required: true, desc: desc)
78
+
79
+ # Define an optional positional argument
80
+ def optional_arg(name, desc: nil, default: nil) = @args << Arg.new(name, required: false, desc: desc, default: default)
81
+
82
+ # Define remaining args (variadic)
83
+ def remaining_args(name, desc: nil) = @args << Arg.new(name, required: false, desc: desc, remaining: true)
84
+
85
+ # Add a subtool
86
+ def add_subtool(tool) = @subtools[tool.name] = tool
87
+
88
+ # Find a subtool by name
89
+ def subtool(name) = @subtools[name]
90
+
91
+ # Include a mixin by name
92
+ def include_mixin(name) = @mixins << name
93
+
94
+ # Set builtin reference for override support
95
+
96
+ # Parse arguments and execute the tool
97
+ def execute(argv, cli)
98
+ # Check for subcommand first
99
+ if argv.any? && (sub = subtool(argv.first))
100
+ return sub.execute(argv[1..], cli)
101
+ end
102
+
103
+ # Validate flags don't conflict with global flags
104
+ validate_flags!
105
+
106
+ # Parse flags and args
107
+ context = ExecutionContext.new(self, cli)
108
+ context.parse(argv)
109
+
110
+ # Check for required args
111
+ @args.select(&:required).each do |arg|
112
+ raise Error, "Missing required argument: #{arg.name}" unless context.options.key?(arg.name)
113
+ end
114
+
115
+ # Execute - re-evaluate source in context so `def run` has access to cli, options, etc.
116
+ if @source_code
117
+ # Re-evaluate the source code in the execution context
118
+ # This makes all def methods available with proper context
119
+ context.instance_eval(@source_code, @source_file || "(tool)")
120
+ if context.respond_to?(:run)
121
+ context.run
122
+ elsif @subtools.any?
123
+ cli.show_help(self)
124
+ else
125
+ raise Error, "Tool '#{full_name}' has no run method"
126
+ end
127
+ elsif @source_proc
128
+ # For nested tools defined with blocks.
129
+ # First eval the source file (if any) to define helper methods,
130
+ # then eval the block to define this subtool's run method.
131
+ if @source_file && File.exist?(@source_file)
132
+ context.instance_eval(File.read(@source_file), @source_file)
133
+ end
134
+ context.instance_eval(&@source_proc)
135
+ if context.respond_to?(:run)
136
+ context.run
137
+ elsif @subtools.any?
138
+ cli.show_help(self)
139
+ else
140
+ raise Error, "Tool '#{full_name}' has no run method"
141
+ end
142
+ elsif @run_block
143
+ context.instance_exec(&@run_block)
144
+ elsif @subtools.any?
145
+ cli.show_help(self)
146
+ else
147
+ raise Error, "Tool '#{full_name}' has no implementation"
148
+ end
149
+ end
150
+
151
+ # Generate help text
152
+ def help_text(executable_name = "dx")
153
+ lines = []
154
+
155
+ # Usage line
156
+ cmd = [executable_name, *full_path].join(" ")
157
+ usage_parts = [cmd]
158
+ usage_parts << "[OPTIONS]" if @flags.any?
159
+ @args.each do |arg|
160
+ usage_parts << if arg.remaining
161
+ "[#{arg.name.to_s.upcase}...]"
162
+ elsif arg.required
163
+ arg.name.to_s.upcase
164
+ else
165
+ "[#{arg.name.to_s.upcase}]"
166
+ end
167
+ end
168
+ usage_parts << "COMMAND" if @subtools.any?
169
+
170
+ lines << "Usage: #{usage_parts.join(' ')}"
171
+ lines << ""
172
+
173
+ # Description
174
+ if @desc
175
+ lines << @desc
176
+ lines << ""
177
+ end
178
+
179
+ if @long_desc
180
+ lines << @long_desc
181
+ lines << ""
182
+ end
183
+
184
+ # Subcommands
185
+ if @subtools.any?
186
+ lines << "Commands:"
187
+ max_name = @subtools.keys.map(&:length).max
188
+ @subtools.sort.each do |name, tool|
189
+ desc_text = tool.desc || ""
190
+ lines << " #{name.ljust(max_name)} #{desc_text}"
191
+ end
192
+ lines << ""
193
+ end
194
+
195
+ # Flags
196
+ if @flags.any?
197
+ lines << "Options:"
198
+ @flags.each do |flag|
199
+ flag_str = flag.specs.join(", ")
200
+ desc_text = flag.desc || ""
201
+ lines << " #{flag_str}"
202
+ lines << " #{desc_text}" if desc_text != ""
203
+ end
204
+ lines << ""
205
+ end
206
+
207
+ # Args
208
+ if @args.any?
209
+ lines << "Arguments:"
210
+ @args.each do |arg|
211
+ name_str = arg.name.to_s.upcase
212
+ name_str += "..." if arg.remaining
213
+ req_str = arg.required ? "(required)" : "(optional)"
214
+ desc_text = arg.desc ? " - #{arg.desc}" : ""
215
+ lines << " #{name_str} #{req_str}#{desc_text}"
216
+ end
217
+ lines << ""
218
+ end
219
+
220
+ lines.join("\n")
221
+ end
222
+ end
223
+
224
+ # Represents a flag definition
225
+ class Flag
226
+ attr_reader :name, :specs, :desc, :default
227
+
228
+ def initialize(name, specs, desc: nil, default: nil)
229
+ @name = name
230
+ @specs = specs
231
+ @desc = desc
232
+ @default = default
233
+ end
234
+
235
+ # Does this flag take an argument?
236
+ def takes_argument? = @specs.any? { |s| s.include?(" ") || s.include?("=") }
237
+ end
238
+
239
+ # Represents a positional argument definition
240
+ class Arg
241
+ attr_reader :name, :required, :desc, :default, :remaining
242
+
243
+ def initialize(name, required:, desc: nil, default: nil, remaining: false)
244
+ @name = name
245
+ @required = required
246
+ @desc = desc
247
+ @default = default
248
+ @remaining = remaining
249
+ end
250
+ end
251
+
252
+ # Execution context - the 'self' when a tool runs
253
+ class ExecutionContext
254
+ include Exec # All tools have access to cmd, capture, spawn, etc.
255
+
256
+ attr_reader :options, :cli
257
+
258
+ def initialize(tool, cli)
259
+ @tool = tool
260
+ @cli = cli
261
+ @options = {}
262
+
263
+ # Set defaults for flags
264
+ tool.flags.each do |flag|
265
+ @options[flag.name] = if !flag.default.nil?
266
+ flag.default
267
+ elsif !flag.takes_argument?
268
+ false # Boolean flags default to false
269
+ end
270
+ end
271
+ tool.args.each do |arg|
272
+ @options[arg.name] = arg.default unless arg.default.nil?
273
+ end
274
+ end
275
+
276
+ # Parse argv into options, return unparsed remainder
277
+ def parse(argv)
278
+ require "optparse"
279
+
280
+ parser = OptionParser.new do |opts|
281
+ tool.flags.each do |flag|
282
+ if flag.takes_argument?
283
+ opts.on(*flag.specs) { |v| @options[flag.name] = v }
284
+ else
285
+ opts.on(*flag.specs) { @options[flag.name] = true }
286
+ end
287
+ end
288
+ end
289
+
290
+ # Parse, collecting non-flag args
291
+ remaining = parser.parse(argv)
292
+
293
+ # Assign positional args
294
+ tool.args.each do |arg|
295
+ if arg.remaining
296
+ @options[arg.name] = remaining
297
+ remaining = []
298
+ elsif remaining.any?
299
+ @options[arg.name] = remaining.shift
300
+ end
301
+ end
302
+
303
+ remaining
304
+ end
305
+
306
+ # DSL methods - no-ops during execution (already captured during load)
307
+ def desc(_text) = nil
308
+ def long_desc(_text) = nil
309
+ def flag(_name, *_specs, **_kwargs) = nil
310
+ def required_arg(_name, **_kwargs) = nil
311
+ def optional_arg(_name, **_kwargs) = nil
312
+ def remaining_args(_name, **_kwargs) = nil
313
+ def include(_name) = nil
314
+ def to_run(&) = nil
315
+
316
+ # We need special handling because `tool` is both an attr_reader
317
+ # and a DSL method. Override the reader to handle both cases.
318
+ def tool(name = nil, &block)
319
+ if name.nil? && block.nil?
320
+ # Called as attr_reader - return the Tool object
321
+ @tool
322
+ else
323
+ # Called as DSL method - no-op during execution (already captured)
324
+ nil
325
+ end
326
+ end
327
+
328
+ # Access options as methods
329
+ def method_missing(name, *args, &)
330
+ if @options.key?(name)
331
+ @options[name]
332
+ else
333
+ super
334
+ end
335
+ end
336
+
337
+ def respond_to_missing?(name, include_private = false) = @options.key?(name) || super
338
+
339
+ # Access to builtin if this is an override
340
+ def builtin = tool.builtin ? ExecutionContext.new(tool.builtin, cli) : nil
341
+
342
+ # Run another tool by path
343
+ def run_tool(*path) = cli.run([*path.map(&:to_s)])
344
+
345
+ # Exit with code
346
+ def exit(code = 0) = Kernel.exit(code)
347
+
348
+ # --- Global options access ---
349
+
350
+ # Access CLI's global options
351
+ def global_options = cli.global_options
352
+
353
+ # Is verbose mode enabled? Returns verbosity level (0 = off, 1+ = on)
354
+ def verbose = global_options[:verbose]
355
+
356
+ def verbose? = verbose > 0
357
+
358
+ # Is quiet mode enabled?
359
+ def quiet? = global_options[:quiet]
360
+
361
+ # Get the effective output format
362
+ # Tool's --format flag takes precedence, then global --format, then context-based default
363
+ def output_format
364
+ # Tool-specific format (from options[:format]) takes precedence
365
+ return options[:format].to_sym if options[:format]
366
+
367
+ # Global format
368
+ return global_options[:format].to_sym if global_options[:format]
369
+
370
+ # Context-based default
371
+ Devex::Context.agent_mode? ? :json : :text
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devex
4
+ VERSION = "0.3.5"
5
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support/path"
4
+ require_relative "dirs"
5
+
6
+ module Devex
7
+ # Immutable working directory context for tool execution.
8
+ #
9
+ # The working directory is passed through the call tree but cannot be
10
+ # mutated. Child contexts see a new directory but parent is unaffected.
11
+ #
12
+ # Usage:
13
+ # working_dir # => current working directory (Path)
14
+ #
15
+ # within "packages/core" do
16
+ # working_dir # => /project/packages/core
17
+ # run "npm", "test" # Runs from packages/core
18
+ # end
19
+ #
20
+ # working_dir # => /project (unchanged)
21
+ #
22
+ # See ADR-003 for full specification.
23
+ #
24
+ module WorkingDir
25
+ Path = Support::Path
26
+
27
+ # Thread-local working directory stack
28
+ # Each entry is an immutable Path
29
+ @stack = []
30
+ @stack_mutex = Mutex.new
31
+
32
+ class << self
33
+ # Get current working directory
34
+ # Defaults to project_dir if no context has been pushed
35
+ def current = @stack_mutex.synchronize { @stack.last || Dirs.project_dir }
36
+
37
+ # Execute a block with a different working directory
38
+ # The working directory is restored after the block completes.
39
+ #
40
+ # @param subdir [String, Path] Directory to change to
41
+ # @yield Block to execute in the new context
42
+ # @return Result of the block
43
+ #
44
+ # @example Relative path
45
+ # within "packages/web" do
46
+ # run "npm", "test"
47
+ # end
48
+ #
49
+ # @example Absolute path
50
+ # within Path["/tmp/build"] do
51
+ # run "make"
52
+ # end
53
+ #
54
+ # @example Using project paths
55
+ # within prj.test do
56
+ # run "rake"
57
+ # end
58
+ #
59
+ def within(subdir)
60
+ new_wd = case subdir
61
+ when Path then subdir.absolute? ? subdir : current / subdir
62
+ when Pathname then subdir.absolute? ? Path.new(subdir) : current / subdir.to_s
63
+ when String then subdir.start_with?("/") ? Path[subdir] : current / subdir
64
+ else
65
+ raise ArgumentError, "Expected String or Path, got #{subdir.class}"
66
+ end
67
+
68
+ push(new_wd)
69
+ yield
70
+ ensure
71
+ pop
72
+ end
73
+
74
+ # Reset the working directory stack (for testing)
75
+ def reset! = @stack_mutex.synchronize { @stack.clear }
76
+
77
+ # Get the full stack (for debugging)
78
+ def stack = @stack_mutex.synchronize { @stack.dup }
79
+
80
+ # Get the depth of the current context
81
+ def depth = @stack_mutex.synchronize { @stack.size }
82
+
83
+ private
84
+
85
+ def push(path) = @stack_mutex.synchronize { @stack.push(path.freeze) }
86
+
87
+ def pop = @stack_mutex.synchronize { @stack.pop }
88
+ end
89
+ end
90
+
91
+ # Mixin module for tools that need working directory support
92
+ module WorkingDirMixin
93
+ # Get current working directory
94
+ def working_dir = WorkingDir.current
95
+
96
+ # Execute block in a different working directory
97
+ def within(subdir, &) = WorkingDir.within(subdir, &)
98
+ end
99
+ end
data/lib/devex.rb ADDED
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Devex - The dx CLI tool with all builtins and dx-specific configuration.
4
+ #
5
+ # This is the full dx application. For just the CLI framework (to build
6
+ # your own CLI tool), use: require "devex/core"
7
+
8
+ require_relative "devex/core"
9
+
10
+ module Devex
11
+ class Error < StandardError; end
12
+
13
+ # ─────────────────────────────────────────────────────────────
14
+ # dx-specific Configuration
15
+ # ─────────────────────────────────────────────────────────────
16
+
17
+ # dx-specific project root markers, checked in order
18
+ ROOT_MARKERS = %w[.dx.yml .dx .git tools].freeze
19
+
20
+ # Default tools directory name
21
+ DEFAULT_TOOLS_DIR = "tools"
22
+
23
+ # Templates directory name within the gem
24
+ TEMPLATES_DIR = "templates"
25
+
26
+ # dx-specific configuration for CLI
27
+ DX_CONFIG = Core::Configuration.new(
28
+ executable_name: "dx",
29
+ flag_prefix: "dx",
30
+ project_markers: %w[.dx.yml .dx .git tools Gemfile Rakefile .devex.yml],
31
+ config_file: ".dx.yml",
32
+ organized_dir: ".dx",
33
+ tools_dir: "tools",
34
+ env_prefix: "DX",
35
+ delegation_file: ".dx-use-local"
36
+ ).freeze
37
+
38
+ class << self
39
+ # Get the dx configuration
40
+ # @return [Core::Configuration]
41
+ def config
42
+ DX_CONFIG
43
+ end
44
+
45
+ # Root directory of the devex gem itself
46
+ # Used for locating built-in templates, builtins, etc.
47
+ def gem_root
48
+ @gem_root ||= File.expand_path('..', __dir__)
49
+ end
50
+
51
+ # Path to the templates directory within the gem
52
+ def templates_path
53
+ @templates_path ||= File.join(File.dirname(__FILE__), "devex", TEMPLATES_DIR)
54
+ end
55
+
56
+ # Load and render a template from the gem's templates directory
57
+ # Returns the rendered string
58
+ #
59
+ # If locals hash is provided, creates a binding with TemplateHelpers
60
+ # and all locals available. If a binding is provided directly, uses that.
61
+ def render_template(name, locals_or_binding = nil)
62
+ path = template_path(name)
63
+ raise Error, "Template not found: #{name}" unless File.exist?(path)
64
+
65
+ bind = if locals_or_binding.is_a?(Hash)
66
+ TemplateHelpers.template_binding(locals_or_binding)
67
+ elsif locals_or_binding.is_a?(Binding)
68
+ locals_or_binding
69
+ else
70
+ TemplateHelpers.template_binding
71
+ end
72
+
73
+ Output.render_template_file(path, bind)
74
+ end
75
+
76
+ # Get full path to a template file
77
+ # Adds .erb extension if not present
78
+ def template_path(name)
79
+ name = "#{name}.erb" unless name.end_with?(".erb")
80
+ File.join(templates_path, name)
81
+ end
82
+
83
+ # Find the project root by walking up from the given directory
84
+ # looking for root markers (.dx.yml, .git, tools/)
85
+ #
86
+ # Returns [root_path, marker_found] or [nil, nil] if not found
87
+ def find_project_root(from = Dir.pwd)
88
+ dir = File.expand_path(from)
89
+
90
+ loop do
91
+ ROOT_MARKERS.each do |marker|
92
+ marker_path = File.join(dir, marker)
93
+ return [dir, marker] if File.exist?(marker_path)
94
+ end
95
+
96
+ parent = File.dirname(dir)
97
+ break if parent == dir # reached filesystem root
98
+
99
+ dir = parent
100
+ end
101
+
102
+ [nil, nil]
103
+ end
104
+
105
+ # Get the tools directory for a project root
106
+ # Reads from .dx.yml or .dx/config.yml if present, otherwise uses default
107
+ def tools_dir(project_root)
108
+ return nil unless project_root
109
+
110
+ config = load_config(project_root)
111
+ tools_dir_name = config["tools_dir"] || DEFAULT_TOOLS_DIR
112
+
113
+ dir = File.join(project_root, tools_dir_name)
114
+ File.directory?(dir) ? dir : nil
115
+ end
116
+
117
+ # Load project configuration from .dx.yml or .dx/config.yml
118
+ # Returns empty hash if no config file exists
119
+ # Raises Error if both .dx.yml and .dx/ directory exist (conflict)
120
+ def load_config(project_root)
121
+ return {} unless project_root
122
+
123
+ dx_dir = File.join(project_root, ".dx")
124
+ dx_yml = File.join(project_root, ".dx.yml")
125
+
126
+ dx_dir_exists = File.directory?(dx_dir)
127
+ dx_yml_exists = File.exist?(dx_yml)
128
+
129
+ # Conflict check: both simple and organized mode markers present
130
+ if dx_dir_exists && dx_yml_exists
131
+ raise Error, <<~ERR.chomp
132
+ Conflicting dx configuration
133
+
134
+ Found both:
135
+ .dx.yml (simple mode config)
136
+ .dx/ (organized mode directory)
137
+
138
+ Choose one:
139
+ • Remove .dx.yml to use organized mode (.dx/config.yml)
140
+ • Remove .dx/ directory to use simple mode (.dx.yml)
141
+ ERR
142
+ end
143
+
144
+ config_file = if dx_dir_exists
145
+ File.join(dx_dir, "config.yml")
146
+ else
147
+ dx_yml
148
+ end
149
+
150
+ if File.exist?(config_file)
151
+ require "yaml"
152
+ YAML.safe_load_file(config_file) || {}
153
+ else
154
+ {}
155
+ end
156
+ end
157
+ end
158
+ end
File without changes
File without changes
File without changes
File without changes
data/sig/devex.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Devex
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end