diff --git a/src/cli.cr b/src/cli.cr
new file mode 100644
index 0000000..038addf
--- /dev/null
+++ b/src/cli.cr
@@ -0,0 +1,66 @@
+require "option_parser"
+require "digest/sha256"
+require "colorize"
+
+require "./launcher"
+
+module DocMachine
+  class Cli
+    def initialize
+    end
+
+    def start(argv)
+      options = {} of Symbol => String
+
+      parser = OptionParser.new do |opts|
+        opts.banner = "Usage: script.cr [options]"
+
+        opts.on("-d", "--data-dir DIR", "Content directory") do |dir|
+          options[:data_dir] = dir
+        end
+
+        opts.on("-a", "--action ACTION", "Action (watch, build, shell, etc.)") do |action|
+          options[:action] = action
+        end
+
+        opts.on("-t", "--tty", "Enable TTY mode (needed for shell)") do |tty|
+          options[:tty] = tty
+        end
+
+        opts.on("-v", "--verbose", "Enable verbosity") do |verbose|
+          options[:verbose] = true.to_s
+        end
+
+        opts.on("-h", "--help", "Show this help") do
+          puts opts
+          exit
+        end
+      end
+
+      parser.parse(ARGV)
+
+
+      basedir = options[:data_dir]? ? options[:data_dir] : Dir.current
+      basehash = Digest::SHA256.hexdigest(basedir)[0..6]
+      action = options[:action]? ? options[:action] : "watch"
+      verbosity = options[:verbose]? ? options[:verbose] : 0
+      docker_image = "glenux/docmachine:latest"
+
+      if options[:help]?
+        puts "Usage: script.cr [options]"
+        puts ""
+        puts "-d, --data-dir DIR     Content directory"
+        puts "-a, --action ACTION    Action (watch, build, shell, etc.)"
+        puts "-t, --tty              Enable TTY mode (needed for shell)"
+        puts "-v, --verbose          Enable verbosity"
+        puts "-h, --help             Show this help"
+        exit
+      end
+
+      launcher = DocMachine::Launcher.new(options)
+      launcher.prepare
+      launcher.start
+      launcher.wait
+    end
+  end
+end
diff --git a/src/launcher.cr b/src/launcher.cr
new file mode 100644
index 0000000..9e06b66
--- /dev/null
+++ b/src/launcher.cr
@@ -0,0 +1,124 @@
+
+module DocMachine
+  class Launcher
+    def initialize(config)
+      @basedir = config[:data_dir]? ? config[:data_dir] : Dir.current
+      @basehash = Digest::SHA256.hexdigest(@basedir)[0..6]
+      @action = config[:action]? ? config[:action] : "watch"
+      # @verbosity = config[:verbose]? ? config[:verbose] : 0
+      @docker_name = "docmachine-#{@basehash}"
+      @docker_image = "glenux/docmachine:latest"
+      @docker_opts = [] of String
+      @enable_tty = !!config[:tty]?
+      @process = nil
+    end
+
+
+    # cleanup environment
+    # create directories
+    # setup permissions
+    def prepare()
+      puts "basedir      = #{@basedir}"
+      puts "docker_image = #{@docker_image}"
+      puts "action       = #{@action}"
+
+      docker_cid = %x{docker ps -f "name=#{@docker_name}" -q}.strip
+
+      puts "docker_name: #{@docker_name}"
+      puts "docker_cid: #{docker_cid}"
+
+      if !docker_cid.empty?
+        Process.run("docker", ["kill", @docker_name])
+      end
+    end
+
+    def start()
+      uid = %x{id -u}.strip
+      gid = %x{id -g}.strip
+      puts "uid: #{uid}"
+      puts "cid: #{gid}"
+
+      docker_opts = [] of String
+      docker_opts << "run"
+      docker_opts << "-i"
+      # add tty support
+      docker_opts << "-t" if @enable_tty
+      # add container name
+      docker_opts.concat ["--name", @docker_name]
+      docker_opts << "--rm"
+      docker_opts << "--shm-size=1gb"
+      docker_opts.concat ["-e", "EXT_UID=#{uid}"]
+      docker_opts.concat ["-e", "EXT_GID=#{gid}"]
+      docker_opts.concat ["-v", "#{@basedir}/docs:/app/docs"]
+      docker_opts.concat ["-v", "#{@basedir}/slides:/app/slides"]
+      docker_opts.concat ["-v", "#{@basedir}/images:/app/images"]
+      docker_opts.concat ["-v", "#{@basedir}/_build:/app/_build"]
+
+      ## Detect Marp SCSS
+      if File.exists?("#{@basedir}/.marp/theme.scss")
+        docker_opt_marp_theme = ["-v", "#{@basedir}/.marp:/app/.marp"]
+        docker_opts.concat docker_opt_marp_theme
+        puts "Theme: detected Marp files. Adding option to command line (#{docker_opt_marp_theme})"
+      else
+        puts "Theme: no theme detected. Using default files"
+        end
+
+      ## Detect Mkdocs configuration - old format (full)
+      if File.exists?("#{@basedir}/mkdocs.yml")
+        puts "Mkdocs: detected mkdocs.yml file. Please rename to mkdocs-patch.yml"
+        exit 1
+      end
+
+      ## Detect Mkdocs configuration - new format (patch)
+      if File.exists?("#{@basedir}/mkdocs-patch.yml")
+        docker_opt_mkdocs_config = ["-v", "#{@basedir}/mkdocs-patch.yml:/app/mkdocs-patch.yml"]
+        docker_opts.concat docker_opt_mkdocs_config
+        puts "Mkdocs: detected mkdocs-patch.yml file. Adding option to command line (#{docker_opt_mkdocs_config})"
+      else
+        puts "Mkdocs: no mkdocs-patch.yml detected. Using default files"
+        end
+
+      ## Detect slides
+      if Dir.exists?("#{@basedir}/slides")
+        docker_opt_marp_port = ["-p", "5200:5200"]
+        docker_opts.concat docker_opt_marp_port 
+        puts "Slides: detected slides directory. Adding option to command line (#{docker_opt_marp_port})"
+      else
+        puts "Slides: no slides directory detected."
+        end
+
+      ## Detect docs
+      if Dir.exists?("#{@basedir}/docs")
+        docker_opt_marp_port = ["-p", "5100:5100"]
+        docker_opts.concat docker_opt_marp_port
+        puts "Slides: detected docs directory. Adding option to command line (#{docker_opt_marp_port})"
+      else
+        puts "Slides: no slides docs detected."
+        end
+
+      docker_opts << @docker_image
+      docker_opts << @action
+
+      puts docker_opts.inspect.colorize(:yellow)
+      @process = Process.new("docker", docker_opts, output: STDOUT, error: STDERR)
+    end
+
+    def wait()
+      process = @process
+      return if process.nil?
+
+      Signal::INT.trap do
+        STDERR.puts "Received CTRL-C"
+        process.signal(Signal::KILL)
+        Process.run("docker", ["kill", @docker_name])
+      end
+      process.wait
+    end
+
+    def stop()
+    end
+
+    def docker_opts()
+    end
+  end
+end
diff --git a/src/main.cr b/src/main.cr
index 7160f6f..c426b18 100644
--- a/src/main.cr
+++ b/src/main.cr
@@ -1,140 +1,6 @@
-require "option_parser"
-require "digest/sha256"
-require "colorize"
 
-options = {} of Symbol => String
+require "./cli"
 
-parser = OptionParser.new do |opts|
-  opts.banner = "Usage: script.cr [options]"
-
-  opts.on("-d", "--data-dir DIR", "Content directory") do |dir|
-    options[:data_dir] = dir
-  end
-  
-  opts.on("-a", "--action ACTION", "Action (watch, build, shell, etc.)") do |action|
-    options[:action] = action
-  end
-  
-  opts.on("-t", "--tty", "Enable TTY mode (needed for shell)") do |tty|
-    options[:tty] = tty
-  end
-  
-  opts.on("-v", "--verbose", "Enable verbosity") do |verbose|
-    options[:verbose] = verbose.to_s
-  end
-  
-  opts.on("-h", "--help", "Show this help") do
-    puts opts
-    exit
-  end
-end
-
-parser.parse(ARGV)
-
-
-basedir = options[:data_dir]? ? options[:data_dir] : Dir.current
-basehash = Digest::SHA256.hexdigest(basedir)[0..6]
-action = options[:action]? ? options[:action] : "watch"
-verbosity = options[:verbose]? ? options[:verbose] : 0
-docker_image = "glenux/docmachine:latest"
-docker_opts = [] of String
-
-if options[:help]?
-  puts "Usage: script.cr [options]"
-  puts ""
-  puts "-d, --data-dir DIR     Content directory"
-  puts "-a, --action ACTION    Action (watch, build, shell, etc.)"
-  puts "-t, --tty              Enable TTY mode (needed for shell)"
-  puts "-v, --verbose          Enable verbosity"
-  puts "-h, --help             Show this help"
-  exit
-end
-
-puts "basedir      = #{basedir}"
-puts "docker_image = #{docker_image}"
-puts "action       = #{action}"
-
-docker_name = "docmachine-#{basehash}"
-docker_cid = %x{docker ps -f "name=#{docker_name}" -q}.strip
-uid = %x{id -u}.strip
-gid = %x{id -g}.strip
-puts "uid: #{uid}"
-puts "cid: #{gid}"
-
-puts "docker_name: #{docker_name}"
-puts "docker_cid: #{docker_cid}"
-
-if !docker_cid.empty?
-  Process.run("docker", ["kill", docker_name])
-end
-
-docker_opts << "run"
-docker_opts << "-i"
-# add tty support
-docker_opts << "-t" if options[:tty]?
-# add container name
-docker_opts.concat ["--name", docker_name]
-docker_opts << "--rm"
-docker_opts << "--shm-size=1gb"
-docker_opts.concat ["-e", "EXT_UID=#{uid}"]
-docker_opts.concat ["-e", "EXT_GID=#{gid}"]
-docker_opts.concat ["-v", "#{basedir}/docs:/app/docs"]
-docker_opts.concat ["-v", "#{basedir}/slides:/app/slides"]
-docker_opts.concat ["-v", "#{basedir}/images:/app/images"]
-docker_opts.concat ["-v", "#{basedir}/_build:/app/_build"]
-
-## Detect Marp SCSS
-if File.exists?("#{basedir}/.marp/theme.scss")
-  docker_opt_marp_theme = ["-v", "#{basedir}/.marp:/app/.marp"]
-  docker_opts.concat docker_opt_marp_theme
-  puts "Theme: detected Marp files. Adding option to command line (#{docker_opt_marp_theme})"
-else
-  puts "Theme: no theme detected. Using default files"
-end
-
-## Detect Mkdocs configuration - old format (full)
-if File.exists?("#{basedir}/mkdocs.yml")
-  puts "Mkdocs: detected mkdocs.yml file. Please rename to mkdocs-patch.yml"
-  exit 1
-end
-
-## Detect Mkdocs configuration - new format (patch)
-if File.exists?("#{basedir}/mkdocs-patch.yml")
-  docker_opt_mkdocs_config = ["-v", "#{basedir}/mkdocs-patch.yml:/app/mkdocs-patch.yml"]
-  docker_opts.concat docker_opt_mkdocs_config
-  puts "Mkdocs: detected mkdocs-patch.yml file. Adding option to command line (#{docker_opt_mkdocs_config})"
-else
-  puts "Mkdocs: no mkdocs-patch.yml detected. Using default files"
-end
-
-## Detect slides
-if Dir.exists?("#{basedir}/slides")
-  docker_opt_marp_port = ["-p", "5200:5200"]
-  docker_opts.concat docker_opt_marp_port 
-  puts "Slides: detected slides directory. Adding option to command line (#{docker_opt_marp_port})"
-else
-  puts "Slides: no slides directory detected."
-end
-
-## Detect docs
-if Dir.exists?("#{basedir}/docs")
-  docker_opt_marp_port = ["-p", "5100:5100"]
-  docker_opts.concat docker_opt_marp_port
-  puts "Slides: detected docs directory. Adding option to command line (#{docker_opt_marp_port})"
-else
-  puts "Slides: no slides docs detected."
-end
-
-docker_opts << docker_image
-docker_opts << action
-
-puts docker_opts.inspect.colorize(:yellow)
-process = Process.new("docker", docker_opts, output: STDOUT, error: STDERR)
-
-Signal::INT.trap do
-  STDERR.puts "Received CTRL-C"
-  process.signal(Signal::KILL)
-  Process.run("docker", ["kill", docker_name])
-end
-process.wait
+app = DocMachine::Cli.new
+app.start(ARGV)