Current section
Files
Jump to
Current section
Files
lib/prompt_runner/cli.ex
defmodule PromptRunner.CLI do
@moduledoc """
Command-line entrypoint for the Prompt Runner packet workflow.
"""
alias PromptRunner
alias PromptRunner.Packet
alias PromptRunner.Packets
alias PromptRunner.Profile
alias PromptRunner.RecoveryConfig
alias PromptRunner.Runner
alias PromptRunner.Template
alias PromptRunner.UI
@spec main(list()) :: :ok | no_return()
def main(args \\ System.argv()) do
args
|> parse_command()
|> dispatch_command()
end
defp run_init(rest) do
{opts, _remaining, _invalid} =
OptionParser.parse(rest,
switches: [default_profile: :string],
aliases: [p: :default_profile]
)
{:ok, paths} = Profile.init(default_profile: opts[:default_profile])
IO.puts(UI.green("Prompt Runner initialized"))
IO.puts(" config: #{paths.config_file}")
IO.puts(" profile: #{paths.profile_file}")
IO.puts(" simulated profile: #{paths.simulated_profile_file}")
IO.puts(" templates: #{paths.templates_dir}")
IO.puts(" default template: #{paths.default_template_file}")
IO.puts(" from-adr template: #{paths.from_adr_template_file}")
:ok
end
defp run_profile_new(name, rest) do
{opts, _remaining, _invalid} =
OptionParser.parse(rest,
switches: [
provider: :string,
model: :string,
reasoning: :string,
permission: :string,
tools: :string,
prompt_template: :string,
cli_confirmation: :string,
resume_attempts: :integer,
retry_attempts: :integer,
retry_base_delay_ms: :integer,
retry_max_delay_ms: :integer,
retry_jitter: :boolean,
auto_repair: :boolean,
repair_attempts: :integer
]
)
attrs =
%{}
|> maybe_put("provider", opts[:provider])
|> maybe_put("model", opts[:model])
|> maybe_put("reasoning_effort", opts[:reasoning])
|> maybe_put("permission_mode", opts[:permission])
|> maybe_put("cli_confirmation", opts[:cli_confirmation])
|> maybe_put("prompt_template", opts[:prompt_template])
|> maybe_put("recovery", recovery_attrs(opts))
|> maybe_put("allowed_tools", parse_csv(opts[:tools]))
case Profile.create(name, attrs) do
{:ok, profile} ->
IO.puts(UI.green("Created profile #{profile.name}"))
IO.puts(" path: #{profile.path}")
:ok
{:error, reason} ->
handle_error(reason)
end
end
defp run_profile_list(_rest) do
{:ok, profiles} = Profile.list()
Enum.each(profiles, &IO.puts(&1))
:ok
end
defp run_template_list(rest) do
packet_dir =
case rest do
[candidate | _] ->
if File.dir?(candidate), do: candidate, else: nil
_ ->
nil
end
{:ok, templates} = Template.list(packet_dir)
Enum.each(templates, fn template ->
location =
case template.source do
:builtin -> "builtin"
:home -> template.path
:packet -> template.path
end
IO.puts("#{template.name}\t#{template.source}\t#{location}")
end)
:ok
end
defp run_packet_new(name, rest) do
{opts, _remaining, _invalid} =
OptionParser.parse(rest,
switches: [
root: :string,
profile: :string,
repo: :keep,
default_repo: :string,
provider: :string,
model: :string,
reasoning: :string,
permission: :string,
prompt_template: :string,
resume_attempts: :integer,
retry_attempts: :integer,
retry_base_delay_ms: :integer,
retry_max_delay_ms: :integer,
retry_jitter: :boolean,
auto_repair: :boolean,
repair_attempts: :integer,
cli_confirmation: :string
],
aliases: [p: :profile]
)
case parse_repo_specs(opts[:repo]) do
{:ok, repo_specs} ->
packet_opts =
[]
|> maybe_put(:root, opts[:root])
|> maybe_put(:profile, opts[:profile])
|> maybe_put(:repos, repo_specs)
|> maybe_put(:default_repo, opts[:default_repo])
|> maybe_put(:provider, opts[:provider])
|> maybe_put(:model, opts[:model])
|> maybe_put(:reasoning_effort, opts[:reasoning])
|> maybe_put(:permission_mode, opts[:permission])
|> maybe_put(:prompt_template, opts[:prompt_template])
|> maybe_put(:resume_attempts, opts[:resume_attempts])
|> maybe_put(:retry_attempts, opts[:retry_attempts])
|> maybe_put(:retry_base_delay_ms, opts[:retry_base_delay_ms])
|> maybe_put(:retry_max_delay_ms, opts[:retry_max_delay_ms])
|> maybe_put(:retry_jitter, opts[:retry_jitter])
|> maybe_put(:auto_repair, opts[:auto_repair])
|> maybe_put(:repair_attempts, opts[:repair_attempts])
|> maybe_put(:cli_confirmation, opts[:cli_confirmation])
case Packet.new(name, packet_opts) do
{:ok, packet} ->
IO.puts(UI.green("Created packet #{packet.name}"))
IO.puts(" root: #{packet.root}")
IO.puts(" manifest: #{packet.manifest_path}")
:ok
{:error, reason} ->
handle_error(reason)
end
{:error, reason} ->
handle_error(reason)
end
end
defp run_packet_doctor(rest) do
packet_dir = packet_dir(rest)
case Packet.doctor(packet_dir) do
{:ok, report} ->
IO.puts(Jason.encode!(report, pretty: true))
:ok
{:error, reason} ->
handle_error(reason)
end
end
defp run_packet_preflight(rest) do
packet_dir = packet_dir(rest)
case Packet.preflight(packet_dir) do
{:ok, report} ->
IO.puts(Jason.encode!(report, pretty: true))
:ok
{:error, {:preflight_failed, report}} ->
IO.puts(Jason.encode!(report, pretty: true))
System.halt(1)
{:error, reason} ->
handle_error(reason)
end
end
defp run_packet_explain(rest) do
packet_dir = packet_dir(rest)
case Packet.explain(packet_dir) do
{:ok, report} ->
IO.puts(Jason.encode!(report, pretty: true))
:ok
{:error, reason} ->
handle_error(reason)
end
end
defp run_repo_add(name, path, rest) do
{opts, remaining, _invalid} =
OptionParser.parse(rest,
switches: [packet: :string, default: :boolean]
)
packet_dir = packet_dir(remaining, opts[:packet])
case Packet.add_repo(packet_dir, name, path, default: opts[:default]) do
{:ok, packet} ->
IO.puts(UI.green("Updated packet #{packet.name}"))
:ok
{:error, reason} ->
handle_error(reason)
end
end
defp run_prompt_new(id, rest) do
{opts, remaining, _invalid} =
OptionParser.parse(rest,
switches: [
packet: :string,
phase: :integer,
name: :string,
targets: :string,
commit: :string,
template: :string
]
)
packet_dir = packet_dir(remaining, opts[:packet])
attrs =
%{}
|> maybe_put("id", id)
|> maybe_put("phase", opts[:phase])
|> maybe_put("name", opts[:name])
|> maybe_put("targets", parse_csv(opts[:targets]))
|> maybe_put("commit", opts[:commit])
|> maybe_put("template", opts[:template])
case Packets.create_prompt(packet_dir, attrs) do
{:ok, path} ->
IO.puts(UI.green("Created prompt #{id}"))
IO.puts(" path: #{path}")
:ok
{:error, reason} ->
handle_error(reason)
end
end
defp run_checklist_sync(rest) do
packet_dir = packet_dir(rest)
case Packets.sync_checklists(packet_dir) do
{:ok, %{paths: paths, warnings: warnings}} ->
IO.puts(UI.green("Synchronized checklists"))
Enum.each(paths, &IO.puts(" #{&1}"))
Enum.each(warnings, fn warning ->
IO.puts(
UI.yellow(
"WARNING: prompt #{warning.prompt_id} (#{warning.file}) has no verification items yet"
)
)
end)
:ok
{:error, reason} ->
handle_error(reason)
end
end
defp run_list(rest) do
packet_dir = packet_dir(rest)
case PromptRunner.plan(packet_dir, interface: :cli) do
{:ok, plan} ->
Runner.list_plan(plan)
:ok
{:error, reason} ->
handle_error(reason)
end
end
defp run_plan(rest) do
packet_dir = packet_dir(rest)
case PromptRunner.plan(packet_dir, interface: :cli) do
{:ok, plan} ->
print_plan_summary(plan)
:ok
{:error, reason} ->
handle_error(reason)
end
end
defp run_run(rest) do
{opts, remaining, _invalid} =
OptionParser.parse(rest,
switches: [
all: :boolean,
phase: :integer,
no_commit: :boolean,
provider: :string,
model: :string,
log_mode: :string,
log_meta: :string,
events_mode: :string,
tool_output: :string,
cli_confirmation: :string,
runtime_store: :string,
committer: :string,
skip_preflight: :boolean
]
)
{packet_dir, prompt_ids} = packet_and_prompt_ids(remaining)
case PromptRunner.plan(packet_dir, cli_opts(opts)) do
{:ok, plan} ->
cli_run_opts =
opts
|> cli_opts()
|> Keyword.put(:run, true)
|> maybe_put(:all, opts[:all] || prompt_ids == [])
|> maybe_put(:phase, opts[:phase])
|> maybe_put(:no_commit, opts[:no_commit])
case Runner.execute_plan(plan, cli_run_opts, prompt_ids) do
:ok -> :ok
{:error, reason} -> handle_error(reason)
end
{:error, reason} ->
handle_error(reason)
end
end
defp run_repair(rest) do
{opts, remaining, _invalid} =
OptionParser.parse(rest,
switches: [packet: :string, no_commit: :boolean]
)
case remaining do
[prompt_id] ->
packet_dir = packet_dir([], opts[:packet])
case PromptRunner.repair(packet_dir,
prompt: prompt_id,
interface: :cli,
no_commit: opts[:no_commit]
) do
{:ok, _run} -> :ok
{:error, reason} -> handle_error(reason)
end
_ ->
handle_error(:missing_prompt_id)
end
end
defp run_status(rest) do
packet_dir = packet_dir(rest)
{:ok, status} = PromptRunner.status(packet_dir)
IO.puts(Jason.encode!(status, pretty: true))
:ok
end
defp packet_dir([], explicit), do: explicit || File.cwd!()
defp packet_dir([candidate | _rest], nil), do: candidate
defp packet_dir(_remaining, explicit), do: explicit
defp packet_dir(remaining), do: packet_dir(remaining, nil)
defp packet_and_prompt_ids([]), do: {File.cwd!(), []}
defp packet_and_prompt_ids([first | rest]) do
if String.starts_with?(first, "-") do
{File.cwd!(), []}
else
prompt_ids = Enum.reject(rest, &String.starts_with?(&1, "-"))
if File.dir?(first) do
{first, normalize_prompt_ids(prompt_ids)}
else
{File.cwd!(), normalize_prompt_ids([first | prompt_ids])}
end
end
end
defp parse_command(["init" | rest]), do: {:init, rest}
defp parse_command(["profile", "new", name | rest]), do: {:profile_new, name, rest}
defp parse_command(["profile", "list" | rest]), do: {:profile_list, rest}
defp parse_command(["template", "list" | rest]), do: {:template_list, rest}
defp parse_command(["packet", "new", name | rest]), do: {:packet_new, name, rest}
defp parse_command(["packet", "doctor" | rest]), do: {:packet_doctor, rest}
defp parse_command(["packet", "preflight" | rest]), do: {:packet_preflight, rest}
defp parse_command(["packet", "explain" | rest]), do: {:packet_explain, rest}
defp parse_command(["repo", "add", name, path | rest]), do: {:repo_add, name, path, rest}
defp parse_command(["prompt", "new", id | rest]), do: {:prompt_new, id, rest}
defp parse_command(["checklist", "sync" | rest]), do: {:checklist_sync, rest}
defp parse_command(["list" | rest]), do: {:list, rest}
defp parse_command(["plan" | rest]), do: {:plan, rest}
defp parse_command(["run" | rest]), do: {:run, rest}
defp parse_command(["repair" | rest]), do: {:repair, rest}
defp parse_command(["status" | rest]), do: {:status, rest}
defp parse_command(["help" | _rest]), do: :help
defp parse_command(["--help" | _rest]), do: :help
defp parse_command(["-h" | _rest]), do: :help
defp parse_command([]), do: :help
defp parse_command(_args), do: :unknown
defp dispatch_command({:init, rest}), do: run_init(rest)
defp dispatch_command({:profile_new, name, rest}), do: run_profile_new(name, rest)
defp dispatch_command({:profile_list, rest}), do: run_profile_list(rest)
defp dispatch_command({:template_list, rest}), do: run_template_list(rest)
defp dispatch_command({:packet_new, name, rest}), do: run_packet_new(name, rest)
defp dispatch_command({:packet_doctor, rest}), do: run_packet_doctor(rest)
defp dispatch_command({:packet_preflight, rest}), do: run_packet_preflight(rest)
defp dispatch_command({:packet_explain, rest}), do: run_packet_explain(rest)
defp dispatch_command({:repo_add, name, path, rest}), do: run_repo_add(name, path, rest)
defp dispatch_command({:prompt_new, id, rest}), do: run_prompt_new(id, rest)
defp dispatch_command({:checklist_sync, rest}), do: run_checklist_sync(rest)
defp dispatch_command({:list, rest}), do: run_list(rest)
defp dispatch_command({:plan, rest}), do: run_plan(rest)
defp dispatch_command({:run, rest}), do: run_run(rest)
defp dispatch_command({:repair, rest}), do: run_repair(rest)
defp dispatch_command({:status, rest}), do: run_status(rest)
defp dispatch_command(:help), do: show_help()
defp dispatch_command(:unknown), do: handle_error(:unknown_command)
defp cli_opts(opts) do
[]
|> Keyword.put(:interface, :cli)
|> maybe_put(:provider, opts[:provider])
|> maybe_put(:model, opts[:model])
|> maybe_put(:log_mode, opts[:log_mode])
|> maybe_put(:log_meta, opts[:log_meta])
|> maybe_put(:events_mode, opts[:events_mode])
|> maybe_put(:tool_output, opts[:tool_output])
|> maybe_put(:cli_confirmation, opts[:cli_confirmation])
|> maybe_put(:runtime_store, opts[:runtime_store])
|> maybe_put(:committer, opts[:committer])
|> maybe_put(:skip_preflight, opts[:skip_preflight])
end
defp parse_csv(nil), do: nil
defp parse_csv(value) when is_binary(value) do
value
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp parse_repo_specs(nil), do: {:ok, nil}
defp parse_repo_specs(values) do
values
|> List.wrap()
|> Enum.reduce_while({:ok, []}, fn value, {:ok, acc} ->
case String.split(value, "=", parts: 2) do
[name, path] when name != "" and path != "" ->
{:cont, {:ok, acc ++ [{String.trim(name), String.trim(path)}]}}
_ ->
{:halt, {:error, {:invalid_repo_option, value}}}
end
end)
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value) when is_map(map), do: Map.put(map, key, value)
defp maybe_put(opts, key, value) when is_list(opts), do: Keyword.put(opts, key, value)
defp recovery_attrs(opts) do
RecoveryConfig.default()
|> put_path(["resume_attempts"], opts[:resume_attempts])
|> put_path(["retry", "max_attempts"], opts[:retry_attempts])
|> put_path(["retry", "base_delay_ms"], opts[:retry_base_delay_ms])
|> put_path(["retry", "max_delay_ms"], opts[:retry_max_delay_ms])
|> put_path(["retry", "jitter"], opts[:retry_jitter])
|> put_path(["repair", "enabled"], opts[:auto_repair])
|> put_path(["repair", "max_attempts"], opts[:repair_attempts])
|> then(&RecoveryConfig.normalize(%{"recovery" => &1}))
end
defp put_path(map, _path, nil), do: map
defp put_path(map, [key], value), do: Map.put(map, key, value)
defp put_path(map, [key | rest], value) do
nested =
map
|> Map.get(key, %{})
|> put_path(rest, value)
Map.put(map, key, nested)
end
defp normalize_prompt_ids(ids) do
Enum.map(ids, fn id ->
id
|> String.trim()
|> String.pad_leading(2, "0")
end)
end
defp print_plan_summary(plan) do
IO.puts("")
IO.puts(UI.bold("PromptRunner Plan"))
IO.puts("Packet: #{plan.source_root || inspect(plan.source)}")
IO.puts("Prompts: #{length(plan.prompts)}")
IO.puts("Provider: #{plan.config.llm_sdk}")
IO.puts("Model: #{plan.config.model}")
Enum.each(plan.prompts, fn prompt ->
IO.puts(" #{prompt.num} - #{prompt.name}")
end)
IO.puts("")
end
@spec handle_error(term()) :: no_return()
defp handle_error(:missing_prompt_id) do
IO.puts(UI.red("ERROR: prompt id is required"))
System.halt(1)
end
defp handle_error(:unknown_command) do
IO.puts(UI.red("ERROR: unknown command"))
show_help()
System.halt(1)
end
defp handle_error(reason) do
IO.puts(UI.red("ERROR: #{inspect(reason)}"))
System.halt(1)
end
defp show_help do
IO.puts("""
Prompt Runner 0.7.0
Setup:
prompt_runner init
prompt_runner profile new NAME [--provider codex --model gpt-5.4 --reasoning xhigh] [--prompt-template from-adr]
prompt_runner profile list
prompt_runner template list [PACKET_DIR]
Packet authoring:
prompt_runner packet new NAME [--root DIR] [--profile NAME] [--repo NAME=PATH] [--default-repo NAME]
prompt_runner packet new NAME [--prompt-template TEMPLATE] [--provider PROVIDER] [--model MODEL]
prompt_runner packet doctor [PACKET_DIR]
prompt_runner packet preflight [PACKET_DIR]
prompt_runner packet explain [PACKET_DIR]
prompt_runner repo add NAME PATH [--packet PACKET_DIR] [--default]
prompt_runner prompt new ID [--packet PACKET_DIR] --phase N --name "..." [--template TEMPLATE]
prompt_runner checklist sync [PACKET_DIR]
Execution:
prompt_runner list [PACKET_DIR]
prompt_runner plan [PACKET_DIR]
prompt_runner run [PACKET_DIR] [PROMPT_ID...] [--skip-preflight]
prompt_runner repair [--packet PACKET_DIR] PROMPT_ID
prompt_runner status [PACKET_DIR]
""")
end
end