diff --git a/cmd/context/context.go b/cmd/context/context.go index 39cfb1d06..0afc97762 100644 --- a/cmd/context/context.go +++ b/cmd/context/context.go @@ -9,7 +9,7 @@ import ( func NewContextCmd(flags *flags.GlobalFlags) *cobra.Command { contextCmd := &cobra.Command{ Use: "context", - Short: "Devsy Context commands", + Short: "Manage contexts", } contextCmd.AddCommand(NewCreateCmd(flags)) diff --git a/cmd/env/env.go b/cmd/env/env.go index bfd24811b..7d6389bb9 100644 --- a/cmd/env/env.go +++ b/cmd/env/env.go @@ -10,7 +10,7 @@ import ( func NewEnvCmd(globalFlags *flags.GlobalFlags) *cobra.Command { envCmd := &cobra.Command{ Use: "env", - Short: "Devsy managed environment variables", + Short: "Manage environment variables", Long: `Manage named environment variables stored per context and injected into workspaces. Values are stored in plaintext in the Devsy config directory; use "devsy secret" for sensitive values.`, diff --git a/cmd/env_flags_test.go b/cmd/env_flags_test.go index ca293f62f..daf7ba61c 100644 --- a/cmd/env_flags_test.go +++ b/cmd/env_flags_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + pkgflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -68,7 +69,12 @@ func TestOptInEnvFlags_AppliesEnvValueToFlag(t *testing.T) { require.NotNil(t, f, "flag --%s not found on %q", tc.flagName, tc.cmdPath) assert.Equal(t, tc.want, f.Value.String()) assert.True(t, f.Changed, "Changed must be true so MarkFlagRequired passes") - assert.Contains(t, f.Usage, tc.envName, "usage should advertise env var") + assert.Equal( + t, + []string{tc.envName}, + f.Annotations[pkgflags.EnvAnnotation], + "env var should be annotated for the help renderer", + ) }) } } diff --git a/cmd/ide/ide.go b/cmd/ide/ide.go index bcfb39150..3b6309646 100644 --- a/cmd/ide/ide.go +++ b/cmd/ide/ide.go @@ -10,7 +10,7 @@ import ( func NewIDECmd(flags *flags.GlobalFlags) *cobra.Command { ideCmd := &cobra.Command{ Use: "ide", - Short: "Devsy IDE commands", + Short: "Manage IDE preferences", } ideCmd.AddCommand(NewUseCmd(flags)) diff --git a/cmd/machine/machine.go b/cmd/machine/machine.go index 172448ef8..3bebdab71 100644 --- a/cmd/machine/machine.go +++ b/cmd/machine/machine.go @@ -9,7 +9,7 @@ import ( func NewMachineCmd(flags *flags.GlobalFlags) *cobra.Command { machineCmd := &cobra.Command{ Use: "machine", - Short: "Devsy Machine commands", + Short: "Manage provider-hosted machines", } machineCmd.AddCommand(NewListCmd(flags)) diff --git a/cmd/mcp/mcp.go b/cmd/mcp/mcp.go index d1629410e..c00fb2b1d 100644 --- a/cmd/mcp/mcp.go +++ b/cmd/mcp/mcp.go @@ -9,7 +9,7 @@ import ( func NewMCPCmd(globalFlags *flags.GlobalFlags) *cobra.Command { cmd := &cobra.Command{ Use: "mcp", - Short: "Run Devsy as a Model Context Protocol server", + Short: "Run as a Model Context Protocol server", } cmd.AddCommand(NewServeCmd(globalFlags)) return cmd diff --git a/cmd/provider/provider.go b/cmd/provider/provider.go index 0dfda7145..7b268bb33 100644 --- a/cmd/provider/provider.go +++ b/cmd/provider/provider.go @@ -9,7 +9,7 @@ import ( func NewProviderCmd(flags *flags.GlobalFlags) *cobra.Command { providerCmd := &cobra.Command{ Use: "provider", - Short: "Devsy Provider commands", + Short: "Manage providers", } providerCmd.AddCommand(NewAddCmd(flags)) diff --git a/cmd/root.go b/cmd/root.go index 7a166abda..96f1854f7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,10 +23,11 @@ import ( "github.com/devsy-org/devsy/cmd/pro" "github.com/devsy-org/devsy/cmd/provider" secretscmd "github.com/devsy-org/devsy/cmd/secrets" - "github.com/devsy-org/devsy/cmd/self" "github.com/devsy-org/devsy/cmd/template" + "github.com/devsy-org/devsy/cmd/update" wsCmdPkg "github.com/devsy-org/devsy/cmd/workspace" "github.com/devsy-org/devsy/pkg/clierr" + "github.com/devsy-org/devsy/pkg/clihelp" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/exitcode" "github.com/devsy-org/devsy/pkg/flags/names" @@ -50,18 +51,27 @@ const ( flagLogOutput = "--" + names.LogOutput flagLogFormat = "--" + names.LogFormat - groupCore = "core" - groupConfig = "config" - groupPlatform = "platform" - groupDevcontainer = "devcontainer" - groupMeta = "meta" - // envProEnabled gates registration of the `pro` command tree. The pro // feature is not ready for general use; set DEVSY_PRO_ENABLED=true to // expose it (e.g. for internal testing). envProEnabled = "DEVSY_PRO_ENABLED" internalCommand = "internal" + + rootLong = "Devsy — standardized development workspaces built on devcontainers, " + + "running on Docker, Kubernetes, cloud providers, and SSH remote hosts." + + rootExample = `- Start a workspace from the current directory: + + $ devsy workspace up . + +- Open an SSH session to a workspace: + + $ devsy workspace ssh my-workspace + +- Configure a provider: + + $ devsy provider add docker` ) func proEnabled() bool { @@ -250,13 +260,17 @@ func renderCLIError(cliErr *clierr.CLIError, machineMode bool) { // without reaching for package-level mutable state. func BuildRoot() (*cobra.Command, *flags.GlobalFlags) { rootCmd := &cobra.Command{ - Use: config.BinaryName, - Short: "Devsy", - Version: version.GetVersion(), + Use: config.BinaryName, + Short: "Devsy", + Long: rootLong, + Example: rootExample, + Version: version.GetVersion(), + SilenceUsage: true, SilenceErrors: true, } rootCmd.SetVersionTemplate("{{.Version}}\n") + clihelp.Install(rootCmd) persistentFlags := rootCmd.PersistentFlags() globalFlags := flags.SetGlobalFlags(persistentFlags) _ = completion.RegisterFlagCompletionFuns(rootCmd, globalFlags) @@ -293,67 +307,29 @@ func BuildRoot() (*cobra.Command, *flags.GlobalFlags) { return nil } - groups := []*cobra.Group{ - {ID: groupCore, Title: "Core commands:"}, - {ID: groupConfig, Title: "Configuration commands:"}, - {ID: groupDevcontainer, Title: "Devcontainer commands:"}, - {ID: groupMeta, Title: "Meta:"}, - } - if proEnabled() { - groups = append(groups, &cobra.Group{ID: groupPlatform, Title: "Platform commands:"}) - } - rootCmd.AddGroup(groups...) - registerSubcommands(rootCmd, globalFlags) return rootCmd, globalFlags } func registerSubcommands(rootCmd *cobra.Command, globalFlags *flags.GlobalFlags) { - providerCmd := provider.NewProviderCmd(globalFlags) - providerCmd.GroupID = groupConfig - rootCmd.AddCommand(providerCmd) - ideCmd := ide.NewIDECmd(globalFlags) - ideCmd.GroupID = groupConfig - rootCmd.AddCommand(ideCmd) - machineCmd := machine.NewMachineCmd(globalFlags) - machineCmd.GroupID = groupCore - rootCmd.AddCommand(machineCmd) - contextCmd := context.NewContextCmd(globalFlags) - contextCmd.GroupID = groupConfig - rootCmd.AddCommand(contextCmd) - secretsCmd := secretscmd.NewSecretsCmd(globalFlags) - secretsCmd.GroupID = groupConfig - rootCmd.AddCommand(secretsCmd) - envCmd := envcmd.NewEnvCmd(globalFlags) - envCmd.GroupID = groupConfig - rootCmd.AddCommand(envCmd) + rootCmd.AddCommand( + ci.NewCICmd(globalFlags), + cliconfig.NewConfigCmd(globalFlags), + context.NewContextCmd(globalFlags), + envcmd.NewEnvCmd(globalFlags), + feature.NewFeatureCmd(globalFlags), + ide.NewIDECmd(globalFlags), + machine.NewMachineCmd(globalFlags), + mcp.NewMCPCmd(globalFlags), + provider.NewProviderCmd(globalFlags), + secretscmd.NewSecretsCmd(globalFlags), + template.NewTemplateCmd(globalFlags), + update.NewUpdateCmd(), + wsCmdPkg.NewWorkspaceCmd(globalFlags), + cmdinternal.NewInternalCmd(globalFlags), + ) if proEnabled() { - proCmd := pro.NewProCmd(globalFlags) - proCmd.GroupID = groupPlatform - rootCmd.AddCommand(proCmd) + rootCmd.AddCommand(pro.NewProCmd(globalFlags)) } - wsCmd := wsCmdPkg.NewWorkspaceCmd(globalFlags) - wsCmd.GroupID = groupCore - rootCmd.AddCommand(wsCmd) - - selfCmd := self.NewSelfCmd(globalFlags) - selfCmd.GroupID = groupMeta - rootCmd.AddCommand(selfCmd) - mcpCmd := mcp.NewMCPCmd(globalFlags) - mcpCmd.GroupID = groupMeta - rootCmd.AddCommand(mcpCmd) - configCmd := cliconfig.NewConfigCmd(globalFlags) - configCmd.GroupID = groupDevcontainer - rootCmd.AddCommand(configCmd) - featureCmd := feature.NewFeatureCmd(globalFlags) - featureCmd.GroupID = groupDevcontainer - rootCmd.AddCommand(featureCmd) - templateCmd := template.NewTemplateCmd(globalFlags) - templateCmd.GroupID = groupDevcontainer - rootCmd.AddCommand(templateCmd) - ciCmd := ci.NewCICmd(globalFlags) - ciCmd.GroupID = groupDevcontainer - rootCmd.AddCommand(ciCmd) - rootCmd.AddCommand(cmdinternal.NewInternalCmd(globalFlags)) } diff --git a/cmd/secrets/secrets.go b/cmd/secrets/secrets.go index d0403f60c..0a8acf3ed 100644 --- a/cmd/secrets/secrets.go +++ b/cmd/secrets/secrets.go @@ -10,7 +10,7 @@ import ( func NewSecretsCmd(flags *flags.GlobalFlags) *cobra.Command { secretsCmd := &cobra.Command{ Use: "secret", - Short: "Devsy Secrets commands", + Short: "Manage secrets", Long: `Manage named secrets stored locally and injected into workspaces. Secret values are kept in the OS keyring (macOS Keychain, Windows Credential diff --git a/cmd/self/self.go b/cmd/self/self.go deleted file mode 100644 index 809549e53..000000000 --- a/cmd/self/self.go +++ /dev/null @@ -1,16 +0,0 @@ -package self - -import ( - "github.com/devsy-org/devsy/cmd/flags" - "github.com/spf13/cobra" -) - -// NewSelfCmd builds the 'devsy self' parent command for managing the devsy CLI itself. -func NewSelfCmd(globalFlags *flags.GlobalFlags) *cobra.Command { - cmd := &cobra.Command{ - Use: "self", - Short: "Manage the devsy CLI itself", - } - cmd.AddCommand(NewUpdateCmd()) - return cmd -} diff --git a/cmd/self/update.go b/cmd/update/update.go similarity index 88% rename from cmd/self/update.go rename to cmd/update/update.go index 7356aba50..22c5a3d11 100644 --- a/cmd/self/update.go +++ b/cmd/update/update.go @@ -1,4 +1,4 @@ -package self +package update import ( "fmt" @@ -14,7 +14,7 @@ const ( channelBeta = "beta" ) -// UpdateCmd is a struct that defines a command call for "self update". +// UpdateCmd is a struct that defines a command call for "update". type UpdateCmd struct { Version string Channel string @@ -24,9 +24,9 @@ type UpdateCmd struct { // NewUpdateCmd creates a new update command. func NewUpdateCmd() *cobra.Command { cmd := &UpdateCmd{} - selfUpdateCmd := &cobra.Command{ + updateCmd := &cobra.Command{ Use: "update", - Short: "Update the Devsy CLI to the newest version", + Short: "Update the CLI to the newest version", Args: cobra.NoArgs, PreRunE: func(_ *cobra.Command, _ []string) error { switch cmd.Channel { @@ -56,7 +56,7 @@ func NewUpdateCmd() *cobra.Command { } cliflags.Add( - selfUpdateCmd, + updateCmd, cliflags.String( &cmd.Version, names.Version, @@ -76,5 +76,5 @@ func NewUpdateCmd() *cobra.Command { "Show which version would be downloaded without actually updating", ), ) - return selfUpdateCmd + return updateCmd } diff --git a/cmd/self/update_test.go b/cmd/update/update_test.go similarity index 99% rename from cmd/self/update_test.go rename to cmd/update/update_test.go index dbeec68fb..36070501f 100644 --- a/cmd/self/update_test.go +++ b/cmd/update/update_test.go @@ -1,4 +1,4 @@ -package self +package update import ( "testing" diff --git a/e2e/tests/self/self.go b/e2e/tests/self/self.go index 743752c7e..141a63891 100644 --- a/e2e/tests/self/self.go +++ b/e2e/tests/self/self.go @@ -21,8 +21,8 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err, "getting current working directory should not error") f := framework.NewDefaultFramework(initialDir + "/bin") - output, err := f.ExecCommandOutput(ctx, []string{"self", "update", "--dry-run"}) - framework.ExpectNoError(err, "self update --dry-run should not error") + output, err := f.ExecCommandOutput(ctx, []string{"update", "--dry-run"}) + framework.ExpectNoError(err, "update --dry-run should not error") ginkgo.By("Parsing dry-run key=value output") lines := strings.Split(strings.TrimSpace(output), "\n") diff --git a/pkg/clihelp/help.go b/pkg/clihelp/help.go new file mode 100644 index 000000000..7319090a1 --- /dev/null +++ b/pkg/clihelp/help.go @@ -0,0 +1,272 @@ +// Package clihelp renders `devsy --help` output. +package clihelp + +import ( + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + "github.com/devsy-org/devsy/pkg/flags" + "github.com/devsy-org/devsy/pkg/theme" + "github.com/spf13/cobra" + pflag "github.com/spf13/pflag" + "golang.org/x/term" +) + +const ( + indent = " " + descIndent = " " + nameColumn = 12 + defaultWidth = 80 + maxWidth = 100 + minWidth = 40 +) + +// Install applies this renderer to cmd and, since cobra propagates help and +// usage functions to children, every command beneath it. +func Install(cmd *cobra.Command) { + cmd.SetHelpFunc(func(c *cobra.Command, _ []string) { + render(c, c.OutOrStdout()) + }) + cmd.SetUsageFunc(func(c *cobra.Command) error { + render(c, c.ErrOrStderr()) + return nil + }) +} + +func render(cmd *cobra.Command, out io.Writer) { + // colorprofile.NewWriter documents a nil environ as meaning os.Environ(), + // but v0.4.3 builds an empty map instead, so TERM reads as unset and every + // profile collapses to NoTTY, silently stripping all color. + w := colorprofile.NewWriter(out, os.Environ()) + width := wrapWidth(out) + + var b strings.Builder + writeIntro(&b, cmd, width) + writeUsage(&b, cmd) + writeCommands(&b, cmd, width) + writeFlags(&b, cmd, width) + writeFooter(&b, cmd) + + _, _ = io.WriteString(w, b.String()) +} + +func wrapWidth(out io.Writer) int { + f, ok := out.(interface{ Fd() uintptr }) + if !ok { + return defaultWidth + } + cols, _, err := term.GetSize(int(f.Fd())) //nolint:gosec // fd fits in int + if err != nil || cols <= 0 { + return defaultWidth + } + return clamp(cols, minWidth, maxWidth) +} + +func clamp(v, low, high int) int { + if v < low { + return low + } + if v > high { + return high + } + return v +} + +func writeIntro(b *strings.Builder, cmd *cobra.Command, width int) { + desc := cmd.Long + if desc == "" { + desc = cmd.Short + } + if desc == "" { + return + } + b.WriteString(wrapIndent(desc, width, "")) + b.WriteString("\n\n") +} + +func writeUsage(b *strings.Builder, cmd *cobra.Command) { + section(b, "USAGE") + if cmd.Runnable() { + b.WriteString(indent) + b.WriteString(cmd.UseLine()) + b.WriteString("\n") + } + if cmd.HasAvailableSubCommands() { + b.WriteString(indent) + b.WriteString(cmd.CommandPath()) + b.WriteString(" [global-flags] \n") + } + if cmd.Example != "" { + b.WriteString("\n") + b.WriteString(indentBlock(strings.TrimRight(cmd.Example, "\n"), indent)) + b.WriteString("\n") + } + b.WriteString("\n") +} + +func writeCommands(b *strings.Builder, cmd *cobra.Command, width int) { + var subs []*cobra.Command + for _, sub := range cmd.Commands() { + if sub.IsAvailableCommand() || sub.IsAdditionalHelpTopicCommand() { + subs = append(subs, sub) + } + } + if len(subs) == 0 { + return + } + + section(b, "SUBCOMMANDS") + sort.Slice(subs, func(i, j int) bool { return subs[i].Name() < subs[j].Name() }) + for _, sub := range subs { + name := sub.Name() + b.WriteString(indent) + b.WriteString(theme.Command.Render(name)) + if sub.Short == "" { + b.WriteString("\n") + continue + } + // Pad from the plain name — the styled string carries escape bytes + // that would otherwise be counted as visible columns. + b.WriteString(strings.Repeat(" ", max(1, nameColumn-len(name)))) + descCol := len(indent) + nameColumn + b.WriteString(wrapHanging(sub.Short, width, descCol)) + b.WriteString("\n") + } + b.WriteString("\n") +} + +// A command's own persistent flags apply to its subcommands just as inherited +// ones do, so both are listed as global. +func writeFlags(b *strings.Builder, cmd *cobra.Command, width int) { + persistent := cmd.PersistentFlags() + var local, global []*pflag.Flag + cmd.NonInheritedFlags().VisitAll(func(f *pflag.Flag) { + if f.Hidden { + return + } + if persistent.Lookup(f.Name) != nil { + global = append(global, f) + return + } + local = append(local, f) + }) + cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) { + if !f.Hidden { + global = append(global, f) + } + }) + + if len(local) > 0 { + section(b, "OPTIONS") + writeFlagList(b, local, width) + } + if len(global) > 0 { + section(b, "GLOBAL OPTIONS") + b.WriteString(wrapIndent( + "Global options apply to every subcommand. Each may be set by flag "+ + "or by its environment variable.", + width, indent, + )) + b.WriteString("\n\n") + writeFlagList(b, global, width) + } +} + +func writeFlagList(b *strings.Builder, list []*pflag.Flag, width int) { + sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name }) + for _, f := range list { + b.WriteString(indent) + b.WriteString(flagSignature(f)) + b.WriteString("\n") + if usage := flagUsage(f); usage != "" { + b.WriteString(wrapIndent(usage, width, descIndent)) + b.WriteString("\n") + } + b.WriteString("\n") + } +} + +func flagSignature(f *pflag.Flag) string { + var parts []string + if f.Shorthand != "" { + parts = append(parts, theme.Flag.Render("-"+f.Shorthand)) + } + name := theme.Flag.Render("--" + f.Name) + if valueType, _ := pflag.UnquoteUsage(f); valueType != "" { + name += " " + theme.Muted.Render(valueType) + } + parts = append(parts, name) + + sig := strings.Join(parts, ", ") + if env := f.Annotations[flags.EnvAnnotation]; len(env) > 0 { + sig += theme.Muted.Render(", ") + theme.EnvVar.Render("$"+env[0]) + } + if def := defaultText(f); def != "" { + sig += " " + theme.Muted.Render(def) + } + return sig +} + +// Zero-ish defaults carry no information, so they are omitted to keep +// signature lines scannable. +func defaultText(f *pflag.Flag) string { + switch f.DefValue { + case "", "false", "0", "[]", "0s": + return "" + } + return fmt.Sprintf("(default: %s)", f.DefValue) +} + +func flagUsage(f *pflag.Flag) string { + _, usage := pflag.UnquoteUsage(f) + return strings.TrimSpace(usage) +} + +func writeFooter(b *strings.Builder, cmd *cobra.Command) { + if !cmd.HasAvailableSubCommands() { + return + } + b.WriteString(theme.Muted.Render(fmt.Sprintf( + "Run %q for more information about a subcommand.", + cmd.CommandPath()+" --help", + ))) + b.WriteString("\n") +} + +func section(b *strings.Builder, title string) { + b.WriteString(theme.Heading.Render(title + ":")) + b.WriteString("\n") +} + +func wrapIndent(text string, width int, prefix string) string { + limit := max(minWidth/2, width-len(prefix)) + wrapped := ansi.Wordwrap(strings.TrimSpace(text), limit, " -") + return indentBlock(wrapped, prefix) +} + +// wrapHanging assumes the first line already begins at column col, so only +// continuation lines are padded to line up under it. +func wrapHanging(text string, width, col int) string { + limit := max(minWidth/2, width-col) + lines := strings.Split(ansi.Wordwrap(strings.TrimSpace(text), limit, " -"), "\n") + pad := strings.Repeat(" ", col) + for i := 1; i < len(lines); i++ { + lines[i] = pad + lines[i] + } + return strings.Join(lines, "\n") +} + +func indentBlock(text, prefix string) string { + lines := strings.Split(text, "\n") + for i, line := range lines { + if strings.TrimSpace(line) != "" { + lines[i] = prefix + line + } + } + return strings.Join(lines, "\n") +} diff --git a/pkg/clihelp/help_test.go b/pkg/clihelp/help_test.go new file mode 100644 index 000000000..d2791ef35 --- /dev/null +++ b/pkg/clihelp/help_test.go @@ -0,0 +1,150 @@ +package clihelp + +import ( + "bytes" + "strings" + "testing" + + pkgflags "github.com/devsy-org/devsy/pkg/flags" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestRoot(t *testing.T) *cobra.Command { + t.Helper() + root := &cobra.Command{Use: "devsy", Short: "Devsy", Long: "Devsy long description."} + + pf := root.PersistentFlags() + pf.String("provider", "", "The provider to use") + pkgflags.BindEnv(pf, "provider") + pf.Bool("hidden-global", false, "should not appear") + require.NoError(t, pf.MarkHidden("hidden-global")) + + noop := func(*cobra.Command, []string) {} + ws := &cobra.Command{Use: "workspace", Short: "Manage workspaces"} + list := &cobra.Command{Use: "list", Short: "List workspaces", Run: noop} + list.Flags().Bool("skip-pro", false, "Don't list pro workspaces") + ws.AddCommand(list) + root.AddCommand(ws) + root.AddCommand(&cobra.Command{Use: "loose", Short: "A loose command", Run: noop}) + + Install(root) + return root +} + +// A bytes.Buffer is not a terminal, so colorprofile strips styling and +// assertions can match plain text. +func renderHelp(t *testing.T, root *cobra.Command, path ...string) string { + t.Helper() + target := root + if len(path) > 0 { + found, _, err := root.Find(path) + require.NoError(t, err) + target = found + } + var buf bytes.Buffer + target.SetOut(&buf) + target.SetErr(&buf) + require.NoError(t, target.Help()) + return buf.String() +} + +func TestRender_RootSections(t *testing.T) { + out := renderHelp(t, newTestRoot(t)) + + assert.Contains(t, out, "Devsy long description.") + assert.Contains(t, out, "USAGE:") + assert.Contains(t, out, "devsy [global-flags] ") + assert.Contains(t, out, "SUBCOMMANDS:") + assert.Contains(t, out, "GLOBAL OPTIONS:") + assert.Contains(t, out, "Run \"devsy --help\"") +} + +func TestRender_SubcommandsAreFlatAndSorted(t *testing.T) { + out := renderHelp(t, newTestRoot(t)) + + body := sectionBody(t, out, "SUBCOMMANDS:") + assert.Contains(t, body, "loose") + assert.Contains(t, body, "workspace") + assert.Less(t, strings.Index(body, "loose"), strings.Index(body, "workspace")) + assert.NotContains(t, out, "CORE COMMANDS:") + assert.NotContains(t, out, "ADDITIONAL COMMANDS:") +} + +func TestRender_EnvVarOnSignatureLine(t *testing.T) { + out := renderHelp(t, newTestRoot(t)) + + require.Contains(t, out, "--provider string, $DEVSY_PROVIDER") + line := signatureLine(t, out, "--provider") + assert.NotContains(t, line, "The provider to use", "description belongs on its own line") +} + +func TestRender_HidesHiddenFlags(t *testing.T) { + out := renderHelp(t, newTestRoot(t)) + assert.NotContains(t, out, "hidden-global") +} + +func TestRender_RootPersistentFlagsAreGlobal(t *testing.T) { + out := renderHelp(t, newTestRoot(t)) + assert.Contains(t, sectionBody(t, out, "GLOBAL OPTIONS:"), "--provider") +} + +func TestRender_LeafSeparatesLocalFromGlobal(t *testing.T) { + out := renderHelp(t, newTestRoot(t), "workspace", "list") + + assert.Contains(t, out, "devsy workspace list [flags]") + assert.Contains(t, sectionBody(t, out, "OPTIONS:"), "--skip-pro") + assert.Contains(t, sectionBody(t, out, "GLOBAL OPTIONS:"), "--provider") + // A leaf has no subcommands, so the footer hint would be a dead end. + assert.NotContains(t, out, "for more information about a subcommand") +} + +func TestDefaultText_OmitsZeroValues(t *testing.T) { + root := &cobra.Command{Use: "x", Run: func(*cobra.Command, []string) {}} + fs := root.Flags() + fs.String("empty", "", "empty default") + fs.Bool("off", false, "false default") + fs.Int("zero", 0, "zero default") + fs.String("set", "text", "real default") + Install(root) + + out := renderHelp(t, root) + for _, flagName := range []string{"--empty", "--off", "--zero"} { + assert.NotContains(t, signatureLine(t, out, flagName), "(default:") + } + assert.Contains(t, signatureLine(t, out, "--set"), "(default: text)") +} + +func TestRender_NoColorWhenNotATerminal(t *testing.T) { + out := renderHelp(t, newTestRoot(t)) + assert.NotContains(t, out, "\x1b[", "styling must be stripped for non-terminal output") +} + +func signatureLine(t *testing.T, out, flagName string) string { + t.Helper() + for line := range strings.SplitSeq(out, "\n") { + if strings.Contains(line, flagName) { + return line + } + } + t.Fatalf("no line containing %q in:\n%s", flagName, out) + return "" +} + +// Section headers are the only unindented, uppercase, colon-terminated lines. +func sectionBody(t *testing.T, out, header string) string { + t.Helper() + _, rest, found := strings.Cut(out, header) + require.True(t, found, "section %q not found in:\n%s", header, out) + + var body []string + for line := range strings.SplitSeq(rest, "\n") { + if strings.HasSuffix(line, ":") && line == strings.ToUpper(line) && + !strings.HasPrefix(line, " ") && strings.TrimSpace(line) != "" { + break + } + body = append(body, line) + } + return strings.Join(body, "\n") +} diff --git a/pkg/flags/env.go b/pkg/flags/env.go index 9d8c7bf90..27da2062b 100644 --- a/pkg/flags/env.go +++ b/pkg/flags/env.go @@ -18,10 +18,14 @@ func EnvName(flagName string) string { return config.EnvPrefix + strings.ToUpper(strings.ReplaceAll(flagName, "-", "_")) } +// EnvAnnotation keys the flag's env var name for the help renderer, which shows +// it beside the flag rather than buried in the description. +const EnvAnnotation = "devsy_env" + // BindEnv wires the canonical DEVSY_* env var into a cobra flag. Call it // immediately after the flag's *Var registration. If the env var is set, the // value is applied to the flag (Changed=true, satisfying MarkFlagRequired) and -// the usage string advertises the env var. +// the env var is recorded under [EnvAnnotation] for the help renderer. // // Failure modes are loud by design: // - panics if the flag is not registered (programmer bug at startup); @@ -35,13 +39,10 @@ func BindEnv(fs *pflag.FlagSet, flagName string) { panic("flags.BindEnv: flag --" + flagName + " is not registered") } envName := EnvName(flagName) - // "[$DEVSY_FOO]" — $VAR convention, bracket avoids clashing with - // parentheses inside the existing description. - suffix := " [$" + envName + "]" - desc := strings.TrimRight(f.Usage, " .") - if !strings.HasSuffix(desc, suffix) { - f.Usage = desc + suffix + if f.Annotations == nil { + f.Annotations = map[string][]string{} } + f.Annotations[EnvAnnotation] = []string{envName} value, ok := os.LookupEnv(envName) if !ok || value == "" { return diff --git a/pkg/theme/theme.go b/pkg/theme/theme.go new file mode 100644 index 000000000..2cf11e863 --- /dev/null +++ b/pkg/theme/theme.go @@ -0,0 +1,14 @@ +// Package theme holds the Devsy CLI color palette and text styles. +package theme + +import "charm.land/lipgloss/v2" + +var Accent = lipgloss.Color("#7C5CFF") + +var ( + Heading = lipgloss.NewStyle().Bold(true) + Flag = lipgloss.NewStyle().Foreground(Accent).Bold(true) + EnvVar = lipgloss.NewStyle().Foreground(Accent) + Command = lipgloss.NewStyle().Bold(true) + Muted = lipgloss.NewStyle().Faint(true) +)