Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 84 additions & 5 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/TencentCloudAgentRuntime/ags-cli/internal/config"
"github.com/TencentCloudAgentRuntime/ags-cli/internal/output"
"github.com/TencentCloudAgentRuntime/ags-cli/internal/updatecheck"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -178,7 +179,7 @@ func Execute() {
initConfig()
rootCmd.SetHelpCommand(newHelpCommand())
if err := explicitHelpTopicError(os.Args[1:]); err != nil {
renderExecuteError(rootCmd, err)
renderExecuteError(rootCmd, err, nil)
}

if hasRawFlag("--help") || hasRawFlag("-h") {
Expand All @@ -197,7 +198,7 @@ func Execute() {
if cmdID != "" {
for _, s := range getAllSchemas() {
if s.Name == cmdID && !s.SupportsJson {
renderExecuteError(targetCmd, output.NewUsageError("INVALID_USAGE", fmt.Sprintf("%s does not support -o json", cmdID), "Use text help for this command, or run 'agr schema -o json' for machine-readable command metadata."))
renderExecuteError(targetCmd, output.NewUsageError("INVALID_USAGE", fmt.Sprintf("%s does not support -o json", cmdID), "Use text help for this command, or run 'agr schema -o json' for machine-readable command metadata."), nil)
}
}
}
Expand All @@ -208,15 +209,75 @@ func Execute() {
schemaErr = renderJSONSchemaEnvelope("help", rootCmd, []string{cmdID})
}
if schemaErr != nil {
renderExecuteError(targetCmd, schemaErr)
renderExecuteError(targetCmd, schemaErr, nil)
}
return
}
}

// Fire off non-blocking background update check (skip for version/update
// commands, non-interactive mode, and JSON output).
var updateCh <-chan *updatecheck.Result
if shouldRunUpdateCheck(os.Args[1:]) {
updateCh = updatecheck.Start(Version)
}

if cmd, err := rootCmd.ExecuteC(); err != nil {
renderExecuteError(cmd, err)
renderExecuteError(cmd, err, updateCh)
}

// Print update notice after command finishes (never blocks the command).
// Note: renderExecuteError calls os.Exit directly on the error path, so it
// prints the notice itself before exiting — this line covers the success
// path only.
if updateCh != nil && !nonInteractive && !isJSON() {
updatecheck.PrintNotice(ios.ErrOut, updateCh)
}
}

// shouldRunUpdateCheck returns false for commands that should not trigger
// a background update check (to avoid infinite loops or noisy output).
// It strips leading global flags (--config, -o, etc.) to find the real
// subcommand token.
func shouldRunUpdateCheck(args []string) bool {
if nonInteractive {
return false
}
// Also skip when JSON output is requested (the notice would never print
// anyway, but skipping the goroutine avoids unnecessary network I/O).
if hasRawOutputFlag("json") {
return false
}
// Find the first positional token (skip flags and their values).
cmd := extractCommandToken(args)
switch cmd {
case "version", "help", "update":
return false
}
// When no subcommand is found (cmd == ""), the user may have passed
// --version/-v/--help/-h which extractCommandToken skips as flags.
// Only scan for these flags when there's no positional subcommand to
// avoid false positives from subcommand arguments that happen to match.
if cmd == "" {
for _, arg := range args {
switch arg {
case "--version", "-v", "--help", "-h":
return false
}
}
}
return true
}

// extractCommandToken walks args skipping known global flags and their values
// to find the first positional argument (the subcommand name). It reuses
// extractCommandTokens for consistency.
func extractCommandToken(args []string) string {
tokens := extractCommandTokens(args)
if len(tokens) > 0 {
return tokens[0]
}
return ""
}

func explicitHelpTopicError(args []string) error {
Expand Down Expand Up @@ -302,9 +363,10 @@ func commandSpecificUsageHint(cmd *cobra.Command, err error) string {
return ""
}

func renderExecuteError(cmd *cobra.Command, err error) {
func renderExecuteError(cmd *cobra.Command, err error, updateCh <-chan *updatecheck.Result) {
var envDone *envelopeAlreadyWritten
if errors.As(err, &envDone) {
printUpdateNotice(updateCh)
os.Exit(envDone.code)
}
cliErr := classifyCLIError(err)
Expand All @@ -325,15 +387,32 @@ func renderExecuteError(cmd *cobra.Command, err error) {
jqFailure := output.NewUsageError("INVALID_JQ_EXPRESSION", jqErr.Error(), "Check your --jq expression syntax.")
jqEnv := output.NewFailedEnvelope(cmdID, jqFailure.Failure, config.GetBackend(), 0)
_ = output.RenderEnvelopeToStdout(jqEnv)
// JSON mode: notice is suppressed by printUpdateNotice's isJSON() check.
printUpdateNotice(updateCh)
os.Exit(output.ExitUsage)
}
// JSON mode: notice is suppressed by printUpdateNotice's isJSON() check.
printUpdateNotice(updateCh)
os.Exit(cliErr.ExitCode)
}
failure := withIdempotencyHint(commandIDForJSONError(cmd, os.Args[1:]), cliErr.Failure)
writeFailureText(ios.ErrOut, failure)
printUpdateNotice(updateCh)
os.Exit(cliErr.ExitCode)
}

// printUpdateNotice prints the background update notice (if any) before the
// process exits via os.Exit, which would otherwise skip the deferred print in
// Execute(). It honors the same suppression rules (non-interactive, JSON) and
// is a no-op when updateCh is nil (e.g. the pre-command help/config error paths
// that run before the background check is started).
func printUpdateNotice(updateCh <-chan *updatecheck.Result) {
if updateCh == nil || nonInteractive || isJSON() {
return
}
updatecheck.PrintNotice(ios.ErrOut, updateCh)
}

// writeFailureText renders a Failure to a human-readable, line-per-field
// stderr block. Code/Message/RequestId are surfaced uniformly so all three
// service-side identifiers needed for a TencentCloud support handoff appear
Expand Down
63 changes: 63 additions & 0 deletions internal/cli/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package cli

import (
"fmt"
"io"

"github.com/TencentCloudAgentRuntime/ags-cli/internal/output"
"github.com/TencentCloudAgentRuntime/ags-cli/internal/updatecheck"
"github.com/spf13/cobra"
)

var updateCmd = &cobra.Command{
Use: "update",
Short: "Check for CLI updates",
Long: `Check whether a newer version of AGR CLI is available.

By default, checks the remote version and reports current vs latest.
Use --check to perform an explicit check (same as running without arguments).`,
Example: exampleBlocks("agr update", "agr update --check", "agr update -o json"),
}

func init() {
updateCmd.Flags().Bool("check", false, "Explicitly check for updates (default behavior)")
updateCmd.RunE = Wrap("update", updateFn)
rootCmd.AddCommand(updateCmd)
}

func updateFn(cmd *cobra.Command, args []string) (*CmdResult, error) {
latest, err := updatecheck.FetchLatestVersion()
if err != nil {
return nil, output.NewCLIError(&output.Failure{
Code: "UPDATE_CHECK_FAILED",
Kind: output.KindGenericError,
Message: fmt.Sprintf("failed to check for updates: %v", err),
Hint: "Check your network connection and try again.",
})
}

version, _, _ := resolvedVersionInfo()
cmp := updatecheck.CompareVersions(version, latest)

data := map[string]any{
"current": version,
"latest": latest,
"update_available": cmp > 0,
}
if cmp > 0 {
data["install_command"] = fmt.Sprintf("curl -fsSL %s | sh", updatecheck.InstallURL)
}

return OK(data, func(w io.Writer) {
fmt.Fprintf(w, "Current: %s\n", version)
fmt.Fprintf(w, "Latest: %s\n", latest)
if cmp > 0 {
fmt.Fprintf(w, "\nUpdate available: %s → %s\n", version, latest)
fmt.Fprintf(w, "Run: curl -fsSL %s | sh\n", updatecheck.InstallURL)
} else if cmp == 0 {
fmt.Fprintf(w, "\nYou are up to date.\n")
} else {
fmt.Fprintf(w, "\nYour version is newer than the latest release.\n")
}
}), nil
}
65 changes: 65 additions & 0 deletions internal/cli/update_check_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package cli

import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("extractCommandToken", func() {
DescribeTable("extracts the first positional subcommand",
func(args []string, expected string) {
Expect(extractCommandToken(args)).To(Equal(expected))
},
Entry("simple subcommand", []string{"instance", "list"}, "instance"),
Entry("flags before subcommand", []string{"--config", "foo.toml", "update"}, "update"),
Entry("-o flag before subcommand", []string{"-o", "json", "status"}, "status"),
Entry("--output=json before subcommand", []string{"--output=json", "status"}, "status"),
Entry("-ojson shorthand before subcommand", []string{"-ojson", "status"}, "status"),
Entry("boolean flags before subcommand", []string{"--debug", "--no-color", "tool"}, "tool"),
Entry("--version flag only", []string{"--version"}, ""),
Entry("-v flag only", []string{"-v"}, ""),
Entry("--help flag only", []string{"--help"}, ""),
Entry("empty args", []string{}, ""),
Entry("multiple valued flags", []string{"--region", "ap-guangzhou", "--secret-id", "xxx", "instance"}, "instance"),
Entry("-- stops parsing", []string{"--", "instance"}, ""),
Entry("--config=value format", []string{"--config=custom.toml", "tool"}, "tool"),
)
})

var _ = Describe("shouldRunUpdateCheck", func() {
// Save and restore global state that shouldRunUpdateCheck reads.
var savedNonInteractive bool

BeforeEach(func() {
savedNonInteractive = nonInteractive
nonInteractive = false
})
AfterEach(func() {
nonInteractive = savedNonInteractive
})

DescribeTable("determines whether to run background update check",
func(args []string, expected bool) {
Expect(shouldRunUpdateCheck(args)).To(Equal(expected))
},
Entry("normal command", []string{"instance", "list"}, true),
Entry("bare agr (no subcommand)", []string{}, true),
Entry("update command skipped", []string{"update"}, false),
Entry("version command skipped", []string{"version"}, false),
Entry("help command skipped", []string{"help"}, false),
Entry("--version flag skipped", []string{"--version"}, false),
Entry("-v flag skipped", []string{"-v"}, false),
Entry("--help flag skipped", []string{"--help"}, false),
Entry("-h flag skipped", []string{"-h"}, false),
Entry("--config before version", []string{"--config", "c.toml", "version"}, false),
Entry("--config before normal cmd", []string{"--config", "c.toml", "tool"}, true),
Entry("-v among other flags (no subcommand)", []string{"--debug", "-v"}, false),
Entry("--version after --config (no subcommand)", []string{"--config", "c.toml", "--version"}, false),
Entry("-v after subcommand is not misinterpreted", []string{"tool", "create", "-v"}, true),
)

It("returns false when nonInteractive is set", func() {
nonInteractive = true
Expect(shouldRunUpdateCheck([]string{"instance", "list"})).To(BeFalse())
})
})
Loading