Skip to content

feat(RHIDP-14129): add intent-based CLI commands and backstage-cli pass-through - #156

Open
yangcao77 wants to merge 10 commits into
redhat-developer:mainfrom
yangcao77:intent-based-cli
Open

feat(RHIDP-14129): add intent-based CLI commands and backstage-cli pass-through#156
yangcao77 wants to merge 10 commits into
redhat-developer:mainfrom
yangcao77:intent-based-cli

Conversation

@yangcao77

@yangcao77 yangcao77 commented Aug 5, 2026

Copy link
Copy Markdown

https://redhat.atlassian.net/browse/RHIDP-14129

Adds intent-based subcommands and backstage-cli pass-through commands to rhdh-cli

Today, interacting with a running RHDH/Backstage instance from the CLI requires backstage-cli actions execute <pluginId>:<actionName> with internal action IDs and raw JSON input. This is very bad experience for human operations.

This change makes rhdh-cli the single entry point:

# Before: two CLIs, raw action IDs                                                                         
backstage-cli auth login --backend-url https://rhdh.example.com                                            
backstage-cli actions execute catalog:query-catalog-entities --query '{"kind":"Component"}' 
backstage-cli actions execute catalog:query-catalog-entities --query '{"kind":"Template"}'   

# After: one CLI, intent-based commands                                                                    
rhdh-cli auth login --backend-url https://rhdh.example.com                                                 
rhdh-cli catalog list --kind Component   
rhdh-cli template list
           

All intent-based commands support --output json for agent consumption and --instance <name> for multi-instance targeting.

The local metadata file is still going to use the config file for backstage-cli , so that existing backstage-cli user can migrate to use rhdh-cli with no extra effort

  • Commands shell out to backstage-cli, no new backend dependencies
  • Large responses use file redirect to work around Node.js pipe buffer limits
  • Human-readable output (entity tables, search results) by default; --output json for agents
  • Existing plugin export and plugin package commands are unchanged

see recording:

rhdh-cli.mov

…tions related will use backstage-cli

Signed-off-by: Stephanie <yangcao@redhat.com>
Signed-off-by: Stephanie <yangcao@redhat.com>
@yangcao77 yangcao77 changed the title [RHIDP-14129] Add intent-based CLI commands and backstage-cli pass-through feat(RHIDP-14129) Add intent-based CLI commands and backstage-cli pass-through Aug 5, 2026
@yangcao77

Copy link
Copy Markdown
Author

@kadel @benwilcock @elsony @durandom FYI

@yangcao77 yangcao77 changed the title feat(RHIDP-14129) Add intent-based CLI commands and backstage-cli pass-through feat(RHIDP-14129): add intent-based CLI commands and backstage-cli pass-through Aug 5, 2026
- Statically import command modules in commands/index.ts instead of
  using require(), since the backstage-cli bundler only follows static
  ESM imports/dynamic import() and silently dropped the require()'d
  files from the packed dist, breaking every command once installed
  from npm (Cannot find module './backstage-passthrough').
- Fix TS2352 in intent-errors.ts by adding a safe getStderr() helper
  instead of casting Error directly to Record<string, unknown>.
- Restrict the PATH used to resolve backstage-cli via `which` to
  directories that aren't group/other-writable, addressing the
  SonarCloud S4036 PATH-search security hotspot in lib/client.ts.
- Extract shared runEntityListAction/runRawAction/runSearchAction
  helpers and a registerPassthroughCommand helper to remove the heavy
  code duplication SonarCloud flagged across catalog/api/template/
  search/docs/backstage-passthrough command files.
- Fix pre-existing lint (no-empty, func-names) and prettier issues so
  the Checks job can get past the linter/prettier steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/commands/intent-based-actions/client.ts Fixed
…ctory

Move catalog/api/search/docs/template/backstage-passthrough and their
supporting client/format/intent-errors/helpers modules into
src/commands/intent-based-actions/, mirroring the existing
export-dynamic-plugin/ and package-dynamic-plugins/ layout, with a
single registerIntentCommands() entry point.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread src/commands/intent-based-actions/client.ts Fixed
SonarCloud S4036 still flagged spawnSync('which', ...) even with a restricted PATH env, since it pattern-matches on shelling out to a path-search utility rather than analyzing the PATH value. Replace it with a direct filesystem walk over PATH entries (skipping group/other-writable directories) and an accessSync executability check, avoiding the flagged pattern entirely.

Co-authored-by: Cursor <cursoragent@cursor.com>

@kadel kadel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two concerns about how backstage-cli is resolved and surfaced.

Resolution: findBackstageCliOnPath walks system PATH looking for a backstage-cli binary, but backstage-cli is not something people typically install as a standalone global binary, so the PATH walk is unlikely to find it. This means the npx -y @backstage/cli fallback is effectively the default path. This fallback silently downloads whatever latest is on npm without user confirmation. rhdh-cli is built against 0.36.3, so the downloaded version could behave differently, and -y suppresses the install prompt. This is a supply chain concern for a CLI meant for production use.

@backstage/cli is already a declared dependency of this project at 0.36.3. Could we resolve the binary from the installed dependency instead of walking PATH or downloading via npx?

Leaking backstage-cli identity: The passthrough commands expose backstage-cli's own output directly. Commander intercepts --help before it reaches backstage-cli, so passthrough commands show empty help with no options. But backstage-cli itself has useful help that's being hidden. Compare:

rhdh-cli auth login --help:

Usage: rhdh-cli auth login [options]

Log in to a Backstage/RHDH instance

Options:
  -h, --help  display help for command

backstage-cli auth login --help:

Usage:
  backstage-cli auth login [flags...]

Flags:
      --backend-url <string>        Backend base URL
  -h, --help                        Show help
      --instance <string>           Name for this instance
      --no-browser                  Do not open browser automatically

rhdh-cli actions execute --help:

Usage: rhdh-cli actions execute [options]

Execute an action

Options:
  -h, --help  display help for command

backstage-cli actions execute --help:

Usage:
  backstage-cli actions execute [flags...] <action-id>

Flags:
  -h, --help                     Show help
      --instance <string>        Name of the instance to use

The rhdh-cli versions hide --backend-url, --no-browser, --instance, and the <action-id> positional argument. Running without --help (e.g., rhdh-cli actions execute with no args) does forward to backstage-cli but then shows backstage-cli branding instead of rhdh-cli.

For human users this is confusing. For AI agents discovering the CLI through --help, it's a blocker since they see no options and can't tell which CLI to use.

@yangcao77

yangcao77 commented Aug 13, 2026

Copy link
Copy Markdown
Author

@kadel

Thanks for the review! Fixed both:

Resolution: backstage-cli is now resolved directly from the installed @backstage/cli dependency (via Node module resolution) instead of walking PATH or falling back to npx. No more supply-chain risk, and it works even with an empty PATH.
Help/branding: Passthrough commands now forward -h/--help to backstage-cli so real flags show up (e.g. rhdh-cli auth login --help now shows --backend-url, --instance, --no-browser, etc.), and all output is rebranded to rhdh-cli instead of backstage-cli.
Pushed the changes, ready for another look.

Signed-off-by: Stephanie <yangcao@redhat.com>
@yangcao77
yangcao77 requested a review from kadel August 17, 2026 15:03

@kadel kadel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we need the intent-based commands (template, catalog, api, search, docs) in this PR, or should they be a follow-up?

Every intent-based command maps 1:1 to actions execute — for example, rhdh-cli template list is just rhdh-cli actions execute catalog:query-catalog-entities --query '{"kind":"Template"}'. The passthrough layer (auth, actions) already gives users and agents full access to the same functionality.

The intent-based commands add ~1,000 lines with no tests, and one of them (template dry-run) already has a bug where it passes an entity ref as templateYaml instead of actual YAML content. The impact is low — the command just fails with an error, it can't cause any damage — but it shows the risk of shipping this much code without test coverage.

If we want to keep those extra commands they need test coverage

Would it make sense to merge just the passthrough commands (auth, actions, actions sources) first — they're solid and already working — and add the intent-based layer in a follow-up with proper test coverage? The repo already has a Jest setup in src/lib/*.test.ts, and most of the new code (formatting, error handling, entity extraction) is pure functions that are straightforward to test.

Comment thread src/commands/intent-based-actions/template.ts Outdated
Signed-off-by: Stephanie <yangcao@redhat.com>
@yangcao77

Copy link
Copy Markdown
Author

@kadel
Thanks for the thorough review, fixed the --template-ref flag to use --templateYaml. now --template-file <path> reads the file and passes its contents as templateYaml.

On whether we need the intent-based commands in this PR: yes, they're the actual point of this story https://redhat.atlassian.net/browse/RHIDP-14129. The goal is that users and agents should never need to know internal action names or the actions execute <plugin>:<action> syntax, that's exactly what the passthrough layer still requires. The intent-based commands (catalog, api, search, docs, template) are the layer that makes rhdh-cli usable without that knowledge, e.g. rhdh-cli template list instead of rhdh-cli actions execute catalog:query-catalog-entities --query '{"kind":"Template"}'. also the intent clis improves UX on the output, backsatge-cli returns json output, whereas the intent clis now outputs in human readable way, more details can be found in the recording attached in the PR description, showing the before/after of using rhdh-cli with the intent-based commands vs raw actions execute.

I agree the test coverage is needed, I initially did not add any as I didn't see any tests coverage for all other cmds in this repo. I've add some unit tests as part of the PR, the integration tests I have created it as a QE item. https://redhat.atlassian.net/browse/RHIDP-14254

@kadel

kadel commented Aug 21, 2026

Copy link
Copy Markdown
Member

The intent-based commands (catalog, api, search, docs, template) are the layer that makes rhdh-cli usable without that knowledge, e.g. rhdh-cli template list instead of rhdh-cli actions execute catalog:query-catalog-entities --query '{"kind":"Template"}'. also the intent clis improves UX on the output, backsatge-cli returns json output, whereas the intent clis now outputs in human readable way, more details can be found in the recording attached in the PR description, showing the before/after of using rhdh-cli with the intent-based commands vs raw actions execute.

I don't think that for agentic use this matters that much. The original backstage cli commands are well designed for AI use.

If the goal for new commands is mainly human usage, then we need to think a little bit harder about how they look like and what arguments they expose.

For example expecting humans to type JSON strings in terminal as arguments for CLI command is bad UX. Agents can deal with it, but people can't.

Even simple tasks like searching only in Components I have to type JSON

rhdh-cli search "rhdh" --filters '{"kind":"Component"}'

something like this would be much better cli experience:

rhdh-cli search "rhdh" --filter kind=Component

In the template execution it can also get quite complicated.

rhdh-cli template execute \
  --template-ref template:default/register-component \
  --values '{"githubHost":"github.com","githubOrganization":"foo","repositoryName":"bar","componentOwner":"user:default/default","componentType":"service","componentLifecycle":"production"}'

More natural CLI experience should be something like this.

rhdh-cli template execute \
  --template-ref template:default/register-component \
  --set githubHost=github.com \
  --set githubOrganization=foo \
  --set repositoryName=bar \
  --set componentOwner=user:default/default \
  --set componentType=service \
  --set componentLifecycle=production

# or
rhdh-cli template execute \
  --template-ref template:default/register-component \
  --set githubHost=github.com, githubOrganization=foo, repositoryName=bar, componentOwner=user:default/default, componentType=service, componentLifecycle=production

The errors are currently also not presented in a user-friendly way

rhdh-cli template execute --template-ref template:default/register-component \
  --values '{"githubHost":"github.com","githubOrganization":"foo","repositoryName":"bar"}'
Error: Backend request failed, 400 Bad Request {"errors":[{"path":[],"property":"instance","message":"requires property \"componentOwner\"","schema":{"title":"Provide information about the new component","required":["componentOwner","componentType","componentLifecycle"],"properties":{"componentName":{"title":"Component Name","type":"string","description":"Name of the created component. If leaved empty the name of the repository will be used."},"componentOwner":{"title":"Owner","description":"Select an owner from the list or enter a reference to a Group or a User","type":"string","ui:field":"EntityPicker","ui:options":{"catalogFilter":{"kind":["Group","User"]}}},"componentType":{"title":"Type","type":"string","description":"The type of component. Well-known and common values: service, website, library.","default":"other"},"componentLifecycle":{"title":"Lifecycle","type":"string","description":"The lifecycle state of the component. Well-known and common values: experimental, production, deprecated.","default":"unknown"}}},"instance":{"githubHost":"github.com","githubOrganization":"foo","repositoryName":"bar"},"name":"required","argument":"componentOwner","stack":"instance requires property \"componentOwner\""},{"path":[],"property":"instance","message":"requires property \"componentType\"","schema":{"title":"Provide information about the new component","required":["componentOwner","componentType","componentLifecycle"],"properties":{"componentName":{"title":"Component Name","type":"string","description":"Name of the created component. If leaved empty the name of the repository will be used."},"componentOwner":{"title":"Owner","description":"Select an owner from the list or enter a reference to a Group or a User","type":"string","ui:field":"EntityPicker","ui:options":{"catalogFilter":{"kind":["Group","User"]}}},"componentType":{"title":"Type","type":"string","description":"The type of component. Well-known and common values: service, website, library.","default":"other"},"componentLifecycle":{"title":"Lifecycle","type":"string","description":"The lifecycle state of the component. Well-known and common values: experimental, production, deprecated.","default":"unknown"}}},"instance":{"githubHost":"github.com","githubOrganization":"foo","repositoryName":"bar"},"name":"required","argument":"componentType","stack":"instance requires property \"componentType\""},{"path":[],"property":"instance","message":"requires property \"componentLifecycle\"","schema":{"title":"Provide information about the new component","required":["componentOwner","componentType","componentLifecycle"],"properties":{"componentName":{"title":"Component Name","type":"string","description":"Name of the created component. If leaved empty the name of the repository will be used."},"componentOwner":{"title":"Owner","description":"Select an owner from the list or enter a reference to a Group or a User","type":"string","ui:field":"EntityPicker","ui:options":{"catalogFilter":{"kind":["Group","User"]}}},"componentType":{"title":"Type","type":"string","description":"The type of component. Well-known and common values: service, website, library.","default":"other"},"componentLifecycle":{"title":"Lifecycle","type":"string","description":"The lifecycle state of the component. Well-known and common values: experimental, production, deprecated.","default":"unknown"}}},"instance":{"githubHost":"github.com","githubOrganization":"foo","repositoryName":"bar"},"name":"required","argument":"componentLifecycle","stack":"instance requires property \"componentLifecycle\""}]}

Another usability problem for human use is that there is no way to easily list what parameters a template requires. The only way to do it is using following command. Which forces people to parse JSON. (without pre-filtering it with jq it makes it even harder to read)

bin/rhdh-cli catalog get --name register-component --kind Template --output json | jq '.spec.parameters'
command output
[
  {
    "title": "Provide information about the GitHub location",
    "required": [
      "githubHost",
      "githubOrganization",
      "repositoryName"
    ],
    "properties": {
      "githubHost": {
        "title": "GitHub hostname",
        "type": "string",
        "description": "Use github.com for GitHub Free, Pro, & Team or specify a hostname of your GitHub Enterprise instance.",
        "default": "github.com"
      },
      "githubOrganization": {
        "title": "GitHub Organization",
        "type": "string"
      },
      "repositoryName": {
        "title": "Repository name",
        "type": "string"
      }
    }
  },
  {
    "title": "Provide information about the new component",
    "required": [
      "componentOwner",
      "componentType",
      "componentLifecycle"
    ],
    "properties": {
      "componentName": {
        "title": "Component Name",
        "type": "string",
        "description": "Name of the created component. If leaved empty the name of the repository will be used."
      },
      "componentOwner": {
        "title": "Owner",
        "description": "Select an owner from the list or enter a reference to a Group or a User",
        "type": "string",
        "ui:field": "EntityPicker",
        "ui:options": {
          "catalogFilter": {
            "kind": [
              "Group",
              "User"
            ]
          }
        }
      },
      "componentType": {
        "title": "Type",
        "type": "string",
        "description": "The type of component. Well-known and common values: service, website, library.",
        "default": "other"
      },
      "componentLifecycle": {
        "title": "Lifecycle",
        "type": "string",
        "description": "The lifecycle state of the component. Well-known and common values: experimental, production, deprecated.",
        "default": "unknown"
      }
    }
  }
]

When testing this I also found bug in catalog list command. When using --fields flag in "human" output style it doesn't show extra fields is specified and KIND and TYPE are there empty if I don't specify them in --fields

rhdh-cli catalog list --kind Component --fields '["metadata.name","metadata.description"]'
NAME                                     KIND             NAMESPACE        TYPE
rhdh                                                      default

in json output it is fine

❯ rhdh-cli catalog list --kind Component --fields '["metadata.name","metadata.description"]'  --output json
{
  "items": [
    {
      "metadata": {
        "name": "rhdh",
        "description": "Red Hat Developer Hub is an enterprise-grade Internal Developer Portal based on Backstage."
      }
    }
  ],
  "totalItems": 1,
  "hasMoreEntities": false
}

To summarize this:
I don't disagree that there is a value in new intent based CLI commands, but if they meant to provide good UX they need a lot more work.
Splitting it into multiple PRs would be a better approach.
First, we introduce wrapped actions command. This already provides all that is needed for agentic use. After that we work on commands for humans, where we design it in a way that actually creates nice UX.

@yangcao77

Copy link
Copy Markdown
Author

@kadel that sounds reasonable.
I've created a new PR for wrapping the auth & actions commands: #167

I will leave this branch & PR continue working on the intent clis for UX improvement based on your review comments.

Signed-off-by: Stephanie <yangcao@redhat.com>
Signed-off-by: Stephanie <yangcao@redhat.com>
@sonarqubecloud

Copy link
Copy Markdown

@yangcao77

Copy link
Copy Markdown
Author

@kadel so I pushed a new commit for your suggested UX improvement on the input format, so they no longer require raw JSON:

here is the summary:

Summary

1.template execute --values

Before:

rhdh-cli template execute --template-ref template:default/register-component \
  --values '{"githubHost":"github.com","githubOrganization":"foo","repositoryName":"bar","componentOwner":"user:default/default","componentType":"service","componentLifecycle":"production"}'

Now:

rhdh-cli template execute --template-ref template:default/register-component \
  --value githubHost=github.com \
  --value githubOrganization=foo \
  --value repositoryName=bar \
  --value componentOwner=user:default/default \
  --value componentType=service \
  --value componentLifecycle=production

2. search --filters

Before:

rhdh-cli search "rhdh" --filters '{"kind":"Component"}'

Now:

rhdh-cli search "rhdh" --filter kind=Component

3. catalog list --fields, and also the filtered output

Before:

rhdh-cli catalog list --kind Component --fields '["metadata.name","metadata.description"]'
# human output ignored --fields entirely:
NAME    KIND    NAMESPACE    TYPE
rhdh                         default

After:

rhdh-cli catalog list --kind Component --fields metadata.name,metadata.description
# human output now actually reflects the request:
NAME    DESCRIPTION
rhdh    Developer Hub

4. catalog list --filter, key=value, merged with --kind/--type, JSON kept as --filters

Before:

--filters '{"metadata.namespace":"default", "spec.lifecycle": "production", "kind": "Component"}'

Now:

rhdh-cli catalog list \
  --kind Component \
  --filter spec.lifecycle=production \
  --filter metadata.namespace=default

merged on top of whatever --kind/--type already built, with --filters <json> as the escape hatch for anything not expressible as flat equality. --filter is now consistently the simple form and --filters (plural) the raw-JSON fallback, matching the convention used in search.

5. search --types

Before:

--types '["techdocs","software-catalog"]'

Now:

--types techdocs,software-catalog

6. template execute / dry-run --secret — key=value alongside --secrets

Before:

--secrets '{"token":"abc"}'

Now:

--secret token=abc` (repeatable),

`--secrets <json>` kept as fallback.

7. catalog validate --entity-file <path>

--entity <yaml> still works, but you can now do --entity-file ./catalog-info.yaml and it reads the file instead of requiring --entity "$(cat entity.yaml)".

@yangcao77

yangcao77 commented Aug 27, 2026

Copy link
Copy Markdown
Author

@kadel Re showing the template params and also the user-friendly error, as long as the bug https://redhat.atlassian.net/browse/RHDHBUGS-3698 you created. I would like to use follow up PR to address those issue.

here is a analysis from claude

Problem A — No easy way to see a template's inputs

Today the only way to discover what --value flags a template needs is:

rhdh-cli catalog get --name register-component --kind Template --output json | jq '.spec.parameters'

i.e. fetch the whole entity and hand-parse nested JSON.

What to build: a new read-only subcommand, e.g. template params (or template show).

  • Data source: wraps catalog:get-catalog-entity (kind Template), reads spec.parameters.
  • Key wrinkle: spec.parameters comes in two shapes — a single object ({required, properties}) or an array of
    "step" objects (register-component has 2 steps/pages). The renderer must normalize both.
  • Human output: one row per input — NAME · REQUIRED · TYPE · DEFAULT · DESCRIPTION, grouped by step title.
    Enum/well-known values shown where present. Directly tells the user which --value key=value flags to pass.
  • JSON output: pass through the normalized parameters.
  • New code: a subcommand in template.ts + a formatTemplateParams() formatter in format.ts (unit-testable — TDD
    lands here).

Decision needed: command name (params / show / describe) and input flag — reuse --template-ref (consistent with
execute) vs --name/--kind (consistent with catalog get). Leaning template show --template-ref ….

Problem B — Validation errors are an unreadable JSON dump

template execute with missing required inputs currently prints the raw scaffolder 400 payload — a multi-hundred-character
{"errors":[…full schema…]} blob.

What to build: parse that payload into a friendly message.

  • Where: the payload is one JSON object embedded after ... 400 Bad Request {…}. Extract the trailing {...},
    JSON.parse, read errors[].

  • Nice bonus: each error object already embeds the field's schema (title + description), so we can render friendly
    labels without a second fetch:

    Error: Template input validation failed
    
    Missing required inputs:
      - componentOwner (Owner) — Select an owner from a Group or User
      - componentType (Type)
      - componentLifecycle (Lifecycle)
    
    Try: rhdh-cli template show --template-ref template:default/register-component
    
  • New code: a helper (shared by execute and dry-run) that detects & formats the {errors:[]} shape, with a
    fallback to the current handleCommandError when parsing fails. Best placed in intent-errors.ts or a small
    scaffolder-errors.ts.

  • Risk: parsing an error string is brittle (depends on backstage-cli output format). Mitigated by: always falling back
    to the raw message, and testing against the real captured payload from the PR comment.

How they connect

They pair naturally: the friendly error in B points the user at the command from A. Build A first so B can reference
it.

Effort / risk / sequencing

Item Effort Risk Notes
A: template show Medium Low Read-only; formatter is TDD-friendly
B: friendly errors Medium Medium Brittle string parsing → needs real-payload tests + fallback

PR recommendation: clean second PR — also satisfies "split into multiple PRs" point

@yangcao77
yangcao77 requested a review from kadel August 27, 2026 18:38
@yangcao77

Copy link
Copy Markdown
Author

@kadel PTAL

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