Skip to content

feat(command): add context-aware typed constructors and ArgGroup support - #73

Merged
jpage-godaddy merged 3 commits into
mainfrom
cli-engine-typed-commands
Aug 3, 2026
Merged

feat(command): add context-aware typed constructors and ArgGroup support#73
jpage-godaddy merged 3 commits into
mainfrom
cli-engine-typed-commands

Conversation

@jpage-godaddy

@jpage-godaddy jpage-godaddy commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Phase 1 of DEVEX-954 (migrate gddy to strongly-typed commands). Closes two engine gaps found while surveying gddy's 67 command definitions:

  • RuntimeCommandSpec::new_typed_with_context and new_typed_streaming: the existing new_typed only covers the (CredentialResolver, T) handler shape, which matched just 7 of gddy's 67 commands. 59 use new_with_context (need CommandContext for command path, middleware, --dry-run) and 1 uses new_streaming — neither had a typed equivalent, forcing a fallback to new_with_context + manual context.typed_args::<T>(). These two new constructors eagerly parse T before calling the handler, same guarantee new_typed gives the credential-only case, for the two majority handler shapes.
  • ArgGroup support: CommandSpec had no way to express "at least one of"/mutually-exclusive argument relationships. Confirmed by gddy's own workaround (required_unless_present_any triplicated three ways, with a comment acknowledging the gap). Adds CommandSpec::arg_groups/with_arg_group for the builder path, and CommandSpec::from_args::<T>() now captures a derive struct's #[group(...)] attribute automatically. Includes a debug_assert against a real clap_derive footgun (verified against vendored source): a struct combining #[command(flatten)] with its own #[group(...)] silently gets an empty, unenforced group.

Illustrative example: gddy application update before/after

This isn't a synthetic example — it's the actual gddy platform app update command, converted against a path-dependency build of this branch as part of validating the change (see "Downstream validation" below). It hits both new features at once: new_with_contextnew_typed_with_context, and the triplicated required_unless_present_any → a single #[group(...)].

Before (today, on main):

fn update_command() -> RuntimeCommandSpec {
    RuntimeCommandSpec::new_with_context(
        CommandSpec::new("update", "Update an application")
            .with_system("applications")
            .with_tier(Tier::Mutate)
            .with_scopes(&[APP_REGISTRY_READ, APP_REGISTRY_WRITE])
            .with_output_schema::<ApplicationUpdate>()
            .with_arg(
                clap::Arg::new("id")
                    .long("id")
                    .value_name("ID")
                    .required(true)
                    .help("Application ID"),
            )
            .with_arg(
                clap::Arg::new("label")
                    .long("label")
                    .value_name("LABEL")
                    // CommandSpec has no ArgGroup; this enforces "at least one" at parse time.
                    .required_unless_present_any(["description", "status"])
                    .help("New label"),
            )
            .with_arg(
                clap::Arg::new("description")
                    .long("description")
                    .value_name("TEXT")
                    .required_unless_present_any(["label", "status"])
                    .help("New description"),
            )
            .with_arg(
                clap::Arg::new("status")
                    .long("status")
                    .value_name("STATUS")
                    .value_parser(["ACTIVE", "INACTIVE"])
                    .required_unless_present_any(["label", "description"])
                    .help("Application status (ACTIVE or INACTIVE)"),
            ),
        |ctx| async move {
            let id = arg_str(&ctx, "id").to_owned();
            let mut input = serde_json::Map::new();
            if let Some(label) = ctx.args.get("label").and_then(|v| v.as_str()) {
                input.insert("label".to_owned(), json!(label));
            }
            if let Some(desc) = ctx.args.get("description").and_then(|v| v.as_str()) {
                input.insert("description".to_owned(), json!(desc));
            }
            if let Some(status) = ctx.args.get("status").and_then(|v| v.as_str()) {
                input.insert("status".to_owned(), json!(status));
            }
            let client = make_client(&ctx).await?;
            let data = client
                .update_application(&id, json!(input))
                .await
                .map_err(client_err)?;
            // ...build CommandResult with next_actions (unchanged below)
            Ok(CommandResult::new(data["updateApplication"].clone()))
        },
    )
}

After (with this PR's constructors):

/// "At least one of" fields for `update`, flattened into `UpdateArgs`.
///
/// Kept as its own derive struct (rather than inline fields on `UpdateArgs`)
/// so the struct-level `#[group(...)]` only covers these three — `id` stays
/// outside the group and independently required.
#[derive(Debug, Clone, clap::Args)]
#[group(required = true, multiple = true)]
struct UpdateFields {
    /// New label.
    #[arg(long, value_name = "LABEL")]
    label: Option<String>,

    /// New description.
    #[arg(long, value_name = "TEXT")]
    description: Option<String>,

    /// Application status (ACTIVE or INACTIVE).
    #[arg(long, value_name = "STATUS", value_parser = ["ACTIVE", "INACTIVE"])]
    status: Option<String>,
}

#[derive(Debug, Clone, clap::Args)]
struct UpdateArgs {
    /// Application ID.
    #[arg(long, value_name = "ID")]
    id: String,

    #[command(flatten)]
    fields: UpdateFields,
}

fn update_command() -> RuntimeCommandSpec {
    RuntimeCommandSpec::new_typed_with_context::<UpdateArgs, _, _, _>(
        CommandSpec::from_args::<UpdateArgs>("update", "Update an application")
            .with_system("applications")
            .with_tier(Tier::Mutate)
            .with_scopes(&[APP_REGISTRY_READ, APP_REGISTRY_WRITE])
            .with_output_schema::<ApplicationUpdate>(),
        |context, args: UpdateArgs| async move {
            let mut input = serde_json::Map::new();
            if let Some(label) = args.fields.label {
                input.insert("label".to_owned(), json!(label));
            }
            if let Some(description) = args.fields.description {
                input.insert("description".to_owned(), json!(description));
            }
            if let Some(status) = args.fields.status {
                input.insert("status".to_owned(), json!(status));
            }
            let client = make_client(&context).await?;
            let data = client
                .update_application(&args.id, json!(input))
                .await
                .map_err(client_err)?;
            // ...build CommandResult with next_actions (unchanged below)
            Ok(CommandResult::new(data["updateApplication"].clone()))
        },
    )
}

What changed, concretely:

  • ctx.args.get("label").and_then(|v| v.as_str())args.fields.label — a typo'd key name is now a compile error instead of a silent None at runtime.
  • Three .required_unless_present_any([...]) calls (one per flag, each listing the other two) → one #[group(required = true, multiple = true)] on UpdateFields. clap's own generated usage line also gets more precise as a side effect: <--label <LABEL>|--description <TEXT>|--status <STATUS>> instead of three independent "required unless" clauses.
  • RuntimeCommandSpec::new_with_context (handler gets ctx: CommandContext, id/args read from ctx.args) → new_typed_with_context (handler gets (context: CommandContext, args: UpdateArgs), still with full CommandContext for make_client(&context), but args arrive pre-parsed and typed).
  • Behavior is unchanged end-to-end: same flags, same "at least one of" enforcement (clap's MissingRequiredArgument either way — the existing gddy test update_requires_at_least_one_field passes unmodified against the converted command), same GraphQL call.
  • Per-flag --help text is unchanged too: clap_derive turns each field's /// doc comment into the exact same help string .help("...") produced in the builder version — /// New label.New label, etc. Confirmed against the real converted command's own --help output:
$ gddy platform app update --help
Usage: gddy platform app update [OPTIONS] --id <ID> <--label <LABEL>|--description <TEXT>|--status <STATUS>>

Options:
      --id <ID>              Application ID
      --label <LABEL>        New label
      --description <TEXT>   New description
      --status <STATUS>      Application status (ACTIVE or INACTIVE)
                              [possible values: ACTIVE, INACTIVE]
  -h, --help                 Print help

Not a breaking change. Seven structs — CommandSpec, GroupSpec, CommandResult, RuntimeCommandSpec, RuntimeGroupSpec, Module, NextAction — are now #[non_exhaustive], and CommandSpec gains a new public arg_groups field. Both would normally force every literal-construction/exhaustive-destructuring site to update. Audited every public struct with pub fields across both real consumers (gdx: 140+ command/group sites; gddy: 67+) and this crate's own tests//examples/ (which compile as separate crates and are subject to the identical restriction as any external consumer) — all seven had zero literal construction or exhaustive destructuring anywhere; every site uses the builder pattern (::new()/::from_args() + with_* chaining) as the docs prescribe. So neither the new field nor any of the #[non_exhaustive] markers requires a single line of downstream code to change, today or for either real consumer — and future field additions to any of the seven won't force a breaking release either.

Explicitly not touched, because the audit found real literal-construction sites that would break: CliConfig, BuildInfo, GuideEntry, ActivityEvent (all have literal construction in this crate's own tests/foundation.rsCliConfig's doc comment explicitly documents this as intentional), and Credential, OutputField, NextActionParam (real literal construction in gdx/gddy with no non-literal alternative shipped yet).

Validated end-to-end against a path-dependency build of gddy (in a sibling worktree): converted application update (shown above) and application deploy (streaming, via new_typed_streaming) to the new constructors. All existing gddy tests (458) pass unmodified, including the tests that pin the "at least one of" error behavior — confirming the ArgGroup swap is behavior-preserving.

Test plan

  • cargo fmt --all --check
  • cargo clippy --all-targets -- -D warnings
  • RUSTDOCFLAGS='-D warnings' cargo doc --no-deps
  • cargo rustdoc --lib -- -W missing-docs (zero missing docs)
  • cargo test --all-targets (new tests in tests/derive_bridge.rs and src/command.rs's unit test module cover both new constructors and both ArgGroup variants — required/multiple and required/mutually-exclusive — plus the builder-path and derive-capture cases)
  • cargo test --doc
  • Downstream validation: gddy's full test suite (458 tests) and manual cargo run smoke tests of the two converted commands, against a path-dependency build of this branch — re-verified after each #[non_exhaustive] addition
  • Audited gdx/gddy/this crate's own tests+examples for literal construction of every struct marked #[non_exhaustive] — zero hits anywhere, confirming no breaking impact

🤖 Generated with Claude Code

…port

Adds RuntimeCommandSpec::new_typed_with_context and new_typed_streaming so
commands needing CommandContext (command path, middleware, --dry-run) or
NDJSON streaming can still get eager, compiler-checked typed args instead of
falling back to context.typed_args::<T>(). Also adds CommandSpec::arg_groups/
with_arg_group, with CommandSpec::from_args capturing clap_derive's
struct-level #[group(...)] attribute, replacing required_unless_present_any/
conflicts_with chains with a single declarative relation.

Closes the two gaps found while surveying gddy's 67 commands for a typed-args
migration (DEVEX-954): new_typed only covered 7 of them, and the engine had
no ArgGroup support at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds missing typed-constructor and argument-relation primitives to cli-engine to support migrating downstream CLIs (e.g., gddy) to strongly-typed command handlers without losing access to CommandContext, streaming, or clap ArgGroup constraints.

Changes:

  • Introduces RuntimeCommandSpec::new_typed_with_context and RuntimeCommandSpec::new_typed_streaming to support typed args for context-aware and streaming handler shapes.
  • Adds CommandSpec::arg_groups plus with_arg_group, and extends CommandSpec::from_args::<T>() to capture derive-registered ArgGroups (with a debug-assert for the flatten+group footgun).
  • Expands documentation and integration/unit tests to cover new constructors and ArgGroup behavior (builder and derive paths).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
tests/derive_bridge.rs Adds integration coverage for the new typed context/streaming constructors and derive-captured ArgGroup constraints.
src/command.rs Implements new runtime constructors and adds ArgGroup support to CommandSpec (including clap command construction).
docs/design.md Documents when to use the new typed constructors and how to express argument relations via ArgGroup (builder and derive).
AGENTS.md Updates the command-authoring checklist to recommend the new APIs and warn about the flatten+group caveat.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Both structs are builder-only in practice — surveyed every consumer
(gdx: 140+ command/group sites, gddy: 67+) and found zero struct-literal
construction or destructuring of either type. Adding #[non_exhaustive] now,
bundled into this release's existing breaking bump, means future field
additions (like arg_groups in this same PR) won't need one of their own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

…pec, Module, and NextAction non_exhaustive

Same rationale as the earlier CommandSpec/GroupSpec change in this PR: audited
every public struct with pub fields across gdx, gddy, and this crate's own
tests/examples (which compile as separate crates and are subject to the same
restriction as any external consumer) for literal construction or exhaustive
destructuring. These five had zero hits anywhere, so marking them
non_exhaustive now closes off future field additions needing a breaking
release of their own, at zero real cost.

Excluded from this batch: CliConfig, BuildInfo, GuideEntry, and ActivityEvent,
which do have real literal-construction sites in tests/foundation.rs (CliConfig's
own doc comment explicitly documents this as intentional). Also excluded:
Credential, OutputField, and NextActionParam, which have real literal
construction in gdx/gddy with no non-literal alternative available yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (1)

docs/design.md:232

  • The with_arg_group bullet breaks Markdown list formatting because the continuation line isn’t indented, so relationships... renders as a separate paragraph instead of part of the bullet.
- `with_arg_group` adds a `clap::ArgGroup` expressing "at least one of" or mutually-exclusive
relationships between args, without a `required_unless_present_any`/`conflicts_with` chain.

@jpage-godaddy jpage-godaddy changed the title feat(command)!: add context-aware typed constructors and ArgGroup support feat(command): add context-aware typed constructors and ArgGroup support Aug 1, 2026
@jpage-godaddy
jpage-godaddy merged commit 748efc6 into main Aug 3, 2026
8 checks passed
@jpage-godaddy
jpage-godaddy deleted the cli-engine-typed-commands branch August 3, 2026 17:15
@github-actions github-actions Bot mentioned this pull request Aug 3, 2026
jpage-godaddy added a commit that referenced this pull request Aug 3, 2026
The move to cli-engine/ (#77) changed release-please's per-path
commit filtering — file diffs for commits made before the move (#72,
#73) point at the old src/ paths, not cli-engine/, so release-please's
regenerated changelog only picked up commits made after the move.
Nothing was lost from git history or the shipped code; this just
restores the visible changelog entry to the full set of changes since
0.6.0.
jpage-godaddy added a commit that referenced this pull request Aug 3, 2026
🤖 I have created a release *beep* *boop*
---


<details><summary>cli-engine: 0.6.1</summary>

##
[0.6.1](cli-engine-v0.6.0...cli-engine-v0.6.1)
(2026-08-03)


### Features

* **command:** add context-aware typed constructors and ArgGroup support
([#73](#73))
([748efc6](748efc6))
* **output:** support nested table output in human rendering
([#72](#72))
([8ca7479](8ca7479))


### Bug Fixes

* **workspace:** drop redundant "." self-reference from workspace
members ([#75](#75))
([b769528](b769528))
* **workspace:** restructure into a true sibling Cargo workspace
([#77](#77))
([05b8b29](05b8b29))
</details>

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jacob Page <jpage@godaddy.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants