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,1000 @@
1
+ # Developing Tools for Devex
2
+
3
+ This guide covers how to create tools (commands) for devex, including all available interfaces, best practices, and patterns.
4
+
5
+ ## Quick Start
6
+
7
+ Create a file in `tools/` (or `lib/devex/builtins/` for built-ins):
8
+
9
+ ```ruby
10
+ # tools/hello.rb
11
+ desc "Say hello"
12
+
13
+ def run
14
+ $stdout.print "Hello, world!\n"
15
+ end
16
+ ```
17
+
18
+ Run with: `dx hello`
19
+
20
+ ### Using Project Libraries
21
+
22
+ Devex automatically adds your project's `lib/` directory to the load path. This means tools can `require` project code directly:
23
+
24
+ ```ruby
25
+ # tools/deploy.rb
26
+ require "myproject/config" # loads lib/myproject/config.rb
27
+ require "myproject/deploy" # loads lib/myproject/deploy.rb
28
+
29
+ desc "Deploy the application"
30
+
31
+ def run
32
+ config = MyProject::Config.load
33
+ MyProject::Deploy.run(config)
34
+ end
35
+ ```
36
+
37
+ No `require_relative` needed - just use standard `require` with your library's namespace.
38
+
39
+ ---
40
+
41
+ ## Tool Definition DSL
42
+
43
+ ### Basic Metadata
44
+
45
+ ```ruby
46
+ desc "Short description (shown in help listing)"
47
+
48
+ long_desc <<~DESC
49
+ Longer description shown when running `dx help <tool>`.
50
+ Can span multiple lines and include examples.
51
+ DESC
52
+ ```
53
+
54
+ ### Flags (Options)
55
+
56
+ ```ruby
57
+ flag :dry_run, "-n", "--dry-run", desc: "Show what would happen"
58
+ flag :count, "-c COUNT", "--count=COUNT", desc: "Number of times"
59
+ flag :output, "-o FILE", "--output=FILE", desc: "Output file"
60
+ flag :format, "--format=FMT", desc: "Output format", default: "text"
61
+ ```
62
+
63
+ Access in `run`: `dry_run`, `count`, `output`, `format` (as methods) or `options[:dry_run]`
64
+
65
+ **Flag options:**
66
+ - `desc:` - Description shown in help
67
+ - `default:` - Default value for the flag. Boolean flags without values default to `false`. Flags with arguments default to `nil` unless `default:` is specified.
68
+
69
+ **Reserved flags:** The following flags are reserved for global use and cannot be used by tools:
70
+ - `-v`, `--verbose` - Use `verbose?` to check global verbose level
71
+ - `-f`, `--format` - Use `global_options[:format]`
72
+ - `-q`, `--quiet` - Use `global_options[:quiet]`
73
+ - `--no-color`, `--color` - Color is handled automatically
74
+
75
+ Tools that define conflicting flags will fail with an error when invoked.
76
+
77
+ ### Positional Arguments
78
+
79
+ ```ruby
80
+ required_arg :filename, desc: "File to process"
81
+ optional_arg :output, desc: "Output file (default: stdout)"
82
+ remaining_args :files, desc: "Additional files"
83
+ ```
84
+
85
+ Access in `run`: `filename`, `output`, `files` (as methods)
86
+
87
+ ### Nested Tools (Subcommands)
88
+
89
+ ```ruby
90
+ # tools/db.rb
91
+ desc "Database operations"
92
+
93
+ tool "migrate" do
94
+ desc "Run migrations"
95
+ def run
96
+ # ...
97
+ end
98
+ end
99
+
100
+ tool "seed" do
101
+ desc "Seed the database"
102
+ flag :env, "-e ENV", desc: "Environment"
103
+ def run
104
+ # ...
105
+ end
106
+ end
107
+ ```
108
+
109
+ Access as: `dx db migrate`, `dx db seed --env=test`
110
+
111
+ ---
112
+
113
+ ## External Command Execution
114
+
115
+ The `Devex::Exec` module provides methods for running external commands with automatic environment handling.
116
+
117
+ ### Quick Reference
118
+
119
+ | Method | Purpose | stdout | Returns |
120
+ |--------|---------|--------|---------|
121
+ | `cmd(*args)` | Run command, wait | streams | `Result` |
122
+ | `cmd?(*args)` | Test if command succeeds | silent | `bool` |
123
+ | `capture(*args)` | Run and capture output | captured | `Result` |
124
+ | `spawn(*args)` | Run in background | configurable | `Controller` |
125
+ | `exec!(*args)` | Replace this process | N/A | never returns |
126
+ | `shell(str)` | Run via shell | streams | `Result` |
127
+ | `shell?(str)` | Test shell command | silent | `bool` |
128
+ | `ruby(*args)` | Run Ruby subprocess | streams | `Result` |
129
+ | `tool(name, *args)` | Run another dx tool | streams | `Result` |
130
+
131
+ **Note:** `cmd` and `cmd?` are aliases for `run` and `run?`. Use `cmd`/`cmd?` inside tools to avoid shadowing the `def run` entry point.
132
+
133
+ ### Basic Usage
134
+
135
+ ```ruby
136
+ # In your tool's run method:
137
+ include Devex::Exec
138
+
139
+ def run
140
+ # Use `cmd` instead of `run` to avoid collision with `def run`
141
+ cmd "bundle", "install"
142
+
143
+ # Check if command succeeded
144
+ result = cmd "make", "test"
145
+ if result.failed?
146
+ Output.error "Tests failed"
147
+ exit result.exit_code
148
+ end
149
+
150
+ # Exit immediately on failure
151
+ cmd("bundle", "install").exit_on_failure!
152
+
153
+ # Chain commands (short-circuit on failure)
154
+ cmd("lint").then { cmd("test") }.then { cmd("build") }.exit_on_failure!
155
+ end
156
+ ```
157
+
158
+ **Note:** Use `cmd` instead of `run` when including `Devex::Exec` in tools. The `def run` entry point shadows `Devex::Exec.run`, so `cmd` is provided as an alias to avoid this collision.
159
+
160
+ ### `cmd` / `run` - Run Command
161
+
162
+ The workhorse method. Runs a command, streams output, waits for completion.
163
+ Use `cmd` inside tools (alias for `run`) to avoid shadowing `def run`.
164
+
165
+ ```ruby
166
+ cmd "bundle", "install"
167
+
168
+ # With options
169
+ cmd "make", "test", env: { CI: "1" }, chdir: "subproject/"
170
+
171
+ # With timeout (seconds)
172
+ cmd "slow_task", timeout: 30
173
+ ```
174
+
175
+ **Behavior:**
176
+ - Streams stdout/stderr to terminal
177
+ - Applies environment stack (cleans bundler pollution)
178
+ - Returns `Result` object
179
+ - Never raises on non-zero exit
180
+
181
+ ### `cmd?` / `run?` - Test Command Success
182
+
183
+ Silent execution, returns boolean. Perfect for conditionals.
184
+ Use `cmd?` inside tools (alias for `run?`).
185
+
186
+ ```ruby
187
+ if cmd? "which", "rubocop"
188
+ cmd "rubocop", "--autocorrect"
189
+ end
190
+
191
+ unless cmd? "git", "diff", "--quiet"
192
+ Output.warn "Uncommitted changes"
193
+ end
194
+ ```
195
+
196
+ ### `capture` - Capture Output
197
+
198
+ When you need the output as a string.
199
+
200
+ ```ruby
201
+ result = capture "git", "rev-parse", "HEAD"
202
+ commit = result.stdout.strip
203
+
204
+ result = capture "git", "status", "--porcelain"
205
+ if result.success? && result.stdout.empty?
206
+ Output.success "Working directory clean"
207
+ end
208
+ ```
209
+
210
+ ### `spawn` - Background Execution
211
+
212
+ Start a process without waiting. Returns immediately with a Controller.
213
+
214
+ ```ruby
215
+ # Start server in background
216
+ server = spawn "rails", "server", "-p", "3000"
217
+
218
+ # Do other work...
219
+ run "curl", "http://localhost:3000/health"
220
+
221
+ # Clean up
222
+ server.kill(:TERM)
223
+ result = server.result # Wait for exit
224
+ ```
225
+
226
+ ### `exec!` - Replace Process
227
+
228
+ Replaces the current process. Use sparingly.
229
+
230
+ ```ruby
231
+ exec! "vim", filename
232
+ # This line never executes
233
+ ```
234
+
235
+ ### `shell` / `shell?` - Shell Execution
236
+
237
+ When you need shell features (pipes, globs, variable expansion).
238
+
239
+ ```ruby
240
+ # Pipes and variables
241
+ shell "grep TODO **/*.rb | wc -l"
242
+ shell "echo $HOME"
243
+
244
+ # Test with shell
245
+ if shell? "command -v docker"
246
+ shell "docker compose up -d"
247
+ end
248
+ ```
249
+
250
+ **Security note:** Never interpolate untrusted input into shell commands.
251
+
252
+ ### `ruby` - Ruby Subprocess
253
+
254
+ Run Ruby with clean environment.
255
+
256
+ ```ruby
257
+ ruby "-e", "puts RUBY_VERSION"
258
+ ruby "script.rb", "--verbose"
259
+ ```
260
+
261
+ ### `tool` - Run Another dx Tool
262
+
263
+ Invoke another devex tool programmatically.
264
+
265
+ ```ruby
266
+ tool "lint", "--fix"
267
+
268
+ if tool?("test")
269
+ tool "deploy"
270
+ end
271
+
272
+ # Capture tool output
273
+ result = tool "version", capture: true
274
+ ```
275
+
276
+ Propagates call tree so child tool knows it was invoked from parent.
277
+
278
+ ### The Result Object
279
+
280
+ All commands (except `run?`/`shell?`/`exec!`) return a `Result`:
281
+
282
+ ```ruby
283
+ result = run "make", "test"
284
+
285
+ # Status
286
+ result.success? # exit_code == 0
287
+ result.failed? # exit_code != 0 or didn't start
288
+ result.signaled? # killed by signal
289
+ result.timed_out? # killed due to timeout
290
+
291
+ # Info
292
+ result.command # ["make", "test"]
293
+ result.exit_code # 0-255 or nil if signaled
294
+ result.pid # Process ID
295
+ result.duration # Seconds elapsed
296
+
297
+ # Output (if captured)
298
+ result.stdout # String or nil
299
+ result.stderr # String or nil
300
+ result.stdout_lines # Array of lines
301
+
302
+ # Monad operations
303
+ result.exit_on_failure! # Exit process if failed
304
+ result.then { run("next") } # Chain if successful
305
+ result.map { |out| out.strip } # Transform stdout
306
+ ```
307
+
308
+ ### The Controller Object
309
+
310
+ `spawn` returns a `Controller` for managing background processes:
311
+
312
+ ```ruby
313
+ ctrl = spawn "server"
314
+
315
+ ctrl.pid # Process ID
316
+ ctrl.executing? # Still running?
317
+ ctrl.elapsed # Seconds since start
318
+
319
+ ctrl.kill(:TERM) # Send signal
320
+ ctrl.terminate # TERM + wait
321
+
322
+ ctrl.result # Wait and get Result
323
+ ctrl.result(timeout: 30) # With timeout
324
+ ```
325
+
326
+ ### Common Options
327
+
328
+ ```ruby
329
+ run "command",
330
+ env: { KEY: "value" }, # Additional environment variables
331
+ chdir: "subdir/", # Working directory
332
+ timeout: 30, # Seconds before killing
333
+ raw: true, # Skip all environment wrappers
334
+ bundle: false, # Skip bundle exec wrapping
335
+ mise: false, # Skip mise exec wrapping
336
+ dotenv: true, # Enable dotenv wrapper (explicit opt-in)
337
+ clean_env: true # Clean bundler pollution (default)
338
+ ```
339
+
340
+ ### Environment Wrapper Chain
341
+
342
+ When running commands, devex automatically applies a wrapper chain:
343
+
344
+ ```
345
+ [dotenv] [mise exec --] [bundle exec] your-command
346
+ ```
347
+
348
+ | Wrapper | When Applied | Default |
349
+ |---------|--------------|---------|
350
+ | `dotenv` | Explicit opt-in only | OFF |
351
+ | `mise exec --` | Auto if `.mise.toml` or `.tool-versions` exists | AUTO |
352
+ | `bundle exec` | Auto if `Gemfile` exists and command looks like a gem | AUTO |
353
+
354
+ **Examples:**
355
+
356
+ ```ruby
357
+ # Just runs: echo hello
358
+ run "echo", "hello"
359
+
360
+ # Auto-detected Gemfile, runs: bundle exec rspec
361
+ run "rspec"
362
+
363
+ # Auto-detected .mise.toml, runs: mise exec -- bundle exec rspec
364
+ run "rspec"
365
+
366
+ # Explicit dotenv, runs: dotenv mise exec -- bundle exec rspec
367
+ run "rspec", dotenv: true
368
+
369
+ # Skip mise wrapping: bundle exec rspec
370
+ run "rspec", mise: false
371
+
372
+ # Skip all wrappers: rspec
373
+ run "rspec", raw: true
374
+
375
+ # Force mise even if not detected: mise exec -- echo hello
376
+ run "echo", "hello", mise: true
377
+
378
+ # Force bundle exec even for non-gem commands: bundle exec custom-script
379
+ run "custom-script", bundle: true
380
+ ```
381
+
382
+ **Gem commands that trigger `bundle exec`:**
383
+ `rake`, `rspec`, `rubocop`, `standardrb`, `steep`, `rbs`, `rails`, `sidekiq`, `puma`, `unicorn`, `thin`, `bundler`, `bundle`, `erb`, `rdoc`, `ri`, `yard`
384
+
385
+ **Note:** The `dotenv` option requires the `dotenv` CLI to be installed (`gem install dotenv`). It loads `.env` files before running the command.
386
+
387
+ ---
388
+
389
+ ## Directory Context
390
+
391
+ Devex provides a rich directory context system for tools that need to work with project paths.
392
+
393
+ ### Core Directories (`Devex::Dirs`)
394
+
395
+ ```ruby
396
+ # Where dx was invoked from
397
+ Devex::Dirs.invoked_dir # => Path
398
+
399
+ # The destination directory (usually same as invoked_dir)
400
+ Devex::Dirs.dest_dir # => Path
401
+
402
+ # Project root (found by walking up looking for markers)
403
+ Devex::Dirs.project_dir # => Path
404
+
405
+ # Where devex gem itself lives
406
+ Devex::Dirs.dx_src_dir # => Path
407
+
408
+ # Is this inside a project?
409
+ Devex::Dirs.in_project? # => true/false
410
+ ```
411
+
412
+ Project markers searched (in order): `.dx.yml`, `.dx/`, `.git`, `Gemfile`, `Rakefile`
413
+
414
+ ### Project Paths (`Devex::ProjectPaths`)
415
+
416
+ Lazy path resolution with fail-fast feedback:
417
+
418
+ ```ruby
419
+ prj = Devex::ProjectPaths.new(root: Devex::Dirs.project_dir)
420
+
421
+ # Standard paths (raises if not found)
422
+ prj.root # => /path/to/project
423
+ prj.lib # => /path/to/project/lib
424
+ prj.src # => /path/to/project/src
425
+ prj.bin # => /path/to/project/bin
426
+ prj.exe # => /path/to/project/exe
427
+
428
+ # Paths with alternatives (tries each in order)
429
+ prj.test # => finds test/, spec/, or tests/
430
+ prj.docs # => finds docs/ or doc/
431
+
432
+ # Glob from root
433
+ prj["*.rb"] # => Array of Path objects
434
+ prj["lib/**/*.rb"] # => Array of Path objects
435
+
436
+ # Config detection (simple vs organized mode)
437
+ prj.config # => .dx.yml or .dx/config.yml
438
+ prj.tools # => tools/ or .dx/tools/
439
+
440
+ # Version file detection
441
+ prj.version # => VERSION, version.rb, or similar
442
+
443
+ # Check mode
444
+ prj.organized_mode? # => true if .dx/ directory exists
445
+ ```
446
+
447
+ ### Working Directory Context
448
+
449
+ Immutable working directory for command execution:
450
+
451
+ ```ruby
452
+ include Devex::WorkingDirMixin
453
+
454
+ def run
455
+ # Current working directory
456
+ working_dir # => Path to current context
457
+
458
+ # Execute block in different directory
459
+ within "packages/core" do
460
+ working_dir # => /project/packages/core
461
+ run "npm", "test" # Runs from packages/core
462
+ end
463
+
464
+ working_dir # => /project (unchanged)
465
+
466
+ # Nest as deep as needed
467
+ within "apps" do
468
+ within "web" do
469
+ run "yarn", "build"
470
+ end
471
+ end
472
+
473
+ # Use with project paths
474
+ within prj.test do
475
+ run "rspec"
476
+ end
477
+ end
478
+ ```
479
+
480
+ The `within` block:
481
+ - Takes relative or absolute paths
482
+ - Restores directory on block exit (even if exception)
483
+ - Thread-safe via mutex
484
+ - Passes directory to spawned commands via `chdir:`
485
+
486
+ ### The Path Class
487
+
488
+ All directory methods return `Devex::Support::Path` objects:
489
+
490
+ ```ruby
491
+ path = Devex::Support::Path["/some/path"]
492
+ path = Devex::Support::Path.pwd
493
+
494
+ # Navigation (returns new Path, immutable)
495
+ path / "subdir" # => Path to /some/path/subdir
496
+ path.parent # => Path to /some
497
+ path.join("a", "b") # => Path to /some/path/a/b
498
+
499
+ # Queries
500
+ path.exist?
501
+ path.file?
502
+ path.directory?
503
+ path.readable?
504
+ path.writable?
505
+ path.executable?
506
+ path.absolute?
507
+ path.relative?
508
+ path.empty? # Empty file or empty directory
509
+
510
+ # File operations
511
+ path.read # => String contents
512
+ path.write("content")
513
+ path.append("more")
514
+ path.touch
515
+ path.mkdir
516
+ path.mkdir_p
517
+ path.rm
518
+ path.rm_rf
519
+ path.cp(dest)
520
+ path.mv(dest)
521
+
522
+ # Metadata
523
+ path.basename # => "path"
524
+ path.extname # => ".rb"
525
+ path.dirname # => Path to parent
526
+ path.expand # => Expanded Path
527
+ path.realpath # => Resolved symlinks
528
+
529
+ # Enumeration
530
+ path.children # => Array of Paths
531
+ path.glob("**/*.rb") # => Array of Paths
532
+ path.find { |p| ... } # Recursive find
533
+
534
+ # Conversion
535
+ path.to_s # => "/some/path"
536
+ path.to_str # => "/some/path" (implicit)
537
+ ```
538
+
539
+ ---
540
+
541
+ ## Runtime Context
542
+
543
+ ### Detecting Environment
544
+
545
+ ```ruby
546
+ def run
547
+ # What environment are we in?
548
+ Devex::Context.env # => "development", "test", "staging", "production"
549
+ Devex::Context.development? # => true/false
550
+ Devex::Context.production? # => true/false
551
+ Devex::Context.safe_env? # => true for dev/test, false for staging/prod
552
+ end
553
+ ```
554
+
555
+ Set via `DX_ENV`, `DEVEX_ENV`, `RAILS_ENV`, or `RACK_ENV`.
556
+
557
+ ### Detecting Agent Mode
558
+
559
+ When invoked by an AI agent (Claude, etc.), output should be structured and machine-readable:
560
+
561
+ ```ruby
562
+ def run
563
+ if Devex::Context.agent_mode?
564
+ # Output JSON, avoid colors, no interactive prompts
565
+ else
566
+ # Rich terminal output okay
567
+ end
568
+ end
569
+ ```
570
+
571
+ Agent mode is detected when:
572
+ - `DX_AGENT_MODE=1` environment variable is set
573
+ - stdout/stderr are merged (`2>&1` redirection)
574
+ - Not a TTY and not CI
575
+
576
+ ### Detecting Interactive Mode
577
+
578
+ ```ruby
579
+ def run
580
+ if Devex::Context.interactive?
581
+ # Can prompt user, show progress bars, etc.
582
+ else
583
+ # Non-interactive: fail or use defaults, no prompts
584
+ end
585
+ end
586
+ ```
587
+
588
+ ### Detecting CI
589
+
590
+ ```ruby
591
+ def run
592
+ if Devex::Context.ci?
593
+ # Running in GitHub Actions, GitLab CI, etc.
594
+ end
595
+ end
596
+ ```
597
+
598
+ ### Call Tree (Task Invocation Chain)
599
+
600
+ Tools can know if they were invoked from another tool:
601
+
602
+ ```ruby
603
+ def run
604
+ Devex::Context.invoked_from_task? # => true if called by another tool
605
+ Devex::Context.invoking_task # => "pre-commit" (immediate parent)
606
+ Devex::Context.root_task # => "pre-commit" (first in chain)
607
+ Devex::Context.call_tree # => ["pre-commit", "lint", "rubocop"]
608
+ end
609
+ ```
610
+
611
+ Use case: A `lint` tool might skip certain checks when invoked from `pre-commit` vs directly.
612
+
613
+ ### Terminal Detection
614
+
615
+ ```ruby
616
+ Devex::Context.terminal? # All three streams are TTYs
617
+ Devex::Context.stdout_tty? # stdout specifically
618
+ Devex::Context.piped? # Data being piped in or out
619
+ Devex::Context.color? # Should we use colors?
620
+ ```
621
+
622
+ ---
623
+
624
+ ## Global Options
625
+
626
+ Tools have access to global flags set by the user:
627
+
628
+ ```ruby
629
+ def run
630
+ # Access global options
631
+ global_options[:format] # --format value
632
+ global_options[:verbose] # -v count (0, 1, 2, ...)
633
+ global_options[:quiet] # -q was set
634
+
635
+ # Convenience methods
636
+ verbose? # true if -v was passed
637
+ verbose # verbosity level (0, 1, 2, ...)
638
+ quiet? # true if -q was passed
639
+
640
+ # Effective output format (considers global + tool flags + context)
641
+ output_format # => :text, :json, or :yaml
642
+ end
643
+ ```
644
+
645
+ ---
646
+
647
+ ## Output Patterns
648
+
649
+ ### Rule: Never Stack `puts` Calls
650
+
651
+ Bad:
652
+ ```ruby
653
+ puts "Header"
654
+ puts "Line 1"
655
+ puts "Line 2"
656
+ ```
657
+
658
+ Good:
659
+ ```ruby
660
+ $stdout.print Devex.render_template("my_template", data)
661
+ ```
662
+
663
+ ### Structured Output (JSON/YAML)
664
+
665
+ ```ruby
666
+ def run
667
+ data = { status: "ok", count: 42 }
668
+
669
+ case output_format
670
+ when :json, :yaml
671
+ Devex::Output.data(data, format: output_format)
672
+ else
673
+ $stdout.print Devex.render_template("my_template", data)
674
+ end
675
+ end
676
+ ```
677
+
678
+ ### Using Templates
679
+
680
+ Templates live in `lib/devex/templates/*.erb`:
681
+
682
+ ```ruby
683
+ # In your tool:
684
+ $stdout.print Devex.render_template("status", {
685
+ name: "myproject",
686
+ version: "1.0.0",
687
+ healthy: true
688
+ })
689
+ ```
690
+
691
+ ```erb
692
+ <%# lib/devex/templates/status.erb %>
693
+ <%= heading "Status" %>
694
+ Project: <%= c :emphasis, name %>
695
+ Version: <%= version %>
696
+ Health: <%= healthy ? csym(:success) : csym(:error) %> <%= healthy ? "OK" : "FAILING" %>
697
+ ```
698
+
699
+ ### Template Helpers
700
+
701
+ Available in all templates:
702
+
703
+ | Helper | Description | Example |
704
+ |--------|-------------|---------|
705
+ | `c(color, text)` | Colorize text | `<%= c :success, "done" %>` |
706
+ | `c(style, color, text)` | Multiple styles | `<%= c :bold, :white, "HEADER" %>` |
707
+ | `sym(name)` | Unicode symbol | `<%= sym :success %>` → ✓ |
708
+ | `csym(name)` | Colored symbol | `<%= csym :error %>` → red ✗ |
709
+ | `heading(text)` | Section heading | `<%= heading "Results" %>` |
710
+ | `muted(text)` | Gray/secondary | `<%= muted "optional info" %>` |
711
+ | `bold(text)` | Bold text | `<%= bold "important" %>` |
712
+ | `hr` | Horizontal rule | `<%= hr %>` |
713
+
714
+ **Colors:** `:success`, `:error`, `:warning`, `:info`, `:header`, `:muted`, `:emphasis`
715
+
716
+ **Symbols:** `:success` (✓), `:error` (✗), `:warning` (⚠), `:info` (ℹ), `:arrow` (→), `:bullet` (•), `:dot` (·)
717
+
718
+ Colors automatically respect `--no-color`. Symbols are always unicode (basic unicode works everywhere).
719
+
720
+ ### Streaming Multiple Documents
721
+
722
+ For composed tools outputting multiple results:
723
+
724
+ ```ruby
725
+ # YAML stream with proper separators
726
+ Devex::Output.yaml_stream([result1, result2, result3])
727
+ # Outputs: doc1, ---, doc2, ---, doc3, ...
728
+
729
+ # JSON Lines (one object per line)
730
+ Devex::Output.jsonl_stream([result1, result2, result3])
731
+ ```
732
+
733
+ ---
734
+
735
+ ## Error Handling
736
+
737
+ ### User Errors
738
+
739
+ ```ruby
740
+ def run
741
+ unless File.exist?(filename)
742
+ Devex::Output.error("File not found: #{filename}")
743
+ exit(1)
744
+ end
745
+ end
746
+ ```
747
+
748
+ ### Structured Errors (Agent Mode)
749
+
750
+ The `Output.error` method automatically adapts to context.
751
+
752
+ ### Exit Codes
753
+
754
+ - `0` - Success
755
+ - `1` - General error
756
+ - `2` - Usage/argument error
757
+
758
+ ### Command Execution Errors
759
+
760
+ Commands return `Result` objects instead of raising exceptions:
761
+
762
+ ```ruby
763
+ result = run "might_fail"
764
+
765
+ if result.failed?
766
+ if result.exception
767
+ # Command failed to start (not found, permission denied)
768
+ Output.error "Command failed to start: #{result.exception.message}"
769
+ else
770
+ # Command ran but returned non-zero
771
+ Output.error "Command failed with exit code #{result.exit_code}"
772
+ end
773
+ exit 1
774
+ end
775
+ ```
776
+
777
+ ---
778
+
779
+ ## Support Library
780
+
781
+ ### Core Extensions (Refinements)
782
+
783
+ Enable Ruby refinements for cleaner code:
784
+
785
+ ```ruby
786
+ using Devex::Support::CoreExt
787
+
788
+ # String
789
+ "hello".present? # => true
790
+ "".blank? # => true
791
+ "HELLO".underscore # => "hello"
792
+ "hello".titleize # => "Hello"
793
+
794
+ # Array/Hash
795
+ [].blank? # => true
796
+ { a: 1 }.present? # => true
797
+
798
+ # Enumerable
799
+ [1, 2, 3].average # => 2.0
800
+ [1, 2, 3].sum_by { |x| x * 2 } # => 12
801
+
802
+ # Numeric
803
+ 5.clamp(1, 3) # => 3
804
+ 5.positive? # => true
805
+ ```
806
+
807
+ Or load globally (for tools that prefer it):
808
+
809
+ ```ruby
810
+ Devex::Support::Global.load!
811
+ ```
812
+
813
+ ### ANSI Colors
814
+
815
+ Direct access to terminal colors:
816
+
817
+ ```ruby
818
+ Devex::Support::ANSI["Hello", :green]
819
+ Devex::Support::ANSI["Error", :red, :bold]
820
+ Devex::Support::ANSI["Text", :white, bg: :blue]
821
+
822
+ # Check if colors enabled
823
+ Devex::Support::ANSI.enabled?
824
+ Devex::Support::ANSI.disable!
825
+ Devex::Support::ANSI.enable!
826
+ ```
827
+
828
+ ---
829
+
830
+ ## Accessing CLI State
831
+
832
+ ```ruby
833
+ def run
834
+ cli.project_root # Path to project root (where .dx.yml or .git is)
835
+ cli.executable_name # "dx"
836
+ end
837
+ ```
838
+
839
+ ---
840
+
841
+ ## Invoking Other Tools
842
+
843
+ ```ruby
844
+ def run
845
+ # Via the tool() method (recommended - tracks call tree)
846
+ tool "test"
847
+ tool "lint", "--fix"
848
+
849
+ # Legacy method
850
+ run_tool("test")
851
+ run_tool("lint", "--fix")
852
+ end
853
+ ```
854
+
855
+ ---
856
+
857
+ ## Overriding Built-ins
858
+
859
+ Project tasks override built-ins of the same name:
860
+
861
+ ```ruby
862
+ # tools/version.rb - overrides built-in version command
863
+ desc "Custom version display"
864
+
865
+ def run
866
+ # Your custom implementation
867
+
868
+ # Optionally call the built-in:
869
+ builtin.run if builtin
870
+ end
871
+ ```
872
+
873
+ ---
874
+
875
+ ## Testing Considerations
876
+
877
+ ### Debug Flags
878
+
879
+ For reproducing issues, users can force context detection:
880
+
881
+ ```bash
882
+ dx --dx-agent-mode version # Force agent mode
883
+ dx --dx-no-agent-mode version # Force non-agent mode
884
+ dx --dx-env=production version # Force environment
885
+ dx --dx-force-color version # Force colors on
886
+ dx --dx-no-color version # Force colors off
887
+ ```
888
+
889
+ ### Programmatic Overrides
890
+
891
+ In tests, use `Context.with_overrides`:
892
+
893
+ ```ruby
894
+ Devex::Context.with_overrides(agent_mode: true, color: false) do
895
+ # Test code here
896
+ end
897
+ ```
898
+
899
+ ---
900
+
901
+ ## Complete Example
902
+
903
+ ```ruby
904
+ # tools/check.rb
905
+ desc "Run project health checks"
906
+
907
+ long_desc <<~DESC
908
+ Runs various health checks on the project and reports status.
909
+ Use --fix to automatically fix issues where possible.
910
+ DESC
911
+
912
+ flag :fix, "--fix", desc: "Automatically fix issues"
913
+ flag :strict, "--strict", desc: "Fail on warnings"
914
+
915
+ include Devex::Exec
916
+ include Devex::WorkingDirMixin
917
+
918
+ def run
919
+ results = {
920
+ checks: [],
921
+ passed: 0,
922
+ failed: 0,
923
+ warnings: 0
924
+ }
925
+
926
+ # Run tests
927
+ within prj.test do
928
+ result = capture "rspec", "--format", "json"
929
+ if result.success?
930
+ results[:passed] += 1
931
+ results[:checks] << { name: "tests", status: "passed" }
932
+ else
933
+ results[:failed] += 1
934
+ results[:checks] << { name: "tests", status: "failed" }
935
+ end
936
+ end
937
+
938
+ # Run linter (use cmd/cmd? inside def run to avoid shadowing)
939
+ if cmd? "which", "rubocop"
940
+ result = cmd "rubocop", *(fix ? ["--autocorrect"] : [])
941
+ status = result.success? ? "passed" : "failed"
942
+ results[:checks] << { name: "lint", status: status }
943
+ result.success? ? results[:passed] += 1 : results[:failed] += 1
944
+ end
945
+
946
+ # Output based on format
947
+ case output_format
948
+ when :json, :yaml
949
+ Devex::Output.data(results, format: output_format)
950
+ else
951
+ $stdout.print Devex.render_template("check_results", results)
952
+ end
953
+
954
+ # Exit code
955
+ exit(1) if results[:failed] > 0
956
+ exit(1) if strict && results[:warnings] > 0
957
+ end
958
+ ```
959
+
960
+ ```erb
961
+ <%# lib/devex/templates/check_results.erb %>
962
+ <%= heading "Health Check Results" %>
963
+
964
+ <% checks.each do |check| -%>
965
+ <%= csym(check[:status] == "passed" ? :success : :error) %> <%= check[:name] %>
966
+ <% end -%>
967
+
968
+ <%= muted "#{passed} passed, #{failed} failed, #{warnings} warnings" %>
969
+ ```
970
+
971
+ ---
972
+
973
+ ## Summary of Available Interfaces
974
+
975
+ | Interface | Purpose |
976
+ |-----------|---------|
977
+ | **Context** | |
978
+ | `Devex::Context.*` | Runtime detection (agent, CI, env, call tree) |
979
+ | `Devex::Dirs.*` | Core directories (invoked, project, dest) |
980
+ | `Devex::ProjectPaths` | Lazy project path resolution |
981
+ | `Devex::WorkingDirMixin` | Working directory context |
982
+ | **Execution** | |
983
+ | `Devex::Exec` | Command execution (run, capture, spawn, etc.) |
984
+ | `Devex::Exec::Result` | Command result with monad operations |
985
+ | `Devex::Exec::Controller` | Background process management |
986
+ | **Output** | |
987
+ | `Devex::Output.*` | Styled output, structured data |
988
+ | `Devex.render_template(name, locals)` | Render ERB template |
989
+ | **Support** | |
990
+ | `Devex::Support::Path` | Immutable path operations |
991
+ | `Devex::Support::ANSI` | Terminal colors |
992
+ | `Devex::Support::CoreExt` | Ruby refinements |
993
+ | **Tool Runtime** | |
994
+ | `output_format` | Effective format (:text, :json, :yaml) |
995
+ | `verbose?`, `quiet?` | Global verbosity flags |
996
+ | `cli.project_root` | Project root path |
997
+ | `tool(name, *args)` | Invoke another tool |
998
+ | `builtin` | Access overridden built-in |
999
+ | `options` | Tool-specific flag/arg values |
1000
+ | `global_options` | Global flag values |