Asgard
A Ruby task runner built on Thor for argument handling and Dagwood for dependency ordering.
The name comes from Norse mythology: Thor is the CLI framework, Asgard is the realm where tasks live, and the task file is named loki — because Loki holds all the tricks.
Asgard is a wrapper around Thor. Anything Thor can do — subcommands, typed options, argument validation, shell completion — is available inside a
.lokifile. Familiarity with Thor's DSL will make you immediately productive with Asgard.
Installation
gem install asgard
Or add to your Gemfile:
bundle add asgard
Tasks
Every .loki file defines tasks as methods inside class Tasks. The Tasks class is pre-defined by the gem — just reopen it and add methods.
A task with no parameters
class Tasks
desc "hello", "Say hello"
def hello = sh 'echo "Hello, World!"'
end
asgard hello
A task with a positional parameter
Declare positional parameters directly in the method signature. Document them in the desc usage string:
class Tasks
desc "hello NAME", "Say hello to NAME"
def hello(name = "World") = sh "echo 'Hello, #{name}!'"
end
asgard hello
asgard hello Alice
A task with a formal argument declaration
Use argument for richer metadata — type checking, enums, and help text:
class Tasks
argument :name,
type: :string,
default: "World",
desc: "Name to greet"
desc "hello NAME", "Say hello to NAME"
def hello = sh "echo 'Hello, #{name}!'"
end
A task with named options
Use method_option (alias: option) for named flags. Access them inside the method via options[:name]:
class Tasks
desc "hello NAME", "Say hello to NAME"
method_option :shout, aliases: "-s", type: :boolean, desc: "Uppercase the output"
method_option :count, aliases: "-n", type: :numeric, default: 1, desc: "Repeat N times"
def hello(name = "World")
= [:shout] ? "HELLO, #{name.upcase}!" : "Hello, #{name}!"
[:count].times { sh "echo '#{}'" }
end
end
asgard hello Alice --shout --count 3
A task with an extended description
long_desc provides detailed help shown by asgard help <task>:
class Tasks
long_desc <<~DESC
Says hello to NAME.
Repeats the greeting COUNT times.
Use --shout to uppercase the output.
DESC
desc "hello NAME", "Say hello to NAME"
method_option :shout, aliases: "-s", type: :boolean, desc: "Uppercase the output"
method_option :count, aliases: "-n", type: :numeric, default: 1, desc: "Repeat N times"
def hello(name = "World")
= [:shout] ? "HELLO, #{name.upcase}!" : "Hello, #{name}!"
[:count].times { sh "echo '#{}'" }
end
end
Dependencies
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.
desc and depends_on are independent — either can come first, both must appear before def.
Sequential dependencies
Bare symbols run one after another in the order declared:
class Tasks
desc "build", "Compile the project"
def build = sh "rake build"
depends_on :build
desc "test", "Run the test suite"
def test = sh "rake test"
depends_on :test
desc "release", "Publish the gem"
def release = sh "bundle exec rake release"
end
asgard release # build → test → release
Parallel dependencies
Wrap symbols in an array to declare they can run concurrently. Asgard waits for all tasks in a parallel group to finish before moving to the next stage:
class Tasks
desc "lint", "Check code style"
def lint = sh "bundle exec rubocop"
desc "typecheck", "Run type checks"
def typecheck = sh "bundle exec srb tc"
# lint and typecheck run in parallel, test waits for both
depends_on [:lint, :typecheck]
desc "test", "Run the test suite"
def test = sh "bundle exec rake test"
end
asgard test # lint ∥ typecheck → test
Mixed sequential and parallel
Mix bare symbols (sequential) and arrays (parallel) in a single depends_on call. Execution proceeds stage by stage — each stage must complete before the next begins:
class Tasks
desc "setup", "Install dependencies"; def setup = sh "bundle install"
desc "lint", "Check code style"; def lint = sh "bundle exec rubocop"
desc "build", "Compile assets"; def build = sh "rake assets:precompile"
desc "test", "Run tests"; def test = sh "bundle exec rake test"
desc "notify", "Post to Slack"; def notify = sh "curl $SLACK_WEBHOOK -d '{\"text\":\"done\"}'"
# setup first, then lint+build in parallel, then test, then notify
depends_on :setup, [:lint, :build], :test, :notify
desc "ci", "Full CI pipeline"
def ci = sh "echo 'CI complete'"
end
asgard ci executes:
setup
↓
lint ∥ build (concurrent)
↓
test
↓
notify
↓
ci
Variables
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:
class Tasks
var :app, "myapp"
var :version, -> { `git describe --tags`.strip }
desc "tag", "Create a release tag"
def tag = sh "git tag #{app}-#{version}"
end
Helper methods
Private methods are callable from any task in the same class but are never registered as commands — they won't appear in --help output and can't be invoked from the CLI.
class Tasks
desc "build", "Compile and package"
def build
compile("src")
package(version)
end
desc "release", "Build and publish"
def release
build
sh "gem push pkg/myapp-#{version}.gem"
end
private
def compile(dir)
sh "gcc -O2 -o bin/myapp #{dir}/*.c"
end
def package(ver)
sh "tar czf pkg/myapp-#{ver}.tar.gz bin/"
end
end
Helpers can also be shared across multiple .loki files by extracting them into a plain Ruby file and loading it explicitly:
# shared/helpers.rb
module BuildHelpers
private
def compile(dir)
sh "gcc -O2 -o bin/myapp #{dir}/*.c"
end
end
# .loki
require_relative "shared/helpers"
class Tasks
include BuildHelpers
desc "build", "Compile the project"
def build = compile("src")
end
Options shared across all tasks
class_option defines an option available to every task in the class:
class Tasks
class_option :dry_run, aliases: "-n", type: :boolean, desc: "Print commands without running"
desc "deploy ENV", "Deploy to the given environment"
def deploy(env = "staging")
if [:dry_run]
puts "Would deploy to #{env}"
else
sh "cap #{env} deploy"
end
end
end
Shell helpers
sh runs any shell command or multiline heredoc. shebang writes a script body to a tempfile and executes it with the given interpreter. Both exit with the command's status code on failure.
class Tasks
desc "setup", "Bootstrap the development environment"
def setup
sh <<~SHELL
brew install redis postgresql
brew services start redis
bundle install
rails db:setup
SHELL
end
desc "analyze", "Run Python data analysis"
def analyze
shebang :python3, <<~PYTHON
import json
data = json.load(open("results.json"))
print(f"Total: {sum(data.values())}")
PYTHON
end
desc "bundle_assets", "Build frontend assets with esbuild"
def bundle_assets
shebang :node, <<~JS
const esbuild = require("esbuild")
esbuild.buildSync({ entryPoints: ["src/app.js"], bundle: true, outfile: "dist/app.js" })
JS
end
end
Supported interpreters: :python3, :python, :node, :ruby, :perl, :bash, :sh. Any other symbol is passed directly to system with a .tmp extension.
Pass silent: true to suppress the command echo:
def build = sh "rake build", silent: true
Environment variables
dotenv loads a .env file into the environment before tasks run:
class Tasks
dotenv # loads .env
dotenv ".env.local" # or a specific file
desc "check", "Print the app name from .env"
def check = sh "echo $APP_NAME"
end
Command aliases
map creates alternative names for a task:
class Tasks
map "-v" => "version"
map "--v" => "version"
map "t" => "test"
desc "version", "Print the version"
def version = puts Asgard::VERSION
end
Subcommands
Group related tasks under a common name using Thor's subcommand method. Define a subcommand class that inherits from Tasks, then register it with a name and description.
class DeployCommands < Tasks
desc "staging", "Deploy to staging"
def staging = sh "cap staging deploy"
desc "production", "Deploy to production"
def production = sh "cap production deploy"
end
class Tasks
desc "deploy SUBCOMMAND", "Deploy the application"
subcommand "deploy", DeployCommands
end
asgard deploy # shows deploy subcommand help
asgard deploy staging
asgard deploy production
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.
depends_on only works within a subcommand group exactly as it does at the top level:
class DBCommands < Tasks
desc "migrate", "Run pending migrations"
def migrate = sh "rails db:migrate"
desc "seed", "Load seed data"
def seed = sh "rails db:seed"
depends_on :migrate, :seed
desc "reset", "Migrate then seed"
def reset = puts "Done."
end
class Tasks
desc "db SUBCOMMAND", "Manage the database"
subcommand "db", DBCommands
end
asgard db reset # migrate → seed → reset
Each subcommand group can have its own desc, long_desc, option, class_option, and map declarations, all scoped to that group.
See examples/server_subcommands.loki and examples/db_subcommands.loki for full working examples.
method_option types reference
| Type | CLI example | Ruby value |
|---|---|---|
:string |
--branch main |
"main" |
:boolean |
--force / --no-force |
true / false |
:numeric |
--count 3 |
3 |
:array |
--tags foo bar baz |
["foo", "bar", "baz"] |
:hash |
--vars KEY:val FOO:bar |
{"KEY"=>"val", "FOO"=>"bar"} |
Common method_option keys: aliases, type, default, required, desc, enum, banner.
Task files
Asgard searches the current directory and its ancestors for a .loki file. That file marks the project root. All *.loki files in the same directory are auto-loaded alphabetically before .loki is loaded.
Single file
myproject/
.loki
Multiple files
Split tasks across files — each reopens class Tasks:
myproject/
.loki ← entry point, can be empty
build.loki
deploy.loki
test.loki
# build.loki
class Tasks
desc "build", "Compile the project"
def build = sh "rake build"
end
# test.loki
class Tasks
depends_on :build
desc "test", "Run the test suite"
def test = sh "bundle exec rake test"
end
# deploy.loki
class Tasks
depends_on :test
desc "deploy", "Deploy to production"
def deploy = sh "cap production deploy"
end
The .loki entry point can be completely empty — it only needs to exist to mark the project root.
Explicit loading
Load any Ruby or .loki file manually from .loki:
# .loki
require_relative "shared/helpers"
require_relative "ci.loki"
class Tasks
# additional tasks
end
Asgard module API
| Method | Description |
|---|---|
Asgard.run!(argv) |
Entry point — finds .loki, loads task files, starts CLI |
Asgard.find_task_file |
Returns path to .loki searching from CWD upward, or nil |
Asgard.load_loki(dir) |
Loads all *.loki files in dir alphabetically |
run! handles its own errors — a missing .loki or a circular dependency both produce a clean one-line message and exit 1.
Development
git clone git@github.com:MadBomber/asgard.git
cd asgard
bundle install
bundle exec rake test # run tests (95% coverage minimum enforced)
bundle exec bin/asgard help # exercise the CLI against this gem's own .loki
Contributing
Bug reports and pull requests are welcome at https://github.com/MadBomber/asgard.
License
MIT. See LICENSE.txt.