asgard 0.2.0 → 0.3.1

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.
data/README.md CHANGED
@@ -17,12 +17,13 @@
17
17
  - <strong>Task Dependencies</strong> — sequential, parallel, and mixed dependency graphs via <code>depends_on</code><br>
18
18
  - <strong>Concurrent Execution</strong> — parallel task groups run in native Ruby threads<br>
19
19
  - <strong>Subcommands</strong> — group related tasks under a named namespace<br>
20
- - <strong>Variables</strong> — static values and lazy-evaluated lambdas via <code>var</code><br>
20
+ - <strong>Variables</strong> — shared configuration via Ruby class variables (<code>@@name</code>), visible across all tasks and subcommands<br>
21
+ - <strong>`helper` DSL</strong> — define a method once, available in both class-level DSL calls (<code>header</code>) and inside task instance methods<br>
21
22
  - <strong>Shell Helpers</strong> — <code>sh</code> for any shell command or heredoc; <code>shebang</code> for polyglot scripts<br>
22
23
  - <strong>Dotenv Support</strong> — load <code>.env</code> files into the environment with <code>dotenv</code><br>
23
24
  - <strong>Auto-Discovery</strong> — <code>.loki</code> root marker searched from CWD upward through parent directories<br>
24
- - <strong>Multi-File Tasks</strong> — split tasks across <code>*.loki</code> files, loaded on demand with <code>--auto-load</code><br>
25
- - <strong>Built-in Flags</strong> — <code>--version</code>, <code>--debug</code>, and <code>--verbose</code> available on every task<br>
25
+ - <strong>Multi-File Tasks</strong> — split tasks across <code>*.loki</code> files, loaded via <code>import</code> from your <code>.loki</code><br>
26
+ - <strong>Built-in Flags</strong> — <code>--debug</code>, <code>--verbose</code>, and <code>--version</code> built-in class options; header/footer DSL for static help text<br>
26
27
  </td>
27
28
  </tr>
28
29
  </table>
@@ -51,8 +52,8 @@ Every `.loki` file defines tasks as methods inside `class Tasks`. The `Tasks` cl
51
52
 
52
53
  ```ruby
53
54
  class Tasks
54
- desc "hello", "Say hello"
55
- def hello = sh 'echo "Hello, World!"'
55
+ desc "Say hello"
56
+ def hello = puts "Hello, World!"
56
57
  end
57
58
  ```
58
59
 
@@ -67,7 +68,7 @@ Declare positional parameters directly in the method signature. Document them in
67
68
  ```ruby
68
69
  class Tasks
69
70
  desc "hello NAME", "Say hello to NAME"
70
- def hello(name = "World") = sh "echo 'Hello, #{name}!'"
71
+ def hello(name = "World") = puts "Hello, #{name}!"
71
72
  end
72
73
  ```
73
74
 
@@ -88,7 +89,7 @@ class Tasks
88
89
  desc: "Name to greet"
89
90
 
90
91
  desc "hello NAME", "Say hello to NAME"
91
- def hello = sh "echo 'Hello, #{name}!'"
92
+ def hello = puts "Hello, #{name}!"
92
93
  end
93
94
  ```
94
95
 
@@ -103,7 +104,7 @@ class Tasks
103
104
  method_option :count, aliases: "-n", type: :numeric, default: 1, desc: "Repeat N times"
104
105
  def hello(name = "World")
105
106
  message = options[:shout] ? "HELLO, #{name.upcase}!" : "Hello, #{name}!"
106
- options[:count].times { sh "echo '#{message}'" }
107
+ options[:count].times { puts message }
107
108
  end
108
109
  end
109
110
  ```
@@ -128,7 +129,7 @@ class Tasks
128
129
  method_option :count, aliases: "-n", type: :numeric, default: 1, desc: "Repeat N times"
129
130
  def hello(name = "World")
130
131
  message = options[:shout] ? "HELLO, #{name.upcase}!" : "Hello, #{name}!"
131
- options[:count].times { sh "echo '#{message}'" }
132
+ options[:count].times { puts message }
132
133
  end
133
134
  end
134
135
  ```
@@ -139,7 +140,7 @@ end
139
140
 
140
141
  `depends_on` declares what must run before a task. Each dependency runs at most once per `asgard` invocation regardless of how many tasks declare it. Circular dependencies are caught at startup.
141
142
 
142
- `desc` and `depends_on` are independent — either can come first, both must appear before `def`. `var` declarations between `depends_on` and `def` are safe and do not consume the pending dependency.
143
+ `desc` and `depends_on` are independent — either can come first, both must appear before `def`.
143
144
 
144
145
  ### Sequential dependencies
145
146
 
@@ -147,15 +148,15 @@ Bare symbols run one after another in the order declared:
147
148
 
148
149
  ```ruby
149
150
  class Tasks
150
- desc "build", "Compile the project"
151
+ desc "Compile the project"
151
152
  def build = sh "rake build"
152
153
 
153
154
  depends_on :build
154
- desc "test", "Run the test suite"
155
+ desc "Run the test suite"
155
156
  def test = sh "rake test"
156
157
 
157
158
  depends_on :test
158
- desc "release", "Publish the gem"
159
+ desc "Publish the gem"
159
160
  def release = sh "bundle exec rake release"
160
161
  end
161
162
  ```
@@ -170,15 +171,15 @@ Wrap symbols in an array to declare they can run concurrently. Asgard waits for
170
171
 
171
172
  ```ruby
172
173
  class Tasks
173
- desc "lint", "Check code style"
174
+ desc "Check code style"
174
175
  def lint = sh "bundle exec rubocop"
175
176
 
176
- desc "typecheck", "Run type checks"
177
+ desc "Run type checks"
177
178
  def typecheck = sh "bundle exec srb tc"
178
179
 
179
180
  # lint and typecheck run in parallel, test waits for both
180
181
  depends_on [:lint, :typecheck]
181
- desc "test", "Run the test suite"
182
+ desc "Run the test suite"
182
183
  def test = sh "bundle exec rake test"
183
184
  end
184
185
  ```
@@ -193,16 +194,16 @@ Mix bare symbols (sequential) and arrays (parallel) in a single `depends_on` cal
193
194
 
194
195
  ```ruby
195
196
  class Tasks
196
- desc "setup", "Install dependencies"; def setup = sh "bundle install"
197
- desc "lint", "Check code style"; def lint = sh "bundle exec rubocop"
198
- desc "build", "Compile assets"; def build = sh "rake assets:precompile"
199
- desc "test", "Run tests"; def test = sh "bundle exec rake test"
200
- desc "notify", "Post to Slack"; def notify = sh "curl $SLACK_WEBHOOK -d '{\"text\":\"done\"}'"
197
+ desc "Install dependencies"; def setup = sh "bundle install"
198
+ desc "Check code style"; def lint = sh "bundle exec rubocop"
199
+ desc "Compile assets"; def build = sh "rake assets:precompile"
200
+ desc "Run tests"; def test = sh "bundle exec rake test"
201
+ desc "Post to Slack"; def notify = sh "curl $SLACK_WEBHOOK -d '{\"text\":\"done\"}'"
201
202
 
202
203
  # setup first, then lint+build in parallel, then test, then notify
203
204
  depends_on :setup, [:lint, :build], :test, :notify
204
- desc "ci", "Full CI pipeline"
205
- def ci = sh "echo 'CI complete'"
205
+ desc "Full CI pipeline"
206
+ def ci = puts "CI complete"
206
207
  end
207
208
  ```
208
209
 
@@ -224,18 +225,24 @@ asgard ci executes:
224
225
 
225
226
  ## Variables
226
227
 
227
- `var` declares a named value available to all tasks as a method. Pass a lambda for lazy evaluation it is called once on first use:
228
+ Shared configuration values are declared as Ruby class variables (`@@name`) at the top of the class body. Use `||=` so the first declaration wins when multiple `.loki` files reopen `Tasks`, and `.freeze` to prevent mutation:
228
229
 
229
230
  ```ruby
230
231
  class Tasks
231
- var :app, "myapp"
232
- var :version, -> { `git describe --tags`.strip }
232
+ @@app ||= "myapp".freeze
233
+ @@max_jobs ||= 4
233
234
 
234
- desc "tag", "Create a release tag"
235
- def tag = sh "git tag #{app}-#{version}"
235
+ desc "Create a release tag"
236
+ def tag = sh "git tag #{@@app}-#{version}"
237
+
238
+ private
239
+
240
+ def version = `git describe --tags`.strip
236
241
  end
237
242
  ```
238
243
 
244
+ Class variables are visible in all task instance methods and in subcommand subclasses — unlike class instance variables (`@name`), which are not accessible inside instance methods.
245
+
239
246
  ---
240
247
 
241
248
  ## Helper methods
@@ -244,13 +251,13 @@ Private methods are callable from any task in the same class but are never regis
244
251
 
245
252
  ```ruby
246
253
  class Tasks
247
- desc "build", "Compile and package"
254
+ desc "Compile and package"
248
255
  def build
249
256
  compile("src")
250
257
  package(version)
251
258
  end
252
259
 
253
- desc "release", "Build and publish"
260
+ desc "Build and publish"
254
261
  def release
255
262
  build
256
263
  sh "gem push pkg/myapp-#{version}.gem"
@@ -268,6 +275,40 @@ class Tasks
268
275
  end
269
276
  ```
270
277
 
278
+ ### The `helper` DSL method
279
+
280
+ Some values need to be available in both class context (e.g. inside `header`) and inside task instance methods. `helper` defines the method once in both contexts:
281
+
282
+ ```ruby
283
+ class Tasks
284
+ @@project ||= "myapp".freeze
285
+
286
+ helper(:version) {
287
+ File.read("lib/myapp/version.rb").match(/VERSION\s*=\s*"([^"]+)"/)[1].freeze
288
+ }
289
+
290
+ header "#{@@project} v#{version}" # class context
291
+
292
+ desc "Show the current version"
293
+ def show_version
294
+ puts version # instance context
295
+ end
296
+ end
297
+ ```
298
+
299
+ Without `helper`, achieving this requires two separate definitions:
300
+
301
+ ```ruby
302
+ def self.version = File.read(...).match(...)[1].freeze
303
+ no_commands { private def version = self.class.version }
304
+ ```
305
+
306
+ `helper` accepts positional arguments, keyword arguments, and blocks — any signature valid in a Ruby method definition:
307
+
308
+ ```ruby
309
+ helper(:tag) { |name, ver, prefix: "v"| "#{prefix}#{name}-#{ver}" }
310
+ ```
311
+
271
312
  Helpers can also be shared across multiple `.loki` files by extracting them into a plain Ruby file and loading it explicitly:
272
313
 
273
314
  ```ruby
@@ -286,13 +327,60 @@ require_relative "shared/helpers"
286
327
  class Tasks
287
328
  include BuildHelpers
288
329
 
289
- desc "build", "Compile the project"
330
+ desc "Compile the project"
290
331
  def build = compile("src")
291
332
  end
292
333
  ```
293
334
 
294
335
  ---
295
336
 
337
+ ## Help header and footer
338
+
339
+ Add static text above and below the command list in `asgard help` output:
340
+
341
+ ```ruby
342
+ class Tasks
343
+ header "my-project — build & release tasks"
344
+ footer "See https://example.com/docs for details"
345
+ end
346
+ ```
347
+
348
+ Multiple calls accumulate. `header` appends each line (top to bottom); `footer` prepends each line (bottom to top), so content from a later-loaded `.loki` file sits closer to the commands:
349
+
350
+ ```ruby
351
+ # .loki
352
+ class Tasks
353
+ header "my-project"
354
+ footer "Maintainer: you@example.com"
355
+ end
356
+
357
+ import "*.loki"
358
+
359
+ # deploy.loki
360
+ class Tasks
361
+ header " deploy targets: staging, production"
362
+ footer "See runbook at wiki/deploy"
363
+ end
364
+ ```
365
+
366
+ ```
367
+ my-project
368
+ deploy targets: staging, production
369
+
370
+ Commands:
371
+ ...
372
+
373
+ Options:
374
+ ...
375
+
376
+ See runbook at wiki/deploy
377
+ Maintainer: you@example.com
378
+ ```
379
+
380
+ Header and footer text is only shown for the general `asgard help` page, not for `asgard help <command>`.
381
+
382
+ ---
383
+
296
384
  ## Options shared across all tasks
297
385
 
298
386
  `class_option` defines an option available to every task in the class:
@@ -320,7 +408,7 @@ end
320
408
 
321
409
  ```ruby
322
410
  class Tasks
323
- desc "setup", "Bootstrap the development environment"
411
+ desc "Bootstrap the development environment"
324
412
  def setup
325
413
  sh <<~SHELL
326
414
  brew install redis postgresql
@@ -330,7 +418,7 @@ class Tasks
330
418
  SHELL
331
419
  end
332
420
 
333
- desc "analyze", "Run Python data analysis"
421
+ desc "Run Python data analysis"
334
422
  def analyze
335
423
  shebang :python3, <<~PYTHON
336
424
  import json
@@ -339,7 +427,7 @@ class Tasks
339
427
  PYTHON
340
428
  end
341
429
 
342
- desc "bundle_assets", "Build frontend assets with esbuild"
430
+ desc "Build frontend assets with esbuild"
343
431
  def bundle_assets
344
432
  shebang :node, <<~JS
345
433
  const esbuild = require("esbuild")
@@ -365,18 +453,31 @@ PY
365
453
 
366
454
  ## Environment variables
367
455
 
368
- `dotenv` loads a `.env` file into the environment before tasks run:
456
+ `dotenv` loads a `.env` file into the environment before tasks run. Use the `env` Kernel method to read environment variables inside task bodies — it accepts a symbol or string and upcases the key automatically:
369
457
 
370
458
  ```ruby
371
459
  class Tasks
372
460
  dotenv # loads .env
373
461
  dotenv ".env.local" # or a specific file
374
462
 
375
- desc "check", "Print the app name from .env"
376
- def check = sh "echo $APP_NAME"
463
+ desc "Start the server"
464
+ def start
465
+ sh "puma -p #{env(:port, '3000')} -e #{env(:rack_env, 'development')}"
466
+ end
467
+
468
+ desc "Deploy the app"
469
+ def deploy
470
+ sh "cap #{env(:deploy_target)} deploy" # raises KeyError if DEPLOY_TARGET is unset
471
+ end
377
472
  end
378
473
  ```
379
474
 
475
+ | Call | Behaviour |
476
+ |------|-----------|
477
+ | `env(:port, "3000")` | Returns `"3000"` when `PORT` is unset |
478
+ | `env(:api_key)` | Raises `KeyError` when `API_KEY` is missing |
479
+ | `env("DATABASE_URL")` | String name works too — always upcased |
480
+
380
481
  ---
381
482
 
382
483
  ## Command aliases
@@ -389,7 +490,7 @@ class Tasks
389
490
  map "--v" => "version"
390
491
  map "t" => "test"
391
492
 
392
- desc "version", "Print the version"
493
+ desc "Print the version"
393
494
  def version = puts Asgard::VERSION
394
495
  end
395
496
  ```
@@ -402,10 +503,10 @@ Group related tasks under a common name using Thor's `subcommand` method. Define
402
503
 
403
504
  ```ruby
404
505
  class DeployCommands < Tasks
405
- desc "staging", "Deploy to staging"
506
+ desc "Deploy to staging"
406
507
  def staging = sh "cap staging deploy"
407
508
 
408
- desc "production", "Deploy to production"
509
+ desc "Deploy to production"
409
510
  def production = sh "cap production deploy"
410
511
  end
411
512
 
@@ -421,20 +522,20 @@ asgard deploy staging
421
522
  asgard deploy production
422
523
  ```
423
524
 
424
- Subcommand tasks have all the same access to helper methods like `sh`, `shebang`, `depends_on`, `var`, and the built-in `--debug`/`--verbose` class options as normal tasks.
525
+ Subcommand tasks have all the same access to `sh`, `shebang`, `depends_on`, and the built-in `--debug`/`--verbose` class options as normal tasks. `@@` class variables declared on `Tasks` are also visible in subcommand subclasses.
425
526
 
426
527
  `depends_on` only works within a subcommand group exactly as it does at the top level:
427
528
 
428
529
  ```ruby
429
530
  class DBCommands < Tasks
430
- desc "migrate", "Run pending migrations"
531
+ desc "Run pending migrations"
431
532
  def migrate = sh "rails db:migrate"
432
533
 
433
- desc "seed", "Load seed data"
534
+ desc "Load seed data"
434
535
  def seed = sh "rails db:seed"
435
536
 
436
537
  depends_on :migrate, :seed
437
- desc "reset", "Migrate then seed"
538
+ desc "Migrate then seed"
438
539
  def reset = puts "Done."
439
540
  end
440
541
 
@@ -470,7 +571,7 @@ Common `method_option` keys: `aliases`, `type`, `default`, `required`, `desc`, `
470
571
 
471
572
  ## Task files
472
573
 
473
- Asgard searches the current directory and its ancestors for a `.loki` file. That file marks the project root. `*.loki` files in the same directory are loaded only when `asgard` is invoked with `--auto-load`.
574
+ Asgard searches the current directory and its ancestors for a `.loki` file. That file marks the project root. Additional `*.loki` files are loaded only when your `.loki` file explicitly calls `import`.
474
575
 
475
576
  ### Single file
476
577
 
@@ -485,16 +586,23 @@ Split tasks across files — each reopens `class Tasks`:
485
586
 
486
587
  ```
487
588
  myproject/
488
- .loki ← entry point, can be empty
589
+ .loki ← entry point; calls import to load task files
489
590
  build.loki
490
591
  deploy.loki
491
592
  test.loki
492
593
  ```
493
594
 
595
+ The `.loki` entry file must call `import` to load the other task files. It also marks the project root for auto-discovery:
596
+
597
+ ```ruby
598
+ # .loki
599
+ import "*.loki"
600
+ ```
601
+
494
602
  ```ruby
495
603
  # build.loki
496
604
  class Tasks
497
- desc "build", "Compile the project"
605
+ desc "Compile the project"
498
606
  def build = sh "rake build"
499
607
  end
500
608
  ```
@@ -503,7 +611,7 @@ end
503
611
  # test.loki
504
612
  class Tasks
505
613
  depends_on :build
506
- desc "test", "Run the test suite"
614
+ desc "Run the test suite"
507
615
  def test = sh "bundle exec rake test"
508
616
  end
509
617
  ```
@@ -512,21 +620,21 @@ end
512
620
  # deploy.loki
513
621
  class Tasks
514
622
  depends_on :test
515
- desc "deploy", "Deploy to production"
623
+ desc "Deploy to production"
516
624
  def deploy = sh "cap production deploy"
517
625
  end
518
626
  ```
519
627
 
520
- The `.loki` entry point can be completely empty — it only needs to exist to mark the project root.
628
+ In a single-file project, `.loki` can be completely empty — its presence alone marks the project root. In a multi-file project, add at least an `import` call.
521
629
 
522
630
  ### Explicit loading
523
631
 
524
- Load any Ruby or `.loki` file manually from `.loki`:
632
+ Load any Ruby or `.loki` file manually from `.loki`. Use `require_relative` for plain Ruby; use `import` for `.loki` files (it enforces the extension and is idempotent):
525
633
 
526
634
  ```ruby
527
635
  # .loki
528
636
  require_relative "shared/helpers"
529
- require_relative "ci.loki"
637
+ import "ci.loki"
530
638
 
531
639
  class Tasks
532
640
  # additional tasks
@@ -540,8 +648,7 @@ end
540
648
  | Method | Description |
541
649
  |---|---|
542
650
  | `Asgard.run!(argv)` | Entry point — finds `.loki`, loads task files, starts CLI |
543
- | `Asgard.find_task_file` | Returns path to `.loki` searching from CWD upward, or nil |
544
- | `Asgard.load_loki(dir)` | Loads all `*.loki` files in dir alphabetically — called by `run!` only when `--auto-load` is passed |
651
+ | `Asgard.find_task_file` | Returns a `Pathname` to `.loki` searching from CWD upward, or `nil` |
545
652
 
546
653
  `run!` handles its own errors — a missing `.loki`, a circular dependency, or a `depends_on` that names a task that doesn't exist all produce a clean one-line message and exit 1.
547
654
 
data/docs/api.md CHANGED
@@ -11,8 +11,7 @@ These class methods are defined on the `Asgard` module itself.
11
11
  | Method | Signature | Description |
12
12
  |---|---|---|
13
13
  | `run!` | `Asgard.run!(argv)` | Main entry point. Finds `.loki`, loads all task files, validates the dependency graph, and dispatches via Thor. Handles its own errors: missing `.loki` and circular dependencies both produce a clean one-line message and `exit 1`. |
14
- | `find_task_file` | `Asgard.find_task_file → String, nil` | Searches `Dir.pwd` and each ancestor directory for a `.loki` file. Returns the absolute path string of the first match, or `nil` if none is found. |
15
- | `load_loki` | `Asgard.load_loki(dir)` | Loads all `*.loki` files in `dir` alphabetically, excluding `.loki` itself. Called by `run!` only when `--auto-load` is present in `argv`. |
14
+ | `find_task_file` | `Asgard.find_task_file → Pathname, nil` | Searches `Dir.pwd` and each ancestor directory for a `.loki` file. Returns a `Pathname` of the first match, or `nil` if none is found. |
16
15
 
17
16
  ### `run!` Details
18
17
 
@@ -30,6 +29,82 @@ After loading task files, it calls `Tasks.validate_deps!` (circular dependency c
30
29
 
31
30
  ---
32
31
 
32
+ ## Kernel Methods
33
+
34
+ These methods are defined as `module_function` on `Kernel` and are therefore available everywhere in Ruby — at the top level of `.loki` files, inside class bodies, and inside task method bodies. No `require` or `include` is needed; they are loaded when `asgard` starts.
35
+
36
+ | Method | Signature | Returns | Description |
37
+ |---|---|---|---|
38
+ | `loki_up` | `loki_up(name = ".loki") → Pathname, nil` | `Pathname` or `nil` | Searches `Dir.pwd` and each ancestor directory for a file named `name`. Returns a `Pathname` for the first match, or `nil` if not found. Exact filenames only — does not expand globs. |
39
+ | `import` | `import(path) → true, false` | `true` if any file newly loaded | Loads one `.loki` file or a glob of `.loki` files. Relative paths resolve relative to the caller's file (like `require_relative`). Idempotent via `$LOADED_FEATURES`. Raises `ArgumentError` if `path` does not end with `.loki`. Raises `LoadError` if a non-glob path does not exist. |
40
+ | `import_up` | `import_up(name = ".loki") → true, false` | `true` if any file newly loaded | Combines `loki_up` and `import`. Walks ancestors to find the file or glob match, then loads it. Returns `false` if nothing is found. |
41
+ | `debug?` | `debug? → true, false` | `$DEBUG` | Returns the current value of `$DEBUG`. Set to `true` by `--debug` on the CLI or directly via `$DEBUG = true`. |
42
+ | `verbose?` | `verbose? → true, false` | `$VERBOSE` | Returns the current value of `$VERBOSE`. Set to `true` by `--verbose` on the CLI or directly via `$VERBOSE = true`. |
43
+ | `env` | `env(name, default = nil) → String, nil` | `ENV` value or default | Fetches an environment variable by symbol or string name. The name is upcased automatically. Raises `KeyError` when the variable is missing and no default is given. |
44
+
45
+ ### `loki_up` Details
46
+
47
+ Despite the name, `loki_up` is not limited to `.loki` files — it locates any file by walking up the directory tree:
48
+
49
+ ```ruby
50
+ loki_up # find .loki (the project root marker)
51
+ loki_up("gem_tasks.loki") # find gem_tasks.loki in CWD or any ancestor
52
+ loki_up(".env") # find the nearest .env file up the tree
53
+ loki_up("VERSION") # find a VERSION file in CWD or any ancestor
54
+ ```
55
+
56
+ Returns a `Pathname` or `nil`. Does not load the file. `Pathname` is accepted by `import`, `dotenv`, `load`, and standard Ruby file methods — no `.to_s` conversion needed in common usage.
57
+
58
+ ```ruby
59
+ if (path = loki_up("gem_tasks.loki"))
60
+ import path # Pathname accepted directly
61
+ end
62
+
63
+ # Pass the located .env to dotenv — works from any subdirectory
64
+ dotenv loki_up(".env") || ".env"
65
+ ```
66
+
67
+ ### `import` Details
68
+
69
+ ```ruby
70
+ import "build.loki" # relative — resolved from the calling file's directory
71
+ import "/home/shared/gem_tasks.loki" # absolute
72
+ import "*.loki" # all *.loki in the same directory as the caller
73
+ import "../shared/*.loki" # all *.loki one level up
74
+ import "**/*.loki" # all *.loki recursively
75
+ import Pathname.new("tasks.loki") # Pathname accepted
76
+ ```
77
+
78
+ **Extension requirement:** the path (or glob pattern) must end with `.loki`. Passing any other extension raises `ArgumentError`.
79
+
80
+ **Glob behaviour:** `Dir.glob` is used for pattern expansion. `*.loki` does not match `.loki` (the dotfile) — Ruby's glob excludes dotfiles from `*` by default. Files are loaded in the order `Dir.glob` returns them (sorted on Ruby ≥ 2.7).
81
+
82
+ **Idempotency:** each resolved absolute path is checked against `$LOADED_FEATURES` before loading. A file already in `$LOADED_FEATURES` is silently skipped and contributes `false` to the return value.
83
+
84
+ **Return value:** `true` if at least one file was newly loaded; `false` if all matched files were already loaded or no glob pattern produced any matches.
85
+
86
+ **Verbose/debug output** (to stderr):
87
+ - `verbose?` true — prints each file path as it is loaded
88
+ - `debug?` true — also prints a skip message for each already-loaded file
89
+
90
+ ### `import_up` Details
91
+
92
+ ```ruby
93
+ import_up # find and load .loki
94
+ import_up "gem_tasks.loki" # find and load gem_tasks.loki up the tree
95
+ import_up "*.loki" # find the nearest ancestor with *.loki files and load them all
96
+ ```
97
+
98
+ **Exact name:** delegates to `loki_up` to find the file, then calls `import` with the absolute path. Returns `false` without raising if the file is not found.
99
+
100
+ **Glob name:** walks ancestor directories manually using `Dir.glob`. Stops at the **first** ancestor that has any matches and loads all of them — it does not continue walking after finding a match. Returns `false` if no ancestor contains matching files.
101
+
102
+ **Verbose/debug output** (to stderr):
103
+ - `verbose?` true — prints `name → /full/path` when a file or directory is found
104
+ - `debug?` true — also prints `name not found` when the search comes up empty
105
+
106
+ ---
107
+
33
108
  ## `Asgard::Base` DSL Class Methods
34
109
 
35
110
  `Asgard::Base` is a `Thor` subclass that provides the task DSL. It is the superclass of `Tasks`. All DSL methods are class methods (called in the class body).
@@ -37,9 +112,10 @@ After loading task files, it calls `Tasks.validate_deps!` (circular dependency c
37
112
  | Method | Signature | Description |
38
113
  |---|---|---|
39
114
  | `depends_on` | `depends_on(*tasks)` | Declare prerequisites for the next `def`. Bare symbols run sequentially; arrays within the splat run as a parallel group. |
40
- | `var` | `var(name, value = nil, &block)` | Declare a named variable. If `value` responds to `call` (lambda/proc) or a block is given, the value is computed lazily on first access. Accessible in task bodies as a method. |
41
- | `import` | `import(mod)` | Include a module into the current class (thin alias for `include`). |
42
115
  | `dotenv` | `dotenv(path = ".env")` | Load the specified `.env` file into `ENV` using the dotenv gem. Silently skipped if the file does not exist. Called at class-load time. |
116
+ | `header` | `header(text)` | Append a line of text shown above the commands list in `asgard help`. Each call adds another line. No-op for per-command help. |
117
+ | `footer` | `footer(text)` | Prepend a line of text shown below the options block in `asgard help`. Each call inserts above the previous lines. No-op for per-command help. |
118
+ | `no_negate` | `no_negate(*names)` | Suppress `[--no-name]` / `[--skip-name]` help entries for one or more boolean class options. Call after the `class_option` declaration. |
43
119
  | `sh` | `sh(script, silent: false)` | Instance method. Run a shell command or multiline heredoc. Single-line → `system(script)`; multiline → `system("bash", "-c", script)`. Exits with the command's status on failure. |
44
120
  | `shebang` | `shebang(interpreter, script, silent: false)` | Instance method. Write `script` to a tempfile and execute it with `interpreter`. See the [Shell Helpers](shell.md) page for the full interpreter table. |
45
121
  | `validate_deps!` | `Tasks.validate_deps!` | Build and topologically sort the full dependency graph using Dagwood. Raises `Asgard::CircularDependencyError` on cycles. Called by `run!` at startup. |
@@ -64,10 +140,9 @@ depends_on :setup, [:lint, :build], :test # setup, then lint+build concurrently
64
140
  |---|---|---|
65
141
  | `class_option :debug` | class option | `--debug` flag. Sets `$DEBUG = true` before any task runs. Boolean, default `false`. |
66
142
  | `class_option :verbose` | class option | `--verbose` flag. Sets `$VERBOSE = true` before any task runs. Boolean, default `false`. |
67
- | `_version` | private task method | Implements `--version`. Prints `Asgard::VERSION` and exits. Registered via `map "--version" => :_version`. Uses `_` prefix convention. |
68
- | `debug?` | private instance method | Returns `$DEBUG`. Available in all task bodies and subcommand classes that inherit from `Tasks`. |
69
- | `verbose?` | private instance method | Returns `$VERBOSE`. Available in all task bodies and subcommand classes that inherit from `Tasks`. |
70
- | `--auto-load` | CLI flag (consumed by `run!`) | Triggers loading of all `*.loki` files before the main `.loki` and the requested task. Consumed by `run!` before Thor dispatch. |
143
+ | `class_option :version` | class option | `--version` flag. Handled by `Asgard.run!` before the `.loki` file is loaded — prints `Asgard::VERSION` and exits. `no_negate :version` suppresses the `[--no-version]` / `[--skip-version]` help entries. |
144
+ | `debug?` | Kernel module function | Returns `$DEBUG`. Available everywhere via `Kernel`. |
145
+ | `verbose?` | Kernel module function | Returns `$VERBOSE`. Available everywhere via `Kernel`. |
71
146
 
72
147
  ---
73
148
 
@@ -78,9 +153,10 @@ These are implementation details exposed for extensibility. Prefer the DSL metho
78
153
  | Method | Description |
79
154
  |---|---|
80
155
  | `_deps` | Hash mapping task name symbols to their stage arrays. Set by `depends_on` + `method_added`. |
81
- | `_vars` | Hash mapping var name symbols to their static values or callables. |
82
- | `_ran_tasks` | `Set` of task name symbols that have already run in the current invocation. |
83
- | `_ran_mutex` | `Mutex` protecting `_ran_tasks` for thread-safe deduplication. |
156
+ | `_done` | `Set` of task name symbols that have completed in the current invocation. |
157
+ | `_running` | `Set` of task name symbols currently executing (started but not yet finished). |
158
+ | `_cond` | Hash of `ConditionVariable` objects keyed by task name; threads wait here when a dep is in-flight. |
159
+ | `_ran_mutex` | `Mutex` protecting `_done`, `_running`, and `_cond` for thread-safe access. |
84
160
  | `_build_dep_graph(stages)` | Translates the stage array (from `_deps`) into a Dagwood-compatible hash. |
85
161
 
86
162
  ---
@@ -90,10 +166,10 @@ These are implementation details exposed for extensibility. Prefer the DSL metho
90
166
  `Asgard::Base` overrides Thor's `invoke_command` to implement dependency resolution and deduplication:
91
167
 
92
168
  1. Sets `$DEBUG` / `$VERBOSE` from `options` if the corresponding flags are present.
93
- 2. Checks `_ran_tasks` skips if this task has already run.
94
- 3. Marks the task as ran.
95
- 4. Resolves dependency stages from `_deps`, builds the Dagwood graph, and executes groups (parallel groups in threads, sequential groups one at a time).
96
- 5. Calls `command.run(self, *args)` to execute the task itself.
169
+ 2. Tries to acquire a run token (`acquire_run_token`): if the task is already in `_done`, returns immediately (skip); if it is in `_running`, waits on the `_cond` ConditionVariable until it finishes, then returns (skip); otherwise adds the task to `_running` and continues.
170
+ 3. Resolves dependency stages from `_deps`, builds the Dagwood graph, and executes groups (parallel groups in threads, sequential groups one at a time).
171
+ 4. Calls `command.run(self, *args)` to execute the task itself.
172
+ 5. In an `ensure` block, adds the task to `_done` and broadcasts on its `_cond` to wake any waiting threads.
97
173
 
98
174
  ---
99
175
 
data/docs/changelog.md CHANGED
@@ -8,6 +8,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Asg
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Added
12
+
13
+ - **`helper` DSL method** — defines a method available in both class context (e.g. inside `header`) and instance context (inside task methods) with a single declaration. Eliminates the manual `def self.name` + `no_commands { private def name = self.class.name }` boilerplate. Supports positional arguments, keyword arguments, and block arguments. See [Helper Methods](helpers.md).
14
+
15
+ ### Changed
16
+
17
+ - **`quality` task** — all three gates (`test`, `rubocop`, `flog_check`) now run in parallel. Each gate captures its own pass/fail result; output is suppressed on pass and filtered to failures only on fail. A summary table is printed after all gates complete.
18
+
19
+ ### Removed
20
+
21
+ - **`var` DSL method** — replaced by native Ruby class variables. Use `@@name ||= "value".freeze` in the class body. Class variables are visible in all task instance methods and in subcommand subclasses, making them the correct tool for shared configuration in a Thor-based task runner. See [Variables](variables.md).
22
+
11
23
  ## [0.2.0] — 2026-05-29
12
24
 
13
25
  ### Changed