Skip to content

Optimize CLI for AI agents in non-interactive mode#118

Merged
joetannenbaum merged 29 commits intomainfrom
non-interactive
Apr 3, 2026
Merged

Optimize CLI for AI agents in non-interactive mode#118
joetannenbaum merged 29 commits intomainfrom
non-interactive

Conversation

@joetannenbaum
Copy link
Copy Markdown
Collaborator

@joetannenbaum joetannenbaum commented Apr 2, 2026

Summary

  • Fix silent destructive actions: confirm() in Laravel Prompts defaults to true when STDIN is not a TTY, causing instance:delete, database-cluster:delete, and background-process:delete to silently proceed without confirmation in non-interactive mode
  • Error output to stderr: Resolver and BaseCommand error JSON now writes to stderr instead of stdout, with a consistent {"error": true, "message": "..."} schema so agents can cleanly separate errors from valid responses
  • Add --json to deploy and all delete commands: 10 delete commands and the deploy command were missing --json flags, producing no output for agents
  • Richer resolver errors: ensureInteractive() now includes available options as structured JSON data, and failAndExit() messages include actionable hints (e.g. "Run cloud application:list --json")
  • Deploy improvements: Emit "initiated" JSON event before monitoring, structured failure JSON with recovery hints, and --no-wait flag to initiate and return immediately
  • Guard completions install: Prevent completions command from silently creating directories/files in non-interactive mode
  • Consistent validation error schema: ValidationErrors::toJson() now matches the standard error format

joetannenbaum and others added 29 commits April 2, 2026 16:40
confirm() defaults to true in Laravel Prompts when STDIN is not a TTY.
Without `default: false`, instance:delete would proceed with deletion
in non-interactive mode even without --force.
…terns

The $dontConfirm pattern required both --force AND a positional argument,
meaning --force alone (with resolver) still triggered confirm() which
silently returns true in non-interactive mode. Simplified to the standard
delete confirmation pattern with default: false.
Agents parsing stdout for valid JSON responses would confuse error
messages with success output. Errors now go to stderr and include
an 'error' key for easy identification.
Keeps stdout clean for valid JSON responses. Error output now includes
an 'error' key for easy identification by agents.
The command already had JSON output logic but no explicit --json flag,
forcing agents to use --no-interaction instead.
New BaseCommand::writeJsonIfWanted() writes JSON to stdout without
exiting, unlike outputJsonIfWanted. Used in deploy to emit an initial
'initiated' JSON line with the deployment_id before the monitoring
loop starts.
Added a data array parameter to ensureInteractive() that gets merged
into the JSON error output. All resolvers now pass their available
options, so agents get clean structured JSON with an 'options' key
instead of having to make a separate list call.
10 delete commands were missing --json and produced no output in
non-interactive mode. Now all delete commands support --json and
output a confirmation message on success.
Non-interactive agents now get a JSON object with deployment_id,
status, failure_reason, and a hint about recovery commands instead
of just an error message.
ValidationErrors::toJson() now includes error and message keys matching
the schema used by all other error output. Also writes to stderr instead
of stdout in breakValidationLoopIfNonInteractive.
Allows agents to initiate a deployment and return immediately with
the deployment ID. They can then poll with deployment:get --json.
All resolver failAndExit messages now tell agents which list command
to run to discover available resources.
confirm() calls would silently create directories and write files in
non-interactive mode. Now bails early with a hint to use --print.
OutputStyle::getErrorOutput() is protected and not callable from
outside the class. Use fwrite(STDERR) consistently with the resolvers.
The command previously funneled entirely into an interactive keyboard-
driven prompt, making it useless for agents. Now polls and outputs
JSON status lines in non-interactive mode.
The agent-detector package returns 'cursor' not 'cursor-cli' for
the Cursor enum value.
When not authenticated, password() silently returns empty in non-
interactive mode. When multiple tokens exist, select() picks the
default silently. Now throws with actionable error messages instead.
HasAClient is used by both BaseCommand and RequiresAuthToken middleware.
The middleware doesn't have isInteractive(), so use stream_isatty(STDIN)
directly to detect non-interactive mode.
stream_isatty alone misses agent detection and CI environment
variables. Now also checks isNonInteractiveEnvironment() which
covers agents, GitHub Actions, GitLab CI, Jenkins, etc.
@joetannenbaum joetannenbaum merged commit a9e55a6 into main Apr 3, 2026
3 checks passed
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.

1 participant