feat(cli): subcommand dispatch with 9 commands + output formatters

CLI grammar:
  cre run                    daemon (sqlite or postgres)
  cre watch                  daemon + live TUI in same process
  cre check                  one-shot policy eval (CI-friendly exit codes)
  cre rotate <id>            manual rotation
  cre policy list / show     compile-time-baked policy registry inspection
  cre export --framework=X   compliance bundle (Phase 14 stub)
  cre audit verify           hash chain + HMAC + Merkle integrity verification
  cre demo                   Tier 1 demo (Phase 15 stub)
  cre version                version
  cre help                   usage

Output module: human / json / ndjson formatter; CI-friendly exit codes
(0 ok, 1 violations/error, 64 usage, 2 audit-chain-broken).

run/watch wire engine + persistence + scheduler + evaluator + log
notifier (and TUI for watch); SIGINT triggers graceful shutdown.

7 unit specs cover usage / version / policy-list-empty / policy-show-404
/ unknown-subcommand / check-no-violations. All commands callable
end-to-end against a working binary.
This commit is contained in:
CarterPerez-dev 2026-04-29 01:12:46 -04:00
parent 9651ff56f1
commit 545e189b43
21 changed files with 755 additions and 23 deletions

View File

@ -0,0 +1,58 @@
# ===================
# ©AngelaMos | 2026
# cli_spec.cr
# ===================
require "../../spec_helper"
require "../../../src/cre/cli/cli"
describe CRE::Cli do
it "prints usage when no args given and exits 64" do
io = IO::Memory.new
code = CRE::Cli.dispatch([] of String, io)
code.should eq 64
io.to_s.should contain "Subcommands"
end
it "prints usage on help" do
io = IO::Memory.new
code = CRE::Cli.dispatch(["help"], io)
code.should eq 0
io.to_s.should contain "Subcommands"
end
it "prints version on version subcommand" do
io = IO::Memory.new
code = CRE::Cli.dispatch(["version"], io)
code.should eq 0
io.to_s.strip.should eq CRE::VERSION
end
it "policy list works against an empty registry" do
CRE::Policy.clear_registry!
io = IO::Memory.new
code = CRE::Cli.dispatch(["policy", "list"], io)
code.should eq 0
io.to_s.should contain "no policies"
end
it "policy show returns 1 on missing policy" do
CRE::Policy.clear_registry!
io = IO::Memory.new
code = CRE::Cli.dispatch(["policy", "show", "nonexistent"], io)
code.should eq 1
end
it "rejects unknown subcommands" do
io = IO::Memory.new
code = CRE::Cli.dispatch(["thisisbad"], io)
code.should eq 64
io.to_s.should contain "unknown subcommand"
end
it "check on empty db returns 0 (no violations)" do
io = IO::Memory.new
code = CRE::Cli.dispatch(["check", "--db=:memory:"], io)
code.should eq 0
end
end

View File

@ -4,10 +4,11 @@
# ===================
require "./cre/version"
require "./cre/cli/cli"
module CRE
def self.main(argv : Array(String)) : Int32
0
Cli.dispatch(argv)
end
end

View File

@ -0,0 +1,66 @@
# ===================
# ©AngelaMos | 2026
# cli.cr
# ===================
require "option_parser"
require "../version"
require "./output"
require "./commands"
module CRE::Cli
USAGE = <<-USAGE
cre - Credential Rotation Enforcer
Usage: cre <subcommand> [options]
Subcommands:
run headless daemon (production / systemd)
watch engine + live TUI in same process
check evaluate policies once, exit non-zero on violations
rotate <credential-id> manually rotate a single credential
policy list list compiled-in policies
policy show <name> inspect one policy
export --framework=<name> generate signed compliance evidence bundle
audit verify verify hash chain + HMAC ratchet + Merkle batches
demo tier-1 zero-deps demo (SQLite + .env rotator)
version print version
help this message
Common options:
--output=human|json|ndjson output format (default: human)
--config=PATH config file (default: $CRE_CONFIG or ./config.cr)
USAGE
def self.dispatch(argv : Array(String), io : IO = STDOUT) : Int32
if argv.empty? || %w[--help -h help].includes?(argv.first)
io.puts USAGE
return argv.empty? ? 64 : 0 # 64 = EX_USAGE
end
subcommand = argv.shift
case subcommand
when "version"
io.puts CRE::VERSION
0
when "run" then Commands::Run.new.execute(argv, io)
when "watch" then Commands::Watch.new.execute(argv, io)
when "check" then Commands::Check.new.execute(argv, io)
when "rotate" then Commands::Rotate.new.execute(argv, io)
when "policy" then Commands::Policy.new.execute(argv, io)
when "export" then Commands::Export.new.execute(argv, io)
when "audit" then Commands::Audit.new.execute(argv, io)
when "demo" then Commands::Demo.new.execute(argv, io)
else
io.puts "unknown subcommand: #{subcommand}"
io.puts USAGE
64
end
rescue ex : OptionParser::InvalidOption | OptionParser::MissingOption
io.puts "error: #{ex.message}"
64
rescue ex
io.puts "error: #{ex.message}"
1
end
end

View File

@ -0,0 +1,14 @@
# ===================
# ©AngelaMos | 2026
# commands.cr
# ===================
require "./commands/run"
require "./commands/watch"
require "./commands/check"
require "./commands/rotate"
require "./commands/policy"
require "./commands/export"
require "./commands/audit"
require "./commands/demo"
require "./commands/version"

View File

@ -0,0 +1,49 @@
# ===================
# ©AngelaMos | 2026
# audit.cr
# ===================
require "../../audit/audit_log"
require "../../persistence/sqlite/sqlite_persistence"
module CRE::Cli::Commands
class Audit
def execute(argv : Array(String), io : IO) : Int32
sub = argv.shift?
case sub
when "verify" then verify(argv, io)
when nil, "--help", "-h"
io.puts "Usage: cre audit verify [--db=PATH]"
0
else
io.puts "unknown audit subcommand: #{sub}"
64
end
end
private def verify(argv : Array(String), io : IO) : Int32
db_path = ENV["CRE_DB_PATH"]? || "cre.db"
hmac_hex = ENV["CRE_HMAC_KEY_HEX"]? || "0" * 64
OptionParser.parse(argv) do |parser|
parser.on("--db=PATH", "") { |p| db_path = p }
end
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(db_path)
persist.migrate!
log = CRE::Audit::AuditLog.new(persist, hmac_hex.hexbytes, 1, 1024)
ok = log.verify_chain
latest_seq = persist.audit.latest_seq
persist.close
if ok
io.puts "✓ audit chain valid: #{latest_seq} entries"
0
else
io.puts "✗ audit chain BROKEN — verification failed"
2
end
end
end
end

View File

@ -0,0 +1,77 @@
# ===================
# ©AngelaMos | 2026
# check.cr
# ===================
require "../../engine/event_bus"
require "../../persistence/sqlite/sqlite_persistence"
require "../../policy/evaluator"
module CRE::Cli::Commands
class Check
def execute(argv : Array(String), io : IO) : Int32
_help_requested = false
output_format = OutputFormat::Human
db_path = ":memory:"
OptionParser.parse(argv) do |parser|
parser.banner = "Usage: cre check [options]"
parser.on("--output=FORMAT", "human|json|ndjson") { |f| output_format = Output.parse_format(f) }
parser.on("--db=PATH", "SQLite path (default :memory:)") { |p| db_path = p }
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
end
return 0 if _help_requested
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(db_path)
persist.migrate!
bus = CRE::Engine::EventBus.new
ch = bus.subscribe(buffer: 256)
bus.run
CRE::Policy::Evaluator.new(bus, persist).evaluate_all
sleep 0.1.seconds
violations = drain(ch).select(&.is_a?(CRE::Events::PolicyViolation)).map(&.as(CRE::Events::PolicyViolation))
bus.stop
persist.close
case output_format
in OutputFormat::Human
if violations.empty?
io.puts "OK: no policy violations"
else
io.puts "VIOLATIONS (#{violations.size}):"
violations.each do |v|
io.puts " - credential=#{v.credential_id} policy=#{v.policy_name} reason=#{v.reason}"
end
end
in OutputFormat::Json, OutputFormat::Ndjson
rows = violations.map do |v|
{
"credential_id" => v.credential_id.to_s,
"policy" => v.policy_name,
"reason" => v.reason,
"occurred_at" => v.occurred_at.to_rfc3339,
}
end
Output.print(io, output_format, rows)
end
violations.empty? ? 0 : 1
end
private def drain(ch : ::Channel(CRE::Events::Event)) : Array(CRE::Events::Event)
out = [] of CRE::Events::Event
loop do
select
when ev = ch.receive
out << ev
else
break
end
end
out
end
end
end

View File

@ -0,0 +1,21 @@
# ===================
# ©AngelaMos | 2026
# demo.cr
# ===================
require "../../demo/tier_1"
module CRE::Cli::Commands
class Demo
def execute(argv : Array(String), io : IO) : Int32
_help_requested = false
OptionParser.parse(argv) do |parser|
parser.banner = "Usage: cre demo (tier-1, no external deps)"
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
end
return 0 if _help_requested
CRE::Demo::Tier1.run(io)
end
end
end

View File

@ -0,0 +1,40 @@
# ===================
# ©AngelaMos | 2026
# export.cr
# ===================
require "../../compliance/bundle"
require "../../persistence/sqlite/sqlite_persistence"
module CRE::Cli::Commands
class Export
def execute(argv : Array(String), io : IO) : Int32
_help_requested = false
framework = "soc2"
out_path = "evidence.zip"
db_path = ENV["CRE_DB_PATH"]? || "cre.db"
OptionParser.parse(argv) do |parser|
parser.banner = "Usage: cre export --framework=<name> --out=<file>"
parser.on("--framework=NAME", "soc2|pci_dss|iso27001|hipaa") { |f| framework = f }
parser.on("--out=PATH", "output zip path") { |p| out_path = p }
parser.on("--db=PATH", "") { |p| db_path = p }
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
end
return 0 if _help_requested
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(db_path)
persist.migrate!
bundle = CRE::Compliance::Bundle.new(persist, framework)
bundle.write(out_path)
persist.close
io.puts "evidence bundle written to #{out_path}"
0
rescue ex
io.puts "export failed: #{ex.message}"
1
end
end
end

View File

@ -0,0 +1,75 @@
# ===================
# ©AngelaMos | 2026
# policy.cr
# ===================
require "../../policy/policy"
module CRE::Cli::Commands
class Policy
def execute(argv : Array(String), io : IO) : Int32
sub = argv.shift?
case sub
when "list" then list(argv, io)
when "show" then show(argv, io)
when nil, "--help", "-h"
io.puts "Usage: cre policy <list|show <name>>"
0
else
io.puts "unknown policy subcommand: #{sub}"
64
end
end
private def list(argv : Array(String), io : IO) : Int32
output_format = OutputFormat::Human
OptionParser.parse(argv) do |parser|
parser.on("--output=FORMAT", "human|json") { |f| output_format = Output.parse_format(f) }
end
policies = CRE::Policy.registry
case output_format
in OutputFormat::Human
if policies.empty?
io.puts "(no policies compiled in)"
else
io.puts "Compiled policies:"
policies.each { |p| io.puts " - #{p.name} (max_age=#{p.max_age}, enforce=#{p.enforce_action.to_s.downcase})" }
end
in OutputFormat::Json, OutputFormat::Ndjson
rows = policies.map do |p|
{
"name" => p.name,
"max_age" => p.max_age.to_s,
"enforce" => p.enforce_action.to_s.downcase,
"warn_at" => p.warn_at.try(&.to_s),
}
end
Output.print(io, output_format, rows)
end
0
end
private def show(argv : Array(String), io : IO) : Int32
name = argv.shift?
if name.nil?
io.puts "usage: cre policy show <name>"
return 64
end
policy = CRE::Policy.registry.find { |p| p.name == name }
if policy.nil?
io.puts "policy not found: #{name}"
return 1
end
io.puts "name: #{policy.name}"
io.puts "desc: #{policy.description || "(none)"}"
io.puts "max_age: #{policy.max_age}"
io.puts "warn_at: #{policy.warn_at || "(none)"}"
io.puts "enforce: #{policy.enforce_action.to_s.downcase}"
io.puts "channels: #{policy.notify_channels.map(&.to_s.downcase).join(", ")}"
io.puts "triggers:"
policy.triggers.each { |k, v| io.puts " #{k.to_s.downcase}: #{v.to_s.downcase}" }
0
end
end
end

View File

@ -0,0 +1,90 @@
# ===================
# ©AngelaMos | 2026
# rotate.cr
# ===================
require "../../engine/event_bus"
require "../../engine/rotation_orchestrator"
require "../../persistence/sqlite/sqlite_persistence"
require "../../rotators/env_file"
module CRE::Cli::Commands
class Rotate
def execute(argv : Array(String), io : IO) : Int32
_help_requested = false
db_url = ENV["DATABASE_URL"]? || "sqlite:cre.db"
cred_id_str = nil
OptionParser.parse(argv) do |parser|
parser.banner = "Usage: cre rotate <credential-id> [options]"
parser.on("--db=URL", "") { |u| db_url = u }
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
parser.unknown_args { |args| cred_id_str = args.first? }
end
return 0 if _help_requested
if cred_id_str.nil?
io.puts "usage: cre rotate <credential-id>"
return 64
end
cred_id = UUID.new(cred_id_str.not_nil!) rescue nil
if cred_id.nil?
io.puts "invalid credential id"
return 64
end
persist = if db_url.starts_with?("sqlite:")
CRE::Persistence::Sqlite::SqlitePersistence.new(db_url.lchop("sqlite:"))
else
raise "rotate currently supports SQLite only via CLI shortcut"
end
persist.migrate!
cred = persist.credentials.find(cred_id)
if cred.nil?
io.puts "credential not found: #{cred_id}"
return 1
end
rotator_class = CRE::Rotators::Rotator.for(rotator_kind_for(cred.kind))
if rotator_class.nil?
io.puts "no rotator registered for kind=#{cred.kind}"
return 1
end
bus = CRE::Engine::EventBus.new
bus.run
orchestrator = CRE::Engine::RotationOrchestrator.new(bus, persist)
rotator = case cred.kind
when CRE::Domain::CredentialKind::EnvFile then CRE::Rotators::EnvFileRotator.new
else
raise "this CLI shortcut only supports env_file via direct rotation; cloud rotators need full daemon config"
end
io.puts "Rotating #{cred.name} (#{cred.id}) via #{rotator.kind}..."
state = orchestrator.run(cred, rotator)
sleep 0.1.seconds
bus.stop
persist.close
case state
when CRE::Persistence::RotationState::Completed then io.puts "✓ rotation completed"; 0
when CRE::Persistence::RotationState::Failed then io.puts "✗ rotation failed"; 1
else io.puts "rotation ended in unexpected state #{state}"; 2
end
end
private def rotator_kind_for(kind : CRE::Domain::CredentialKind) : Symbol
case kind
in .aws_secretsmgr? then :aws_secretsmgr
in .vault_dynamic? then :vault_dynamic
in .github_pat? then :github_pat
in .env_file? then :env_file
in .aws_iam_key? then :aws_iam_key
in .database? then :database
end
end
end
end

View File

@ -0,0 +1,72 @@
# ===================
# ©AngelaMos | 2026
# run.cr
# ===================
require "../../engine/engine"
require "../../engine/scheduler"
require "../../persistence/sqlite/sqlite_persistence"
require "../../persistence/postgres/postgres_persistence"
require "../../policy/evaluator"
require "../../notifiers/log_notifier"
module CRE::Cli::Commands
class Run
def execute(argv : Array(String), io : IO) : Int32
_help_requested = false
db_url = ENV["DATABASE_URL"]? || "sqlite:cre.db"
hmac_hex = ENV["CRE_HMAC_KEY_HEX"]? || "0" * 64
interval = (ENV["CRE_TICK_SECONDS"]? || "60").to_i
OptionParser.parse(argv) do |parser|
parser.banner = "Usage: cre run [options]"
parser.on("--db=URL", "database URL (sqlite:path or postgres://...)") { |u| db_url = u }
parser.on("--interval=SECONDS", "scheduler tick interval") { |i| interval = i.to_i }
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
end
return 0 if _help_requested
persist = build_persistence(db_url)
persist.migrate!
engine = CRE::Engine::Engine.new(persist, hmac_hex.hexbytes)
log_notifier = CRE::Notifiers::LogNotifier.new(engine.bus)
evaluator = CRE::Policy::Evaluator.new(engine.bus, persist)
scheduler = CRE::Engine::Scheduler.new(engine.bus, interval.seconds)
engine.start
log_notifier.start
evaluator.start
scheduler.start
io.puts "cre running. PID #{Process.pid}, tick #{interval}s, db #{redact(db_url)}"
Signal::INT.trap do
io.puts "\nshutting down..."
scheduler.stop
evaluator.stop
log_notifier.stop
engine.stop
persist.close
exit 0
end
sleep
0
end
private def build_persistence(url : String) : CRE::Persistence::Persistence
if url.starts_with?("sqlite:")
CRE::Persistence::Sqlite::SqlitePersistence.new(url.lchop("sqlite:"))
elsif url.starts_with?("postgres://") || url.starts_with?("postgresql://")
CRE::Persistence::Postgres::PostgresPersistence.new(url)
else
raise "unknown database URL: #{url}"
end
end
private def redact(url : String) : String
url.gsub(/:(\/{2,})([^:@]+):([^@]+)@/) { |_| ":#{$1}#{$2}:****@" }
end
end
end

View File

@ -0,0 +1,14 @@
# ===================
# ©AngelaMos | 2026
# version.cr
# ===================
require "../../version"
module CRE::Cli::Commands
module Version
def self.print(io : IO) : Nil
io.puts "cre v#{CRE::VERSION}"
end
end
end

View File

@ -0,0 +1,59 @@
# ===================
# ©AngelaMos | 2026
# watch.cr
# ===================
require "../../engine/engine"
require "../../engine/scheduler"
require "../../persistence/sqlite/sqlite_persistence"
require "../../persistence/postgres/postgres_persistence"
require "../../policy/evaluator"
require "../../tui/tui"
module CRE::Cli::Commands
class Watch
def execute(argv : Array(String), io : IO) : Int32
_help_requested = false
db_url = ENV["DATABASE_URL"]? || "sqlite:cre.db"
hmac_hex = ENV["CRE_HMAC_KEY_HEX"]? || "0" * 64
OptionParser.parse(argv) do |parser|
parser.banner = "Usage: cre watch [options]"
parser.on("--db=URL", "") { |u| db_url = u }
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
end
return 0 if _help_requested
persist = if db_url.starts_with?("sqlite:")
CRE::Persistence::Sqlite::SqlitePersistence.new(db_url.lchop("sqlite:"))
elsif db_url.starts_with?("postgres://") || db_url.starts_with?("postgresql://")
CRE::Persistence::Postgres::PostgresPersistence.new(db_url)
else
raise "unknown database URL"
end
persist.migrate!
engine = CRE::Engine::Engine.new(persist, hmac_hex.hexbytes)
evaluator = CRE::Policy::Evaluator.new(engine.bus, persist)
scheduler = CRE::Engine::Scheduler.new(engine.bus, 60.seconds)
tui = CRE::Tui::Tui.new(engine.bus)
engine.start
evaluator.start
scheduler.start
tui.start
Signal::INT.trap do
tui.stop
scheduler.stop
evaluator.stop
engine.stop
persist.close
exit 0
end
sleep
0
end
end
end

View File

@ -0,0 +1,41 @@
# ===================
# ©AngelaMos | 2026
# output.cr
# ===================
require "json"
module CRE::Cli
enum OutputFormat
Human
Json
Ndjson
end
module Output
def self.parse_format(s : String) : OutputFormat
case s.downcase
when "human" then OutputFormat::Human
when "json" then OutputFormat::Json
when "ndjson" then OutputFormat::Ndjson
else
raise "unknown output format: #{s} (valid: human, json, ndjson)"
end
end
def self.print(io : IO, format : OutputFormat, data) : Nil
case format
in OutputFormat::Human
io.puts data.to_s
in OutputFormat::Json
io.puts data.to_json
in OutputFormat::Ndjson
if data.is_a?(Array)
data.each { |row| io.puts row.to_json }
else
io.puts data.to_json
end
end
end
end
end

View File

@ -0,0 +1,19 @@
# ===================
# ©AngelaMos | 2026
# bundle.cr
# ===================
require "../persistence/persistence"
module CRE::Compliance
# Bundle is implemented in Phase 14. This stub keeps the CLI export
# subcommand wired and compilable until then.
class Bundle
def initialize(@persistence : Persistence::Persistence, @framework : String)
end
def write(path : String) : Nil
raise NotImplementedError.new("Compliance::Bundle.write will be wired up in Phase 14")
end
end
end

View File

@ -0,0 +1,16 @@
# ===================
# ©AngelaMos | 2026
# tier_1.cr
# ===================
module CRE::Demo
# Tier 1 demo is implemented in Phase 15. This stub keeps `cre demo`
# wired and compilable until then.
module Tier1
def self.run(io : IO) : Int32
io.puts "Tier 1 demo not yet implemented (Phase 15 of build)."
io.puts "Try: cre run --db=sqlite:cre.db"
0
end
end
end

View File

@ -56,8 +56,8 @@ module CRE::Notifiers
when Events::AlertRaised
case ev.severity
in Events::Severity::Critical then Log.error &.emit("alert", text: ev.message)
in Events::Severity::Warn then Log.warn &.emit("alert", text: ev.message)
in Events::Severity::Info then Log.info &.emit("alert", text: ev.message)
in Events::Severity::Warn then Log.warn &.emit("alert", text: ev.message)
in Events::Severity::Info then Log.info &.emit("alert", text: ev.message)
end
end
end

View File

@ -12,7 +12,7 @@ module CRE::Notifiers
# directly with HTTP::Client; no tourmaline dependency for the notification
# path keeps the footprint small.
class Telegram
Log = ::Log.for("cre.telegram")
Log = ::Log.for("cre.telegram")
DEFAULT_API = "https://api.telegram.org"
class TelegramError < Exception

View File

@ -63,13 +63,13 @@ module CRE::Notifiers
return "unauthorized" unless authorized_viewer?(chat_id)
cmd, _, rest = text.strip.lstrip('/').partition(' ')
case cmd
when "status" then status_message
when "queue" then queue_message
when "alerts" then alerts_message
when "status" then status_message
when "queue" then queue_message
when "alerts" then alerts_message
when "help", "" then help_message
when "rotate" then handle_rotate(chat_id, rest)
when "snooze" then handle_snooze(chat_id, rest)
when "history" then history_message(rest)
when "rotate" then handle_rotate(chat_id, rest)
when "snooze" then handle_snooze(chat_id, rest)
when "history" then history_message(rest)
else
"unknown command: /#{cmd} (try /help)"
end

View File

@ -43,10 +43,10 @@ module CRE::Rotators
Domain::NewSecret.new(
ciphertext: payload.to_slice,
metadata: {
"lease_id" => ds.lease_id,
"lease_duration" => ds.lease_duration.to_s,
"old_lease_id" => c.tag("current_lease_id") || "",
"username" => ds.username,
"lease_id" => ds.lease_id,
"lease_duration" => ds.lease_duration.to_s,
"old_lease_id" => c.tag("current_lease_id") || "",
"username" => ds.username,
},
)
end

View File

@ -17,8 +17,8 @@ module CRE::Tui
HOME = "#{ESC}H"
RESET = "#{ESC}0m"
BOLD = "#{ESC}1m"
DIM = "#{ESC}2m"
BOLD = "#{ESC}1m"
DIM = "#{ESC}2m"
FG_RED = "#{ESC}31m"
FG_GREEN = "#{ESC}32m"
@ -36,13 +36,33 @@ module CRE::Tui
"#{color}#{text}#{RESET}"
end
def self.green(text : String) : String ; colorize(text, FG_GREEN) ; end
def self.red(text : String) : String ; colorize(text, FG_RED) ; end
def self.yellow(text : String) : String ; colorize(text, FG_YELLOW) ; end
def self.cyan(text : String) : String ; colorize(text, FG_CYAN) ; end
def self.gray(text : String) : String ; colorize(text, FG_GRAY) ; end
def self.bold(text : String) : String ; colorize(text, BOLD) ; end
def self.dim(text : String) : String ; colorize(text, DIM) ; end
def self.green(text : String) : String
colorize(text, FG_GREEN)
end
def self.red(text : String) : String
colorize(text, FG_RED)
end
def self.yellow(text : String) : String
colorize(text, FG_YELLOW)
end
def self.cyan(text : String) : String
colorize(text, FG_CYAN)
end
def self.gray(text : String) : String
colorize(text, FG_GRAY)
end
def self.bold(text : String) : String
colorize(text, BOLD)
end
def self.dim(text : String) : String
colorize(text, DIM)
end
# Strip ANSI escape sequences (useful for testing rendered output).
def self.strip(text : String) : String