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 .loki file. 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")
    message = options[:shout] ? "HELLO, #{name.upcase}!" : "Hello, #{name}!"
    options[:count].times { sh "echo '#{message}'" }
  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")
    message = options[:shout] ? "HELLO, #{name.upcase}!" : "Hello, #{name}!"
    options[:count].times { sh "echo '#{message}'" }
  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 options[: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.