Skip to content
Merged
2 changes: 1 addition & 1 deletion bin/dev
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ else
end

begin
Dev::Runner.new.run(ARGV, out: $stdout, ui: ui)
Dev::Runner.new(ui: ui).run(ARGV)
rescue Dev::DevYamlNotFoundError
warn "dev: no dev.yml found in this directory or any parent."
warn "Run dev from inside a project that defines a dev.yml."
Expand Down
2 changes: 1 addition & 1 deletion src/dev.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
require "sorbet-runtime"

# Dev CLI: find repo with dev.yml, run declared commands (optionally in a CLI::UI Frame).
# Entry point: Dev::Runner.new.run(ARGV)
# Entry point: Dev::Runner.new(ui:).run(ARGV)
module Dev
DEV_YAML_FILENAME = "dev.yml"

Expand Down
27 changes: 27 additions & 0 deletions src/dev/builtin_executor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# typed: strict
# frozen_string_literal: true

require_relative "command"
require_relative "execution_context"

module Dev
# In-process execution of a builtin's Ruby body. The delegation is
# deliberately thin: this class exists so CommandExecutor's three sealed
# arms dispatch to uniformly injectable strategies (the builtin arm is
# mocked in tests exactly like the process-boundary arms), not because
# builtin execution needs any mediation.
class BuiltinExecutor
extend T::Sig

# Run the builtin's body in the current process.
#
# @param command [BuiltinCommand]
# @param args [Array<String>] argv after the command name
# @param context [ExecutionContext]
# @return [void]
sig { params(command: BuiltinCommand, args: T::Array[String], context: ExecutionContext).void }
def execute(command, args:, context:)
command.call(args:, context:)
end
end
end
1 change: 1 addition & 0 deletions src/dev/builtins.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ module Builtins; end
require_relative "builtins/clone_command"
require_relative "builtins/cred_command"
require_relative "builtins/deps_command"
require_relative "builtins/help_command"
require_relative "builtins/install_deps_command"
require_relative "builtins/learnings_command"
require_relative "builtins/plan_command"
Expand Down
3 changes: 3 additions & 0 deletions src/dev/builtins/cache_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ def initialize(
sig { override.returns(String) }
def desc = "Manage host caches (e.g. cache gc --keep 2)"

sig { override.returns(Command::Category) }
def category = Command::Category::Workflow

sig { override.params(args: T::Array[String], context: ExecutionContext).void }
def call(args:, context:)
subcommand, *rest = args
Expand Down
3 changes: 3 additions & 0 deletions src/dev/builtins/cd_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ def initialize(accessor: Dev::Cd::Accessor.new)
sig { override.returns(String) }
def desc = "Jump to a checkout under $DEV_CD_ROOT (default ~/src) by fuzzy name"

sig { override.returns(Command::Category) }
def category = Command::Category::Workflow

sig { override.params(args: T::Array[String], context: ExecutionContext).void }
def call(args:, context:)
@accessor.run(args)
Expand Down
3 changes: 3 additions & 0 deletions src/dev/builtins/check_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ def initialize(dependency_service:)
sig { override.returns(String) }
def desc = "Check dependency state freshness (manifest vs lockfiles vs installed)"

sig { override.returns(Command::Category) }
def category = Command::Category::Lifecycle

# check IS the explicit staleness inspection — guarding before it
# would report the same thing twice.
sig { override.returns(T::Boolean) }
Expand Down
3 changes: 3 additions & 0 deletions src/dev/builtins/clone_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ def initialize(accessor: Dev::Clone::Accessor.new)
sig { override.returns(String) }
def desc = "Clone a GitHub repo (via gh auth) into $DEV_CD_ROOT (default ~/src), org defaults to d3mlabs"

sig { override.returns(Command::Category) }
def category = Command::Category::Workflow

sig { override.params(args: T::Array[String], context: ExecutionContext).void }
def call(args:, context:)
@accessor.run(args)
Expand Down
3 changes: 3 additions & 0 deletions src/dev/builtins/cred_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ def initialize(accessor: Dev::CredentialAccessor.new)
sig { override.returns(String) }
def desc = "Resolve a stored credential (e.g. cred get <namespace> <key>)"

sig { override.returns(Command::Category) }
def category = Command::Category::Workflow

sig { override.params(args: T::Array[String], context: ExecutionContext).void }
def call(args:, context:)
@accessor.run(args)
Expand Down
3 changes: 3 additions & 0 deletions src/dev/builtins/deps_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ def initialize(
sig { override.returns(String) }
def desc = "Inspect locked dependencies (e.g. deps path ficsit <mod> <platform>)"

sig { override.returns(Command::Category) }
def category = Command::Category::Lifecycle

sig { override.params(args: T::Array[String], context: ExecutionContext).void }
def call(args:, context:)
@accessor_factory.call(context.project_root).run(args)
Expand Down
53 changes: 53 additions & 0 deletions src/dev/builtins/help_command.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# typed: strict
# frozen_string_literal: true

require "stringio"

require "dev/cli/usage_printer"
require "dev/command"

module Dev
module Builtins
# `dev help` (also routed from bare `dev`, `--help`, and `-h`): render
# the grouped usage listing. Help lists the very catalog that contains
# it, so the listing arrives as a provider resolved at call time — the
# composition root closes the self-reference, not this class.
class HelpCommand < BuiltinCommand
extend T::Sig

CommandsProvider = T.type_alias { T.proc.returns(T::Hash[String, Command]) }

sig do
params(
project_name: String,
usage_printer: Cli::UsagePrinter,
out: T.any(IO, StringIO),
commands_provider: CommandsProvider,
).void
end
def initialize(project_name:, usage_printer:, out:, commands_provider:)
super()
@project_name = T.let(project_name, String)
@usage_printer = T.let(usage_printer, Cli::UsagePrinter)
@out = T.let(out, T.any(IO, StringIO))
@commands_provider = T.let(commands_provider, CommandsProvider)
end

sig { override.returns(String) }
def desc = "Show this usage"

sig { override.returns(Command::Category) }
def category = Command::Category::Workflow

# Help must work while the dependency state is stale — it is how the
# remediation commands get discovered in the first place.
sig { override.returns(T::Boolean) }
def staleness_exempt? = true

sig { override.params(args: T::Array[String], context: ExecutionContext).void }
def call(args:, context:)
@usage_printer.print(project_name: @project_name, commands: @commands_provider.call, out: @out)
end
end
end
end
3 changes: 3 additions & 0 deletions src/dev/builtins/install_deps_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ def initialize(
sig { override.returns(String) }
def desc = "Install locked dependencies handled on the host (e.g. gh releases)"

sig { override.returns(Command::Category) }
def category = Command::Category::Lifecycle

# install-deps IS the remediation for a stale install — never nag
# before it.
sig { override.returns(T::Boolean) }
Expand Down
3 changes: 3 additions & 0 deletions src/dev/builtins/learnings_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ def desc
"init: scaffold the index)"
end

sig { override.returns(Command::Category) }
def category = Command::Category::Workflow

sig { override.params(args: T::Array[String], context: ExecutionContext).void }
def call(args:, context:)
@accessor_factory.call(context.project_root).run(args)
Expand Down
3 changes: 3 additions & 0 deletions src/dev/builtins/plan_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ def initialize(accessor_factory: ->(project_root) { Dev::Plan::Accessor.new(proj
sig { override.returns(String) }
def desc = "Sync Cursor plans with GitHub issues (new/link/pull/push/status)"

sig { override.returns(Command::Category) }
def category = Command::Category::Workflow

# plan never touches dependencies and runs headlessly from Cursor
# hooks, where a staleness warning would only add noise.
sig { override.returns(T::Boolean) }
Expand Down
3 changes: 3 additions & 0 deletions src/dev/builtins/provide_image_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class ProvideImageCommand < BuiltinCommand
sig { override.returns(String) }
def desc = "Resolve the build container image (local/pull/build) and print its tag"

sig { override.returns(Command::Category) }
def category = Command::Category::Lifecycle

# Hidden: workflow plumbing, not a developer intent command.
sig { override.returns(T::Boolean) }
def hidden? = true
Expand Down
3 changes: 3 additions & 0 deletions src/dev/builtins/reset_container_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class ResetContainerCommand < BuiltinCommand
sig { override.returns(String) }
def desc = "Remove the persistent build container (clears its incremental cache)"

sig { override.returns(Command::Category) }
def category = Command::Category::Lifecycle

sig { override.params(args: T::Array[String], context: ExecutionContext).void }
def call(args:, context:)
cfg = T.must(context.build_container)
Expand Down
3 changes: 3 additions & 0 deletions src/dev/builtins/runner_setup_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ def desc
"Register this host as a self-hosted GitHub Actions runner (repo-scoped, or org-wide with --org)"
end

sig { override.returns(Command::Category) }
def category = Command::Category::Lifecycle

sig { override.params(args: T::Array[String], context: ExecutionContext).void }
def call(args:, context:)
cfg = context.runner
Expand Down
3 changes: 3 additions & 0 deletions src/dev/builtins/up_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ def initialize(install_deps_command:, hook_installer: Dev::Cd::HookInstaller.new
sig { override.returns(String) }
def desc = "Install locked dependencies, then run the project's up command (if defined)"

sig { override.returns(Command::Category) }
def category = Command::Category::Lifecycle

# up IS the staleness remediation — never nag before it.
sig { override.returns(T::Boolean) }
def staleness_exempt? = true
Expand Down
3 changes: 3 additions & 0 deletions src/dev/builtins/update_deps_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ class UpdateDepsCommand < BuiltinCommand
sig { override.returns(String) }
def desc = "Resolve dependency constraints and write lockfiles"

sig { override.returns(Command::Category) }
def category = Command::Category::Lifecycle

# update-deps IS the remediation for a stale manifest — nagging before
# it would block the very fix being run.
sig { override.returns(T::Boolean) }
Expand Down
63 changes: 48 additions & 15 deletions src/dev/cli/usage_printer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,71 @@

module Dev
module Cli
# The `dev` / `dev --help` usage view. Consumes the visible commands the
# CommandService serves (never the repository) and renders the flat
# listing; grouped sections (a category on the builtin base) would slot
# in here when the usage-groups work lands.
# The usage view the help builtin renders. Consumes the visible commands
# the CommandService serves (never the repository) and renders them as
# sections keyed by each command's Category trait: the project's own
# commands first, then the Lifecycle and Development flow builtins.
# Alphabetical within a section, so the listing is deterministic
# regardless of registration order.
class UsagePrinter
extend T::Sig

sig { params(argv: T::Array[String]).returns(T::Boolean) }
def show_usage?(argv)
argv.empty? || argv == ["--help"] || argv == ["-h"]
end

# @param project_name [String] the dev.yml `name:`
# @param commands [Hash{String => Dev::Command}] visible commands, in
# listing order
# @param commands [Hash{String => Dev::Command}] visible commands
# @param out [IO, StringIO]
# @return [void]
sig { params(project_name: String, commands: T::Hash[String, Command], out: T.any(IO, StringIO)).void }
def print(project_name:, commands:, out:)
sections = commands.group_by { |_name, command| command.category }

out.puts "Usage: dev <command> [args...]"
out.puts ""
out.puts "Commands for #{project_name}:"
if commands.empty?
project_commands = sections.fetch(Command::Category::Project, [])
if project_commands.empty?
out.puts " (no commands defined)"
else
commands.each do |cmd_name, command|
out.puts " #{cmd_name.ljust(12)} #{command.desc}"
end
print_commands(project_commands, out)
end
print_section("Lifecycle", sections.fetch(Command::Category::Lifecycle, []), out)
print_section("Development flow", sections.fetch(Command::Category::Workflow, []), out)
out.puts ""
out.puts "Examples: dev up dev up -v dev update-deps dev test"
end

private

# Render one builtin section; sections with no commands are omitted
# entirely (some builtins are config-gated, e.g. reset-container).
#
# @param heading [String]
# @param commands [Array<[String, Dev::Command]>]
# @param out [IO, StringIO]
# @return [void]
sig do
params(
heading: String,
commands: T::Array[[String, Command]],
out: T.any(IO, StringIO),
).void
end
def print_section(heading, commands, out)
return if commands.empty?

out.puts ""
out.puts "#{heading}:"
print_commands(commands, out)
end

# @param commands [Array<[String, Dev::Command]>]
# @param out [IO, StringIO]
# @return [void]
sig { params(commands: T::Array[[String, Command]], out: T.any(IO, StringIO)).void }
def print_commands(commands, out)
commands.sort_by { |name, _command| name }.each do |name, command|
out.puts " #{name.ljust(12)} #{command.desc}"
end
end
end
end
end
26 changes: 26 additions & 0 deletions src/dev/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,27 @@ module Command
abstract!
sealed!

# The usage sections `dev --help` renders. Every command declares its
# group explicitly (the trait is abstract, not defaulted) so nothing
# lands in a section silently.
class Category < T::Enum
enums do
# Environment provisioning and dependency state (up, check, ...).
Lifecycle = new
# Day-to-day development tooling (cd, plan, help, ...).
Workflow = new
# Commands the project defines in dev.yml.
Project = new
end
end

sig { abstract.returns(String) }
def desc; end

# The usage section this command lists under.
sig { abstract.returns(Category) }
def category; end

# Whether this command is callable but omitted from `dev`/`dev --help`
# usage. Used for internal plumbing (e.g. build primitives) a project
# keeps invocable without advertising it. Visible by default.
Expand Down Expand Up @@ -117,6 +135,9 @@ def initialize(run:, desc: "(no description)", repl: false, container: true, hid
sig(:final) { override.returns(T::Boolean) }
def hidden? = @hidden

sig(:final) { override.returns(Category) }
def category = Category::Project

sig(:final) { params(other: Object).returns(T::Boolean) }
def ==(other)
return false unless other.is_a?(ProjectCommand)
Expand Down Expand Up @@ -175,5 +196,10 @@ def staleness_exempt? = @builtin.staleness_exempt?

sig(:final) { override.returns(T::Boolean) }
def stamps? = @builtin.stamps?

# The usage section belongs to the slot too: an overriding `up:` still
# lists under Lifecycle, with the project's description.
sig(:final) { override.returns(Category) }
def category = @builtin.category
end
end
Loading
Loading