use std::collections::BTreeSet;
use std::path::Path;
use std::path::PathBuf;
use clap::Arg;
use clap::ArgAction;
use clap::ColorChoice;
use clap::Command;
use monochange_config::load_cli_commands;
use monochange_core::CliCommandDefinition;
use monochange_core::CliInputDefinition;
use monochange_core::CliInputKind;
use monochange_core::CliStepDefinition;
use monochange_core::default_cli_commands;
pub fn build_command(bin_name: &'static str) -> Command {
let root = current_dir_or_dot();
build_command_for_root(bin_name, &root)
}
pub(crate) fn configured_change_type_choices(
configuration: &monochange_core::WorkspaceConfiguration,
) -> Vec<String> {
configuration
.changelog
.types
.keys()
.cloned()
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
pub(crate) fn apply_runtime_change_type_choices(
cli: &mut [CliCommandDefinition],
configuration: &monochange_core::WorkspaceConfiguration,
) {
let choices = configured_change_type_choices(configuration);
if choices.is_empty() {
return;
}
let Some(change_command) = cli.iter_mut().find(|command| command.name == "change") else {
return;
};
let Some(change_type_input) = change_command
.inputs
.iter_mut()
.find(|input| input.name == "type" && input.choices.is_empty())
else {
return;
};
change_type_input.kind = CliInputKind::Choice;
change_type_input.choices = choices;
}
pub(crate) fn cli_commands_for_root(root: &Path) -> Vec<CliCommandDefinition> {
load_cli_commands(root).unwrap_or_else(|_| default_cli_commands())
}
pub(crate) fn cli_commands_from_config(
configuration: &Result<
monochange_core::WorkspaceConfiguration,
monochange_core::MonochangeError,
>,
) -> Vec<CliCommandDefinition> {
let Ok(configuration) = configuration else {
return default_cli_commands();
};
let mut cli = configuration.cli.clone();
apply_runtime_change_type_choices(&mut cli, configuration);
apply_runtime_prepare_release_markdown_defaults(&mut cli);
cli
}
pub(crate) fn apply_runtime_prepare_release_markdown_defaults(cli: &mut [CliCommandDefinition]) {
for cli_command in cli {
if !command_supports_release_diff_preview(cli_command) {
continue;
}
let Some(format_input) = cli_command
.inputs
.iter_mut()
.find(|input| input.name == "format")
else {
continue;
};
let has_markdown = format_input
.choices
.iter()
.any(|choice| choice == "markdown");
if !has_markdown {
format_input.choices.insert(0, "markdown".to_string());
}
let has_md = format_input.choices.iter().any(|choice| choice == "md");
if !has_md {
format_input.choices.push("md".to_string());
}
if format_input.default.as_deref() == Some("text") {
format_input.default = Some("markdown".to_string());
}
}
}
pub(crate) fn build_command_for_root(bin_name: &'static str, root: &Path) -> Command {
let cli = cli_commands_for_root(root);
build_command_with_cli(bin_name, &cli)
}
fn monochange_styles() -> clap::builder::Styles {
clap::builder::Styles::styled()
.header(crate::cli_theme::header())
.usage(crate::cli_theme::usage())
.literal(crate::cli_theme::literal())
.placeholder(crate::cli_theme::placeholder())
.error(crate::cli_theme::error())
.valid(crate::cli_theme::valid())
.invalid(crate::cli_theme::error())
}
const GLOBAL_OPTIONS_HELP_HEADING: &str = "Global Options";
const RELEASE_OPTIONS_HELP_HEADING: &str = "Release Options";
#[allow(clippy::redundant_closure_for_method_calls)]
pub(crate) fn build_command_with_cli(
bin_name: &'static str,
cli: &[CliCommandDefinition],
) -> Command {
let mut command = Command::new(bin_name)
.version(env!("CARGO_PKG_VERSION"))
.about("Manage versions and releases for your multiplatform, multilanguage monorepo")
.styles(monochange_styles())
.color(ColorChoice::Auto)
.disable_help_subcommand(true)
.subcommand_required(true)
.arg_required_else_help(true)
.subcommand_help_heading("Built-in Commands")
.next_help_heading(GLOBAL_OPTIONS_HELP_HEADING)
.arg(
Arg::new("log-level")
.long("log-level")
.global(true)
.help_heading(GLOBAL_OPTIONS_HELP_HEADING)
.help("Set tracing filter (e.g. debug, monochange=trace)")
.value_name("FILTER")
.hide(true),
)
.arg(
Arg::new("quiet")
.long("quiet")
.short('q')
.global(true)
.help_heading(GLOBAL_OPTIONS_HELP_HEADING)
.help("Suppress stdout/stderr output and run in dry-run mode when supported")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("progress-format")
.long("progress-format")
.global(true)
.help_heading(GLOBAL_OPTIONS_HELP_HEADING)
.help("Control progress output on stderr")
.value_name("FORMAT")
.value_parser(["auto", "unicode", "ascii", "json"]),
)
.arg(
Arg::new("jq")
.long("jq")
.global(true)
.help_heading(GLOBAL_OPTIONS_HELP_HEADING)
.help("Filter JSON output with a jq-style expression, such as `.assets[].name`")
.value_name("EXPRESSION"),
)
.arg(
Arg::new("snapshot")
.long("snapshot")
.global(true)
.help_heading(GLOBAL_OPTIONS_HELP_HEADING)
.help("Print a normalized JSON snapshot for this command subtree")
.action(ArgAction::SetTrue),
)
.subcommand(
Command::new("snapshot")
.about("Print a normalized JSON snapshot of the CLI surface")
.arg(
Arg::new("view")
.long("view")
.value_name("VIEW")
.default_value("full")
.value_parser(["full", "light", "index"])
.help("Snapshot view to render"),
)
.arg(
Arg::new("command")
.value_name("COMMAND")
.num_args(0..)
.help("Optional command path to render"),
),
)
.subcommand(
Command::new("init")
.about(
"Generate monochange.toml with detected packages, groups, and default CLI commands",
)
.arg(
Arg::new("force")
.long("force")
.help("Overwrite an existing monochange.toml file")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("provider")
.long("provider")
.help("Source-control provider for release automation workflows")
.long_help(
"Configure release automation for the specified provider. \
When provided, the generated config includes:\n\
\n\
- [source] section with the provider configured\n\
- Release and pull request settings for the provider\n\
- A minimal starter config without generated [cli.*] command aliases\n\
- GitHub Actions workflows (for --provider=github)\n\
\nSupported providers: github, gitlab, gitea",
)
.value_parser(["github", "gitlab", "gitea"]),
),
)
.subcommand(Command::new("populate").about(
"Add any missing built-in CLI commands to monochange.toml so you can customize them",
))
.subcommand(build_command_wizard_subcommand())
.subcommand(build_skill_subcommand())
.subcommand(build_subagents_subcommand())
.subcommand(build_analyze_subcommand())
.subcommand(build_migrate_subcommand())
.subcommand(build_lint_subcommand())
.subcommand(build_versions_subcommand())
.subcommand({
#[cfg(feature = "mcp")]
{
Command::new("mcp").about(
"Start the monochange MCP (Model Context Protocol) server over stdin/stdout",
)
}
#[cfg(not(feature = "mcp"))]
{
Command::new("mcp").hide(true)
}
})
.subcommand(build_check_subcommand())
.subcommand(build_help_subcommand());
command = command
.next_help_heading("Built-in Command Groups")
.subcommand(build_step_subcommand())
.subcommand(build_run_subcommand(cli));
command
}
pub(crate) fn build_command_wizard_subcommand() -> Command {
Command::new("command")
.about("Open an interactive dashboard for adding or editing config-defined CLI commands")
.after_help(
"Examples:\n monochange command\n\nUse this wizard to create or revise [cli.<name>] entries in monochange.toml. It keeps existing command details unless you choose to replace them.",
)
}
pub(crate) fn build_skill_subcommand() -> Command {
Command::new("skill")
.about("Install the monochange skill bundle into the current project with the skills CLI")
.after_help(
r"Examples:
monochange help skill
monochange skill
monochange skill --list
monochange skill -a claude-code -a codex
monochange skill --skill monochange --copy -y
monochange skill -g -a pi -y
This command forwards all remaining arguments to:
skills add <monochange-source>
Common forwarded flags from the upstream `skills add` command include:
-g, --global install to the user-level agent directories
-a, --agent <AGENT> target specific agent harnesses
-s, --skill <SKILL> install specific skills from the source
-l, --list list the available skills without installing
--copy copy files instead of symlinking
-y, --yes skip confirmation prompts
--all install all skills to all supported agents
Runner selection is automatic. monochange prefers:
1. npx
2. pnpm dlx
3. bunx",
)
.arg(
Arg::new("args")
.help("Arguments forwarded to `skills add` after the monochange skill source")
.num_args(0..)
.action(ArgAction::Append)
.trailing_var_arg(true)
.allow_hyphen_values(true),
)
}
pub(crate) fn build_subagents_subcommand() -> Command {
Command::new("subagents")
.about("Generate repo-local monochange subagents and agent guidance files")
.after_help(
r"Examples:
monochange help subagents
monochange subagents claude
monochange subagents pi codex
monochange subagents --all --dry-run --format json
monochange subagents vscode copilot --no-mcp
Targets:
- claude -> .claude/agents/*.md and .mcp.json
- vscode -> .github/agents/*.agent.md and .vscode/mcp.json
- copilot -> .github/agents/*.agent.md and .vscode/mcp.json
- pi -> .pi/agents/*.md
- codex -> .codex/agents/*.toml
- cursor -> .cursor/rules/*.mdc
Generated agents are CLI-first. They should prefer:
1. monochange
2. monochange
3. npx -y @monochange/cli
Use `--no-mcp` to skip MCP config files for targets that support repo-local MCP config.",
)
.arg(
Arg::new("target")
.help("Subagent target(s) to generate")
.value_name("TARGET")
.num_args(1..)
.required_unless_present("all")
.value_parser(["claude", "vscode", "copilot", "pi", "codex", "cursor"]),
)
.arg(
Arg::new("all")
.long("all")
.help("Generate files for all supported targets")
.conflicts_with("target")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("force")
.long("force")
.help("Overwrite generated files that already exist with different contents")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("dry-run")
.long("dry-run")
.help("Preview the generated files without writing them")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("format")
.long("format")
.help("Output format for the generated subagent plan")
.default_value("markdown")
.value_parser(["text", "json", "markdown", "md"]),
)
.arg(
Arg::new("sha")
.long("sha")
.help("Output only the commit SHA of the discovered release record")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("no-mcp")
.long("no-mcp")
.help("Skip repo-local MCP config files for supported targets")
.action(ArgAction::SetTrue),
)
}
pub(crate) fn build_analyze_subcommand() -> Command {
Command::new("analyze")
.about("Analyze semantic changes for one package across main, head, and optional release baselines")
.after_help(
r"Examples:
monochange analyze --package core
monochange analyze --package core --format json
monochange analyze --package core --release-ref core/v1.2.3
monochange analyze --package core --main-ref main --head-ref HEAD
Analysis notes:
- Runs package-scoped semantic analysis using the selected package's configured release identity.
- Defaults `--release-ref` to the newest tag for the package or the version group that owns it.
- If no prior release tag exists, falls back to first-release analysis using only `main -> head`.",
)
.arg(
Arg::new("package")
.long("package")
.required(true)
.value_name("PACKAGE")
.help("Configured package id, discovered package id, package name, manifest path, or package directory"),
)
.arg(
Arg::new("release-ref")
.long("release-ref")
.value_name("REF")
.help("Explicit release baseline ref. Defaults to the latest tag for the package or owning version group"),
)
.arg(
Arg::new("main-ref")
.long("main-ref")
.value_name("REF")
.help("Base branch or ref to compare against. Defaults to the detected default branch"),
)
.arg(
Arg::new("head-ref")
.long("head-ref")
.value_name("REF")
.help("Head ref to analyze. Defaults to HEAD"),
)
.arg(
Arg::new("detection-level")
.long("detection-level")
.default_value("signature")
.value_parser(["basic", "signature", "semantic"])
.help("Level of semantic detail to request from analyzers"),
)
.arg(
Arg::new("format")
.long("format")
.default_value("markdown")
.value_parser(["text", "json", "markdown", "md"])
.help("Output format"),
)
}
pub(crate) fn build_migrate_subcommand() -> Command {
Command::new("migrate")
.about("Audit or migrate release metadata for monochange repositories")
.subcommand_required(true)
.arg_required_else_help(true)
.subcommand(
Command::new("audit")
.about("Report existing release tools, changelog providers, and CI migration work")
.after_help(
r"Examples:
monochange migrate audit
monochange migrate audit --format json
Audit notes:
- Looks for known release tools such as knope, Changesets, release-please, semantic-release, and cargo-release.
- Scans GitHub Actions workflows for legacy release actions and commands.
- Reports changelog providers and trusted-publishing migration recommendations.",
)
.arg(
Arg::new("format")
.long("format")
.help("Output format")
.default_value("text")
.value_parser(["text", "json", "markdown", "md"]),
),
)
.subcommand(
Command::new("release-records")
.about("Migrate committed .monochange release records to the latest schema version")
.after_help(
r"Examples:
monochange migrate release-records --dry-run
monochange migrate release-records
monochange migrate release-records --format json
Migration notes:
- Scans .monochange/releases/*/release.json.
- Rewrites only records whose schema_version is older than this monochange binary.
- Use --dry-run to preview which records would change.",
)
.arg(
Arg::new("dry-run")
.long("dry-run")
.help("Preview release-record migrations without writing files")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("format")
.long("format")
.help("Output format")
.default_value("text")
.value_parser(["text", "json", "markdown", "md"]),
),
)
}
pub(crate) fn build_check_subcommand() -> Command {
Command::new("check")
.about("Validate configuration, changesets, and run manifest lint rules")
.after_help(
"Examples:\n monochange check\n monochange check --fix\n monochange check --ecosystem cargo,npm\n monochange check --only cargo/sorted-dependencies\n\n\
Lint rules are configured in the top-level [lints] section of monochange.toml:\n\n\
[lints]\n use = [\"cargo/recommended\", \"npm/recommended\"]\n\n\
[lints.rules]\n \"cargo/internal-dependency-workspace\" = \"error\"",
)
.arg(
Arg::new("fix")
.long("fix")
.short('f')
.help("Automatically fix lint issues where possible")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("ecosystem")
.long("ecosystem")
.short('e')
.help("Limit linting to specific lint suites")
.value_name("ECOSYSTEMS")
.value_delimiter(','),
)
.arg(
Arg::new("only")
.long("only")
.help("Run only the specified lint rule ids")
.value_name("RULES")
.value_delimiter(','),
)
.arg(
Arg::new("format")
.long("format")
.help("Output format")
.default_value("markdown")
.value_parser(["text", "json", "markdown", "md"]),
)
.arg(
Arg::new("verbose")
.long("verbose")
.short('v')
.help("Show extra lint diagnostic details")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("sha")
.long("sha")
.help("Output only the commit SHA of the discovered release record")
.action(ArgAction::SetTrue),
)
}
pub(crate) fn build_lint_subcommand() -> Command {
Command::new("lint")
.about("Inspect and scaffold manifest lint rules")
.subcommand_required(true)
.arg_required_else_help(true)
.subcommand(
Command::new("list")
.about("List registered lint rules and presets")
.arg(
Arg::new("format")
.long("format")
.help("Output format")
.default_value("markdown")
.value_parser(["text", "json", "markdown", "md"]),
)
.arg(
Arg::new("sha")
.long("sha")
.help("Output only the commit SHA of the discovered release record")
.action(ArgAction::SetTrue),
),
)
.subcommand(
Command::new("explain")
.about("Explain a lint rule or preset")
.arg(
Arg::new("id")
.required(true)
.help("Lint rule id or preset id to explain"),
)
.arg(
Arg::new("format")
.long("format")
.help("Output format")
.default_value("markdown")
.value_parser(["text", "json", "markdown", "md"]),
)
.arg(
Arg::new("sha")
.long("sha")
.help("Output only the commit SHA of the discovered release record")
.action(ArgAction::SetTrue),
),
)
.subcommand(
Command::new("new")
.about("Scaffold a new lint rule in an ecosystem crate")
.after_help(
"Examples:\n monochange lint new cargo/no-path-dependencies\n monochange lint new npm/require-package-manager",
)
.arg(
Arg::new("id")
.required(true)
.help("New lint id in the form <ecosystem>/<rule-name>"),
),
)
}
pub(crate) fn build_help_subcommand() -> Command {
Command::new("help")
.about("Show detailed help for a command")
.long_about(
"Show detailed help, examples, and tips for any monochange command. \
Run `monochange help` to list all commands, or `monochange help <command>` for \
detailed usage information with examples.",
)
.arg(
Arg::new("command")
.help("Command name to get help for (e.g. change, release, init)")
.value_name("COMMAND"),
)
}
pub(crate) fn command_supports_release_diff_preview(cli_command: &CliCommandDefinition) -> bool {
cli_command
.steps
.iter()
.any(|step| matches!(step, CliStepDefinition::PrepareRelease { .. }))
}
fn step_command_summary(step: &CliStepDefinition) -> String {
match step.step_kebab_name().as_str() {
"affected-packages" => "Compute affected packages from a prepared release plan".to_string(),
"create-change-file" => "Create a changeset file for one or more packages".to_string(),
"prepare-release" => "Plan version bumps, changelogs, and release artifacts".to_string(),
"release-record" => {
"Inspect the release record associated with a tag or commit".to_string()
}
"publish-readiness" => {
"Check package registry publishing readiness without publishing packages".to_string()
}
"tag-release" => "Create and push release tags from a release record".to_string(),
"publish-release" => {
"Publish provider release objects from a prepared release artifact".to_string()
}
kebab => format!("Run the built-in {kebab} release workflow step"),
}
}
pub(crate) fn build_step_subcommand() -> Command {
let mut command = Command::new("step")
.about("Run built-in release workflow steps")
.subcommand_required(true)
.arg_required_else_help(true)
.subcommand_help_heading("Step Commands");
for step in monochange_core::all_step_variants() {
let step = step.with_inherited_step_inputs();
let kebab = step.step_kebab_name();
let synthetic = CliCommandDefinition {
name: kebab,
help_text: Some(step_command_summary(&step)),
inputs: step.step_inputs_schema(),
steps: vec![step],
dry_run: false,
};
command = command.subcommand(build_cli_command_subcommand_with_prefix(
&synthetic,
"monochange step",
));
}
command
}
pub(crate) fn build_run_subcommand(cli: &[CliCommandDefinition]) -> Command {
let mut command = Command::new("run")
.about("Run workflow commands defined in monochange.toml")
.subcommand_required(true)
.arg_required_else_help(true)
.subcommand_help_heading("Workflow Commands");
for cli_command in cli {
if cli_command.name == "versions" {
continue;
}
command = command.subcommand(build_cli_command_subcommand_with_prefix(
cli_command,
"monochange run",
));
}
command
}
#[cfg(test)]
pub(crate) fn build_cli_command_subcommand(cli_command: &CliCommandDefinition) -> Command {
build_cli_command_subcommand_with_prefix(cli_command, "monochange")
}
pub(crate) fn build_cli_command_subcommand_with_prefix(
cli_command: &CliCommandDefinition,
usage_prefix: &str,
) -> Command {
let help_text = cli_command
.help_text
.clone()
.unwrap_or_else(|| format!("Run the `{}` command", cli_command.name));
let usage = cli_command_usage_with_prefix(cli_command, usage_prefix);
let mut command = Command::new(leak_string(cli_command.name.clone()))
.about(help_text)
.override_usage(usage)
.next_help_heading(RELEASE_OPTIONS_HELP_HEADING)
.arg(
Arg::new("dry-run")
.long("dry-run")
.help_heading(RELEASE_OPTIONS_HELP_HEADING)
.help("Run the command in dry-run mode when supported")
.action(ArgAction::SetTrue),
);
if command_supports_release_diff_preview(cli_command) {
command = command
.arg(
Arg::new("diff")
.long("diff")
.help_heading(RELEASE_OPTIONS_HELP_HEADING)
.help("Show unified file diffs for prepared release changes")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("prepared-release")
.long("prepared-release")
.help_heading(RELEASE_OPTIONS_HELP_HEADING)
.help("Read or write the prepared release artifact at a specific path")
.value_name("PATH"),
);
}
if let Some(after_help) = cli_command_after_help(cli_command) {
command = command.after_help(after_help);
}
command = command.next_help_heading("Options");
for input in &cli_command.inputs {
command = command.arg(build_cli_command_input_arg(input));
}
command
}
pub(crate) fn cli_command_after_help(cli_command: &CliCommandDefinition) -> Option<&'static str> {
match cli_command.name.as_str() {
"step:publish-release" | "step publish-release" => {
Some(
r"What this step does:
- Reads a prepared release artifact produced by prepare-release.
- Creates or updates source-control provider release objects, such as GitHub, GitLab, or Gitea releases.
- Uploads provider release notes and asset metadata according to the configured source provider.
What this step does not do:
- It does not publish package artifacts to registries such as npm, crates.io, pub.dev, or PyPI.
- Registry publication is handled by the publish planning and publish commands.
Typical release flow:
monochange step prepare-release --output prepared-release.json
monochange step publish-release --prepared-release prepared-release.json
Related commands:
monochange help step prepare-release
monochange help step publish-readiness
monochange help step publish-packages",
)
}
"step:prepare-release" | "step prepare-release" => {
Some(
r"What this step does:
- Reads pending changesets and workspace package metadata.
- Calculates version bumps, changelog entries, and release artifacts.
- Writes a prepared release artifact that later steps can consume.
Typical release flow:
monochange step prepare-release --output prepared-release.json
monochange step publish-release --prepared-release prepared-release.json",
)
}
"step:affected-packages" | "step affected-packages" => {
Some(
r"What this step does:
- Computes packages affected by a change or release plan.
- Includes direct changes, grouped packages, and dependent packages according to monochange propagation rules.
Use this command when debugging release scope before preparing or publishing a release.",
)
}
"step:create-change-file" => {
Some(
r"What this step does:
- Creates a changeset file for one or more packages.
- Records bump intent, reason text, and dependency-caused relationships.
Prefer configured package ids in change files whenever a leaf package changed.",
)
}
"change" => {
Some(
r#"Examples:
monochange run change --package sdk-core --bump patch --reason "fix panic"
monochange run change --package sdk-core --bump minor --reason "add API" --output .changeset/sdk-core.md
monochange run change --package sdk --bump minor --reason "coordinated release"
monochange run change --package sdk-config --bump none --caused-by sdk-core --reason "dependency-only follow-up"
Rules:
- Prefer configured package ids in change files whenever a leaf package changed.
- Use a group id only when the change is intentionally owned by the whole group.
- Dependents and grouped members are propagated automatically during planning.
- Use `--caused-by` when a package is only changing because another package or group moved first.
- Legacy manifest paths may still resolve during migration, but declared ids are the stable interface."#,
)
}
"release" => {
Some(
r"Examples:
monochange run release --dry-run --format text
monochange run release --dry-run --format json
monochange run release --dry-run --diff
monochange run release
Planning reminders:
- Direct package changes propagate to dependents using defaults.parent_bump.
- Group synchronization happens before final output is rendered.
- Explicit versions on grouped members propagate to the whole group.",
)
}
"versions" => {
Some(
r"Examples:
monochange versions --dry-run
monochange versions
monochange versions --dry-run --format json
Summary notes:
- This command syncs internal workspace dependency constraints.
- Use --dry-run to preview manifest edits before writing files.
- Strategy precedence is package config, ecosystem config, ecosystem default; --strategy overrides.",
)
}
"commit-release" => {
Some(
r"Examples:
monochange run commit-release --dry-run --format json
monochange run commit-release --dry-run --diff
monochange run commit-release
Commit notes:
- Reuses the standard monochange release commit subject/body contract.
- Embeds a durable release record block in the commit body.
- Can run before OpenReleaseRequest in the same workflow.",
)
}
"affected" => {
Some(
r"Examples:
monochange step affected-packages --changed-paths crates/core/src/lib.rs --format json
monochange step affected-packages --from origin/main --verify
Verification reminders:
- Prefer package ids in .changeset files.
- Group-owned changesets cover all members of that group.
- Ignored paths and skip labels are controlled from [changesets.affected].",
)
}
"diagnostics" => {
Some(
r"Examples:
monochange step diagnose-changesets --format json
monochange step diagnose-changesets --changeset .changeset/feature.md
Diagnostics include:
- Target packages/groups and requested bump
- commit SHA that introduced and last updated each changeset
- linked review request (when detected)
- related issue references",
)
}
"repair-release" => {
Some(
r"Examples:
monochange repair-release --from v1.2.3 --dry-run
monochange repair-release --from v1.2.3 --target HEAD --format json
Repair notes:
- Finds the release record from history using the supplied ref.
- Moves the full release tag set together.
- Defaults to descendant-only retargets unless --force is set.
- Hosted release sync runs by default and can be disabled with --sync-provider=false.",
)
}
"tag-release" | "step:tag-release" | "step tag-release" => {
Some(
r"Examples:
monochange step tag-release --from HEAD
monochange step tag-release --from HEAD --dry-run --format json
monochange step tag-release --from HEAD --push=false
Tagging notes:
- Requires the resolved ref itself to be the monochange release commit.
- Creates the full tag set declared by that release record.
- Treats reruns on the same commit as already up to date.
- Use `monochange repair-release` if you need to move existing tags later.",
)
}
_ => None,
}
}
pub(crate) fn cli_command_usage(cli_command: &CliCommandDefinition) -> String {
cli_command_usage_with_prefix(cli_command, "monochange")
}
pub(crate) fn cli_command_usage_with_prefix(
cli_command: &CliCommandDefinition,
usage_prefix: &str,
) -> String {
let mut usage = String::with_capacity(32 + cli_command.name.len());
usage.push_str(usage_prefix);
usage.push(' ');
usage.push_str(&cli_command.name);
usage.push_str(" [--dry-run]");
if command_supports_release_diff_preview(cli_command) {
usage.push_str(" [--diff] [--prepared-release <PATH>]");
}
for input in &cli_command.inputs {
usage.push(' ');
usage.push_str(&cli_input_usage(input));
}
usage
}
pub(crate) fn cli_input_usage(input: &CliInputDefinition) -> String {
let long_name = input.name.replace('_', "-");
let value_name = match input.kind {
CliInputKind::Boolean => None,
CliInputKind::Path => Some("PATH".to_string()),
CliInputKind::String | CliInputKind::StringList | CliInputKind::Choice => {
Some(input.name.to_uppercase())
}
};
let option = value_name.map_or_else(
|| format!("--{long_name}"),
|name| format!("--{long_name} <{name}>"),
);
if input.required {
option
} else {
format!("[{option}]")
}
}
fn build_cli_command_input_arg(input: &CliInputDefinition) -> Arg {
let long_name = leak_string(input.name.replace('_', "-"));
let value_name = leak_string(input.name.to_uppercase());
let help_text = input.help_text.clone().unwrap_or_default();
let mut arg = Arg::new(leak_string(input.name.clone()))
.long(long_name)
.required(input.required)
.help(help_text);
arg = match input.kind {
CliInputKind::String => arg.value_name(value_name),
CliInputKind::StringList => arg.value_name(value_name).action(ArgAction::Append),
CliInputKind::Path => arg.value_name("PATH"),
CliInputKind::Boolean => {
if input.default.as_deref() == Some("true") {
arg.value_name(value_name)
.num_args(0..=1)
.default_missing_value("true")
.require_equals(true)
.value_parser(["true", "false"])
} else {
arg.action(ArgAction::SetTrue)
}
}
CliInputKind::Choice => {
let possible_values: Vec<_> = input.choices.iter().cloned().map(leak_string).collect();
arg.value_name(value_name)
.value_parser(clap::builder::PossibleValuesParser::new(possible_values))
}
};
if let Some(short) = input.short {
arg = arg.short(short);
}
let should_apply_default = input
.default
.as_ref()
.is_some_and(|default| !matches!(input.kind, CliInputKind::Boolean) || default == "true");
if should_apply_default {
arg = arg.default_value(leak_string(input.default.clone().unwrap()));
}
arg
}
fn leak_string(value: impl Into<String>) -> &'static str {
Box::leak(value.into().into_boxed_str())
}
pub(crate) fn build_versions_subcommand() -> Command {
Command::new("versions")
.about("List package and group versions or sync internal dependency constraints")
.long_about(
"List current package and version-group versions, or update internal workspace dependency \
references to match canonical package versions. Use `monochange versions list --format json` \
for a flat version inventory and `monochange versions sync` to normalize dependency constraints.",
)
.after_help(
"Examples:\n monochange versions list --format json\n monochange versions sync --dry-run\n monochange versions sync --dry-run --format json\n monochange versions sync --strategy exact\n\n\
Calling `monochange versions` without a subcommand still runs the legacy sync behavior, but it is \
deprecated and will be removed in a future version. Use `monochange versions sync` instead.",
)
.arg_required_else_help(false)
.subcommand_required(false)
.args(versions_sync_args())
.subcommand(
Command::new("list")
.about("List current package and group versions")
.long_about(
"List current package and version-group versions as a flat mapping keyed by the exact \
monochange package or group id.",
)
.arg(
Arg::new("format")
.long("format")
.help("Output format")
.default_value("text")
.value_parser(["text", "json"]),
),
)
.subcommand(
Command::new("sync")
.about("Sync internal dependency constraints to package versions")
.long_about(
"Update internal workspace dependency references to match canonical package versions. \
Use this when migrating to monochange, checking whether manifests are already in sync, \
or normalizing constraints before grouping packages for shared releases.",
)
.after_help(
"Examples:\n monochange versions sync --dry-run\n monochange versions sync --dry-run --format json\n monochange versions sync --strategy exact\n\n\
This command syncs internal workspace dependency constraints. Strategy precedence is package config, \
then ecosystem config, then the ecosystem default unless --strategy forces one style for this run.",
)
.args(versions_sync_args()),
)
}
fn versions_sync_args() -> Vec<Arg> {
vec![
Arg::new("dry-run")
.long("dry-run")
.help("Show what would change without modifying files")
.action(ArgAction::SetTrue),
Arg::new("format")
.long("format")
.help("Output format")
.default_value("text")
.value_parser(["text", "json"]),
Arg::new("strategy")
.long("strategy")
.help("Override version constraint strategy: default, exact, caret, compatible")
.long_help(
"Override the version constraint strategy. With `default`, monochange uses \
package config first, then ecosystem config, then the ecosystem default. Use \
`exact`, `caret`, or `compatible` to force one style for this run.",
)
.default_value("default")
.value_parser(["default", "exact", "caret", "compatible"]),
]
}
pub(crate) fn current_dir_or_dot() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}