diff --git a/.cursor/skills/create-ticloud-cli-command/LICENSE.txt b/.cursor/skills/create-ticloud-cli-command/LICENSE.txt new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/.cursor/skills/create-ticloud-cli-command/SKILL.md b/.cursor/skills/create-ticloud-cli-command/SKILL.md new file mode 100644 index 00000000..5d4e2fc2 --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/SKILL.md @@ -0,0 +1,134 @@ +--- +name: create-ticloud-cli-command +description: Builds TiDB Cloud CLI commands. Use when creating new commands. +license: Complete terms in LICENSE.txt +metadata: + author: shiyuhang0 +--- + +# Create TiCloud CLI Command + +This skill builds TiDB Cloud CLI commands, helping users create new commands with production-ready code. + +## When to use + +- The user asks to create new CLI commands or subcommands. +- Do not use when the agent does not support plan mode. + +## About TiDB Cloud CLI + +TiDB Cloud CLI (TiCloud CLI) is a command-line interface for interacting with TiDB Cloud, built on the Cobra library. + +Key design of TiDB Cloud CLI: +- Built on the Cobra library. +- Uses the TiDB Cloud Open API as the client and keeps the SDK inside the project. +- Every command supports both interactive and non-interactive modes. + +- More about TiDB Cloud CLI: `references/tidbcloud-cli.md`. +- More about Cobra: `references/cobra.md`. + +## Core Principles + +### Plan first + +The AI agent must switch to plan mode to collect information from the user and generate a plan first. + +Then use agent mode to generate the code. + +### Test first + +If the user requires testing, testing takes precedence over implementation. Write tests first, then implement, and finally validate against the tests. + +### Declarative + +The user must declare the command format; the implementation must comply with that declared format. + +## Workflow + +Must follow the workflow below: + +### Generate SDK phase + +Always prompt the user: "Do you need to add or update swagger? Please provide the swagger path if you need." + +Skip this phase if user does not need. + +Once in this phase, follow the guide in `references/sdk.md` to generate the SDK. + +After SDK is generated, ask user to use go>=1.24 to run `make generate-mocks` manually! + +### Plan phase + +The agent must switch to plan mode in this phase. + +The agent must ask the user for the following information during the plan phase: +1. **Command format**: If the user does not provide the command format, ask them to provide it first. See `assets/command.md` for the command format template. The commnad flags can be omitted as it can be inferred from swagger. +2. **Tests**: Ask whether the user needs tests. Tests are recommended. +3. **Other necessary information** + +Generate the plan after the user confirms all the information. + +### Agent phase + +The agent must switch to agent mode in this phase. Follow the workflow below: + +1. **Ensure the generate SDK phase** +2. **Write unit tests** +3. **Implement command** +4. **Run tests** + +#### Ensure the sdk + +Ensure generate SDK phase has been done. + +#### Write unit tests + +Skip this step if the user does not need tests. + +Write unit tests following `references/ut.md`. If the tests need to invoke the command, create the empty command first. See `assets/empty.go`. + +#### Implement command + +Implement the command following the patterns in `assets/example`. + +The implementation must meet the following requirements: + +1. **Command definition**: Include name, aliases, example, and other necessary attributes. + +2. **Flags definition**: Include flag type, description, default value, etc. Infer flag information from the Swagger spec and SDK client parameters. See `references/flag.md` for more details. Please get customer confirmation once the flags are set. + +3. **Flags retrieval**: Implement retrieval logic for both interactive and non-interactive modes. For interactive mode, UI is required. The UI is based on the Bubbletea library. See `references/ui.md` for more details. + +4. **Dual mode support**: Support both interactive and non-interactive modes. Rules: + 1. **Detect mode** with `MarkInteractive`: + - Default to interactive. + - If any non-interactive flag is provided, switch to non-interactive and mark required flags. + 2. **Interactive constraints**: + - If `!h.IOStreams.CanPrompt`, return an error instructing the user to use non-interactive flags. + +5. **SDK calls**: Use the SDK client in `api_client.go` to call the corresponding method. Note that `assets/example` does not include this part (it is only a template); it must be included when implementing the actual command. + +#### Run the tests + +Skip this step if the user does not need tests. + +Run tests with: `go test -race -cover -count=1 path -v` + +For example, to run tests under `internal/cli/serverless/branch`: `go test -race -cover -count=1 ./internal/cli/serverless/branch -v`. + +Ensure all tests pass. If they do not pass, fix the implementation or the tests. + +## Final Code Structure + +The final code structure must follow the structure in `assets/example`: + +- Command Go files. +- UI Go files. +- Test files if needed. + +Command output should follow the patterns in `assets/example`: + +- **Human output**: Print a concise, user-friendly message; use `color.GreenString` for success. Avoid raw JSON in human mode. +- **JSON output**: Use `output.PrintJson` with a stable, predictable payload (e.g. keys like `message`, resource IDs, `items`). +- **No TTY**: Default to JSON output for list/describe-style commands (see `assets/example/list.go`). +- **Examples**: Include `Example` blocks for both interactive and non-interactive usage (see `assets/example/create.go` and `assets/example/list.go`). \ No newline at end of file diff --git a/.cursor/skills/create-ticloud-cli-command/assets/command.md b/.cursor/skills/create-ticloud-cli-command/assets/command.md new file mode 100644 index 00000000..329a64bb --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/assets/command.md @@ -0,0 +1,20 @@ +# Command format + +This document describes the command format users need to provide. The following uses the example resource: + + +``` +ticloud serverless example create -c --displayname +ticloud serverless example get -c --example-id +ticloud serverless example list -c --output +ticloud serverless example delete -c --example-id --force +``` + +Flags can be omitted and inferred from the TiDB Cloud SDK. The following are also accepted: + +``` +ticloud serverless example create +ticloud serverless example get +ticloud serverless example list +ticloud serverless example delete +``` \ No newline at end of file diff --git a/.cursor/skills/create-ticloud-cli-command/assets/empty.go b/.cursor/skills/create-ticloud-cli-command/assets/empty.go new file mode 100644 index 00000000..b1ea855e --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/assets/empty.go @@ -0,0 +1,25 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example + +import ( + "github.com/spf13/cobra" + + "github.com/tidbcloud/tidbcloud-cli/internal" +) + +func CreateCmd(h *internal.Helper) *cobra.Command { + return nil +} diff --git a/.cursor/skills/create-ticloud-cli-command/assets/example/create.go b/.cursor/skills/create-ticloud-cli-command/assets/example/create.go new file mode 100644 index 00000000..6b482f51 --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/assets/example/create.go @@ -0,0 +1,162 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example + +import ( + "fmt" + + "github.com/fatih/color" + "github.com/juju/errors" + "github.com/spf13/cobra" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/config" + "github.com/tidbcloud/tidbcloud-cli/internal/flag" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" +) + +type CreateOpts struct { + interactive bool +} + +func (o CreateOpts) NonInteractiveFlags() []string { + return []string{ + flag.ClusterID, + flag.DisplayName, + } +} + +func (o CreateOpts) RequiredFlags() []string { + return []string{ + flag.ClusterID, + flag.DisplayName, + } +} + +func (o *CreateOpts) MarkInteractive(cmd *cobra.Command) error { + o.interactive = true + for _, fn := range o.NonInteractiveFlags() { + if f := cmd.Flags().Lookup(fn); f != nil && f.Changed { + o.interactive = false + } + } + if !o.interactive { + for _, fn := range o.RequiredFlags() { + if err := cmd.MarkFlagRequired(fn); err != nil { + return err + } + } + } + return nil +} + +func CreateCmd(h *internal.Helper) *cobra.Command { + opts := &CreateOpts{interactive: true} + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an example resource", + Args: cobra.NoArgs, + Example: fmt.Sprintf(` Create an example resource (interactive): + $ %[1]s serverless example create + + Create an example resource (non-interactive): + $ %[1]s serverless example create -c --display-name + + Create with multi-line input flags (non-interactive): + $ %[1]s serverless example create -c --display-name \ + --s3.uri \ + --s3.access-key-id \ + --s3.secret-access-key `, config.CliName), + PreRunE: func(cmd *cobra.Command, args []string) error { + return opts.MarkInteractive(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + d, err := h.Client() + if err != nil { + return err + } + var clusterID, displayName string + var s3URI, s3AccessKeyID, s3SecretAccessKey string + if opts.interactive { + if !h.IOStreams.CanPrompt { + return errors.New("The terminal doesn't support interactive mode, please use non-interactive mode") + } + project, err := cloud.GetSelectedProject(ctx, h.QueryPageSize, d) + if err != nil { + return err + } + cluster, err := cloud.GetSelectedCluster(ctx, project.ID, h.QueryPageSize, d) + if err != nil { + return err + } + clusterID = cluster.ID + + displayName, err = GetDisplayNameInput() + if err != nil { + return err + } + + s3URI, s3AccessKeyID, s3SecretAccessKey, err = GetS3Inputs() + if err != nil { + return err + } + } else { + var err error + clusterID, err = cmd.Flags().GetString(flag.ClusterID) + if err != nil { + return errors.Trace(err) + } + displayName, err = cmd.Flags().GetString(flag.DisplayName) + if err != nil { + return errors.Trace(err) + } + s3URI, err = cmd.Flags().GetString(flag.S3URI) + if err != nil { + return errors.Trace(err) + } + s3AccessKeyID, err = cmd.Flags().GetString(flag.S3AccessKeyID) + if err != nil { + return errors.Trace(err) + } + s3SecretAccessKey, err = cmd.Flags().GetString(flag.S3SecretAccessKey) + if err != nil { + return errors.Trace(err) + } + } + + if clusterID == "" { + return errors.New("cluster id is required") + } + if displayName == "" { + return errors.New("display name is required") + } + + // TODO implement the create logic + _, err = fmt.Fprintln(h.IOStreams.Out, color.GreenString("example %s created", "example-id")) + if err != nil { + return err + } + }, + } + + cmd.Flags().StringP(flag.ClusterID, flag.ClusterIDShort, "", "The cluster ID.") + cmd.Flags().StringP(flag.DisplayName, flag.DisplayNameShort, "", "Display name for the example resource.") + cmd.Flags().String(flag.S3URI, "", "The S3 URI.") + cmd.Flags().String(flag.S3AccessKeyID, "", "The S3 access key ID.") + cmd.Flags().String(flag.S3SecretAccessKey, "", "The S3 secret access key.") + return cmd +} diff --git a/.cursor/skills/create-ticloud-cli-command/assets/example/create_test.go b/.cursor/skills/create-ticloud-cli-command/assets/example/create_test.go new file mode 100644 index 00000000..3bccf7ff --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/assets/example/create_test.go @@ -0,0 +1,15 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example diff --git a/.cursor/skills/create-ticloud-cli-command/assets/example/delete.go b/.cursor/skills/create-ticloud-cli-command/assets/example/delete.go new file mode 100644 index 00000000..4af3135b --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/assets/example/delete.go @@ -0,0 +1,158 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example + +import ( + "fmt" + + "github.com/AlecAivazis/survey/v2" + "github.com/fatih/color" + "github.com/juju/errors" + "github.com/spf13/cobra" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/config" + "github.com/tidbcloud/tidbcloud-cli/internal/flag" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" +) + +type DeleteOpts struct { + interactive bool +} + +func (o DeleteOpts) NonInteractiveFlags() []string { + return []string{ + flag.ClusterID, + flag.ExampleID, + flag.Force, + } +} + +func (o DeleteOpts) RequiredFlags() []string { + return []string{ + flag.ClusterID, + flag.DisplayName, + } +} + +func (o *DeleteOpts) MarkInteractive(cmd *cobra.Command) error { + o.interactive = true + for _, fn := range o.NonInteractiveFlags() { + if f := cmd.Flags().Lookup(fn); f != nil && f.Changed { + o.interactive = false + } + } + if !o.interactive { + for _, fn := range o.RequiredFlags() { + if err := cmd.MarkFlagRequired(fn); err != nil { + return err + } + } + } + return nil +} + +func DeleteCmd(h *internal.Helper) *cobra.Command { + opts := &DeleteOpts{interactive: true} + var force bool + + cmd := &cobra.Command{ + Use: "delete", + Aliases: []string{"rm"}, + Short: "Delete an example resource", + Args: cobra.NoArgs, + Example: fmt.Sprintf(` Delete an example resource (interactive): + $ %[1]s serverless example delete + + Delete an example resource (non-interactive): + $ %[1]s serverless example delete -c --example-id `, config.CliName), + PreRunE: func(cmd *cobra.Command, args []string) error { + return opts.MarkInteractive(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + d, err := h.Client() + if err != nil { + return err + } + var clusterID, exampleID string + if opts.interactive { + if !h.IOStreams.CanPrompt { + return errors.New("The terminal doesn't support interactive mode, please use non-interactive mode") + } + project, err := cloud.GetSelectedProject(ctx, h.QueryPageSize, d) + if err != nil { + return err + } + cluster, err := cloud.GetSelectedCluster(ctx, project.ID, h.QueryPageSize, d) + if err != nil { + return err + } + clusterID = cluster.ID + + example, err := GetSelectedExample(ctx, clusterID, h.QueryPageSize, d) + if err != nil { + return err + } + exampleID = example.ID + } else { + var err error + clusterID, err = cmd.Flags().GetString(flag.ClusterID) + if err != nil { + return errors.Trace(err) + } + exampleID, err = cmd.Flags().GetString(flag.ExampleID) + if err != nil { + return errors.Trace(err) + } + force, err = cmd.Flags().GetBool(flag.Force) + if err != nil { + return errors.Trace(err) + } + } + + if clusterID == "" { + return errors.New("cluster id is required") + } + if exampleID == "" { + return errors.New("example id is required") + } + + if !force { + if !h.IOStreams.CanPrompt { + return errors.New("The terminal doesn't support prompt, run with --force to skip confirmation") + } + var confirm string + if err := survey.AskOne(&survey.Input{Message: DeleteConfirmPrompt}, &confirm); err != nil { + return err + } + if confirm != "yes" { + return errors.New("deletion cancelled") + } + } + + // TODO implement the create logic + _, err = fmt.Fprintln(h.IOStreams.Out, color.GreenString("example %s deleted", "example-id")) + if err != nil { + return err + } + }, + } + + cmd.Flags().StringP(flag.ClusterID, flag.ClusterIDShort, "", "The cluster ID.") + cmd.Flags().String(flag.ExampleID, "", "The example ID.") + cmd.Flags().BoolVar(&force, flag.Force, false, "Delete without confirmation.") + return cmd +} diff --git a/.cursor/skills/create-ticloud-cli-command/assets/example/delete_test.go b/.cursor/skills/create-ticloud-cli-command/assets/example/delete_test.go new file mode 100644 index 00000000..3bccf7ff --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/assets/example/delete_test.go @@ -0,0 +1,15 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example diff --git a/.cursor/skills/create-ticloud-cli-command/assets/example/descibe_test.go b/.cursor/skills/create-ticloud-cli-command/assets/example/descibe_test.go new file mode 100644 index 00000000..3bccf7ff --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/assets/example/descibe_test.go @@ -0,0 +1,15 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example diff --git a/.cursor/skills/create-ticloud-cli-command/assets/example/describe.go b/.cursor/skills/create-ticloud-cli-command/assets/example/describe.go new file mode 100644 index 00000000..44815198 --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/assets/example/describe.go @@ -0,0 +1,139 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example + +import ( + "fmt" + + "github.com/juju/errors" + "github.com/spf13/cobra" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/config" + "github.com/tidbcloud/tidbcloud-cli/internal/flag" + "github.com/tidbcloud/tidbcloud-cli/internal/output" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" +) + +type DescribeOpts struct { + interactive bool +} + +func (o DescribeOpts) NonInteractiveFlags() []string { + return []string{ + flag.ClusterID, + flag.ExampleID, + } +} + +func (o DescribeOpts) RequiredFlags() []string { + return []string{ + flag.ClusterID, + flag.ExampleID, + } +} + +func (o *DescribeOpts) MarkInteractive(cmd *cobra.Command) error { + o.interactive = true + for _, fn := range o.NonInteractiveFlags() { + if f := cmd.Flags().Lookup(fn); f != nil && f.Changed { + o.interactive = false + } + } + if !o.interactive { + for _, fn := range o.RequiredFlags() { + if err := cmd.MarkFlagRequired(fn); err != nil { + return err + } + } + } + return nil +} + +func DescribeCmd(h *internal.Helper) *cobra.Command { + opts := &DescribeOpts{interactive: true} + + cmd := &cobra.Command{ + Use: "describe", + Aliases: []string{"get"}, + Short: "Describe an example resource", + Args: cobra.NoArgs, + Example: fmt.Sprintf(` Describe an example resource (interactive): + $ %[1]s serverless example describe + + Describe an example resource (non-interactive): + $ %[1]s serverless example describe -c --example-id `, config.CliName), + PreRunE: func(cmd *cobra.Command, args []string) error { + return opts.MarkInteractive(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + d, err := h.Client() + if err != nil { + return err + } + var clusterID, exampleID string + if opts.interactive { + if !h.IOStreams.CanPrompt { + return errors.New("The terminal doesn't support interactive mode, please use non-interactive mode") + } + project, err := cloud.GetSelectedProject(ctx, h.QueryPageSize, d) + if err != nil { + return err + } + cluster, err := cloud.GetSelectedCluster(ctx, project.ID, h.QueryPageSize, d) + if err != nil { + return err + } + clusterID = cluster.ID + + example, err := GetSelectedExample(ctx, clusterID, h.QueryPageSize, d) + if err != nil { + return err + } + exampleID = example.ID + } else { + var err error + clusterID, err = cmd.Flags().GetString(flag.ClusterID) + if err != nil { + return errors.Trace(err) + } + exampleID, err = cmd.Flags().GetString(flag.ExampleID) + if err != nil { + return errors.Trace(err) + } + } + + if clusterID == "" { + return errors.New("cluster id is required") + } + if exampleID == "" { + return errors.New("example id is required") + } + + // TODO implement the get logic, now just mock the payload + payload := map[string]string{ + "message": UnwiredFeaturePrompt, + "cluster_id": clusterID, + "example_id": exampleID, + } + return output.PrintJson(h.IOStreams.Out, payload) + }, + } + + cmd.Flags().StringP(flag.ClusterID, flag.ClusterIDShort, "", "The cluster ID.") + cmd.Flags().String(flag.ExampleID, "", "The example ID.") + return cmd +} diff --git a/.cursor/skills/create-ticloud-cli-command/assets/example/example.go b/.cursor/skills/create-ticloud-cli-command/assets/example/example.go new file mode 100644 index 00000000..b3b804cc --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/assets/example/example.go @@ -0,0 +1,37 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example + +import ( + "github.com/spf13/cobra" + + "github.com/tidbcloud/tidbcloud-cli/internal" +) + +func ExampleCmd(h *internal.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "example", + Short: "Manage example resources", + } + + cmd.AddCommand( + CreateCmd(h), + DescribeCmd(h), + DeleteCmd(h), + ListCmd(h), + ) + + return cmd +} diff --git a/.cursor/skills/create-ticloud-cli-command/assets/example/list.go b/.cursor/skills/create-ticloud-cli-command/assets/example/list.go new file mode 100644 index 00000000..8f525711 --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/assets/example/list.go @@ -0,0 +1,153 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example + +import ( + "fmt" + + "github.com/fatih/color" + "github.com/juju/errors" + "github.com/spf13/cobra" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/config" + "github.com/tidbcloud/tidbcloud-cli/internal/flag" + "github.com/tidbcloud/tidbcloud-cli/internal/output" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" +) + +type ListOpts struct { + interactive bool +} + +func (o ListOpts) NonInteractiveFlags() []string { + return []string{ + flag.ClusterID, + } +} + +func (o ListOpts) RequiredFlags() []string { + return []string{ + flag.ClusterID, + } +} + +func (o *ListOpts) MarkInteractive(cmd *cobra.Command) error { + o.interactive = true + for _, fn := range o.NonInteractiveFlags() { + if f := cmd.Flags().Lookup(fn); f != nil && f.Changed { + o.interactive = false + } + } + if !o.interactive { + for _, fn := range o.RequiredFlags() { + if err := cmd.MarkFlagRequired(fn); err != nil { + return err + } + } + } + return nil +} + +func ListCmd(h *internal.Helper) *cobra.Command { + opts := &ListOpts{interactive: true} + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List example resources", + Args: cobra.NoArgs, + Example: fmt.Sprintf(` List example resources (interactive): + $ %[1]s serverless example list + + List example resources (non-interactive): + $ %[1]s serverless example list -c + + List example resources with json format: + $ %[1]s serverless example list -o json`, config.CliName), + PreRunE: func(cmd *cobra.Command, args []string) error { + return opts.MarkInteractive(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + d, err := h.Client() + if err != nil { + return err + } + var clusterID string + if opts.interactive { + if !h.IOStreams.CanPrompt { + return errors.New("The terminal doesn't support interactive mode, please use non-interactive mode") + } + project, err := cloud.GetSelectedProject(ctx, h.QueryPageSize, d) + if err != nil { + return err + } + cluster, err := cloud.GetSelectedCluster(ctx, project.ID, h.QueryPageSize, d) + if err != nil { + return err + } + clusterID = cluster.ID + } else { + var err error + clusterID, err = cmd.Flags().GetString(flag.ClusterID) + if err != nil { + return errors.Trace(err) + } + } + + if clusterID == "" { + return errors.New("cluster id is required") + } + + // TODO implement the list logic, now just mock the payload + payload := map[string]interface{}{ + "message": UnwiredFeaturePrompt, + "cluster_id": clusterID, + "items": []interface{}{}, + } + + format, err := cmd.Flags().GetString(flag.Output) + if err != nil { + return errors.Trace(err) + } + + if format == output.JsonFormat || !h.IOStreams.CanPrompt { + err := output.PrintJson(h.IOStreams.Out, payload) + if err != nil { + return errors.Trace(err) + } + } else if format == output.HumanFormat { + columns := []output.Column{ + // Omit code to define columns + } + var rows []output.Row + // Omit code to generate rows + err := output.PrintHumanTable(h.IOStreams.Out, columns, rows) + if err != nil { + return errors.Trace(err) + } + return nil + } else { + return fmt.Errorf("unsupported output format: %s", format) + } + return nil + }, + } + + cmd.Flags().StringP(flag.ClusterID, flag.ClusterIDShort, "", "The cluster ID.") + cmd.Flags().StringP(flag.Output, flag.OutputShort, output.HumanFormat, flag.OutputHelp) + return cmd +} diff --git a/.cursor/skills/create-ticloud-cli-command/assets/example/list_test.go b/.cursor/skills/create-ticloud-cli-command/assets/example/list_test.go new file mode 100644 index 00000000..3bccf7ff --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/assets/example/list_test.go @@ -0,0 +1,15 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example diff --git a/.cursor/skills/create-ticloud-cli-command/assets/example/ui.go b/.cursor/skills/create-ticloud-cli-command/assets/example/ui.go new file mode 100644 index 00000000..058ce70e --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/assets/example/ui.go @@ -0,0 +1,131 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package example + +import ( + "context" + "fmt" + + "github.com/AlecAivazis/survey/v2" + tea "github.com/charmbracelet/bubbletea" + "github.com/juju/errors" + + "github.com/tidbcloud/tidbcloud-cli/internal/flag" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" + "github.com/tidbcloud/tidbcloud-cli/internal/ui" + "github.com/tidbcloud/tidbcloud-cli/internal/util" +) + +const ( + ExampleNamePrompt = "Input the example name:" + DeleteConfirmPrompt = "Type 'yes' to confirm deletion:" + UnwiredFeaturePrompt = "This command is not wired to an API yet." +) + +var s3InputDescriptions = map[string]string{ + flag.S3URI: "S3 URI (e.g., s3://bucket/path)", + flag.S3AccessKeyID: "S3 access key ID", + flag.S3SecretAccessKey: "S3 secret access key", +} + +type Example struct { + ID string + DisplayName string +} + +func (e Example) String() string { + return fmt.Sprintf("%s(%s)", e.DisplayName, e.ID) +} + +func GetSelectedExample(ctx context.Context, clusterID string, pageSize int64, client cloud.TiDBCloudClient) (*Example, error) { + // examples, err := client.ListExamples(ctx, clusterID, pageSize, nil) + // if err != nil { + // return nil, errors.Trace(err) + // } + + // var items = make([]interface{}, 0, len(examples)) + // for _, example := range examples { + // items = append(items, &Example{ID: *example.ExampleId, DisplayName: *example.DisplayName}) + // } + + items := []interface{}{ + &Example{ID: "example-1", DisplayName: "Example One"}, + &Example{ID: "example-2", DisplayName: "Example Two"}, + &Example{ID: "example-3", DisplayName: "Example Three"}, + } + + if len(items) == 0 { + return nil, fmt.Errorf("no available examples found") + } + + model, err := ui.InitialSelectModel(items, "Choose the example:") + if err != nil { + return nil, errors.Trace(err) + } + itemsPerPage := 6 + model.EnablePagination(itemsPerPage) + model.EnableFilter() + + p := tea.NewProgram(model) + exampleModel, err := p.Run() + if err != nil { + return nil, errors.Trace(err) + } + if m, _ := exampleModel.(ui.SelectModel); m.Interrupted { + return nil, util.InterruptError + } + selected := exampleModel.(ui.SelectModel).GetSelectedItem() + if selected == nil { + return nil, errors.New("no example selected") + } + return selected.(*Example), nil +} + +func GetDisplayNameInput() (string, error) { + model, err := ui.InitialOneInputModel(ExampleNamePrompt, "example-name") + if err != nil { + return "", errors.Trace(err) + } + if model.Interrupted { + return "", util.InterruptError + } + if model.Err != nil { + return "", model.Err + } + return model.Input.Value(), nil +} + +func GetS3Inputs() (string, string, string, error) { + fmt.Fprintln(h.IOStreams.Out, "Pl") + inputs := []string{flag.S3URI, flag.S3AccessKeyID, flag.S3SecretAccessKey} + textInput, err := ui.InitialInputModel(inputs, s3InputDescriptions) + if err != nil { + return "", "", "", err + } + + s3URI := textInput.Inputs[0].Value() + if s3URI == "" { + return "", "", "", errors.New("empty S3 URI") + } + s3AccessKeyID := textInput.Inputs[1].Value() + if s3AccessKeyID == "" { + return "", "", "", errors.New("empty S3 access key ID") + } + s3SecretAccessKey := textInput.Inputs[2].Value() + if s3SecretAccessKey == "" { + return "", "", "", errors.New("empty S3 secret access key") + } + return s3URI, s3AccessKeyID, s3SecretAccessKey, nil +} diff --git a/.cursor/skills/create-ticloud-cli-command/references/bubbletea.md b/.cursor/skills/create-ticloud-cli-command/references/bubbletea.md new file mode 100644 index 00000000..a617a895 --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/references/bubbletea.md @@ -0,0 +1,205 @@ +## Bubbletea Knowledge + +Bubble Tea is based on the functional design paradigms of The Elm +Architecture, which happens to work nicely with Go. It's a delightful way +to build applications. + +This tutorial assumes you have a working knowledge of Go. + +### Enough! Let's get to it. + +For this tutorial, we're making a shopping list. + +To start we'll define our package and import some libraries. Our only external +import will be the Bubble Tea library, which we'll call `tea` for short. + +```go +package main + +// These imports will be used later on the tutorial. If you save the file +// now, Go might complain they are unused, but that's fine. +// You may also need to run `go mod tidy` to download bubbletea and its +// dependencies. +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" +) +``` + +Bubble Tea programs are comprised of a **model** that describes the application +state and three simple methods on that model: + +- **Init**, a function that returns an initial command for the application to run. +- **Update**, a function that handles incoming events and updates the model accordingly. +- **View**, a function that renders the UI based on the data in the model. + +### The Model + +So let's start by defining our model which will store our application's state. +It can be any type, but a `struct` usually makes the most sense. + +```go +type model struct { + choices []string // items on the to-do list + cursor int // which to-do list item our cursor is pointing at + selected map[int]struct{} // which to-do items are selected +} +``` + +### Initialization + +Next, we’ll define our application’s initial state. In this case, we’re defining +a function to return our initial model, however, we could just as easily define +the initial model as a variable elsewhere, too. + +```go +func initialModel() model { + return model{ + // Our to-do list is a grocery list + choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"}, + + // A map which indicates which choices are selected. We're using + // the map like a mathematical set. The keys refer to the indexes + // of the `choices` slice, above. + selected: make(map[int]struct{}), + } +} +``` + +Next, we define the `Init` method. `Init` can return a `Cmd` that could perform +some initial I/O. For now, we don't need to do any I/O, so for the command, +we'll just return `nil`, which translates to "no command." + +```go +func (m model) Init() tea.Cmd { + // Just return `nil`, which means "no I/O right now, please." + return nil +} +``` + +### The Update Method + +Next up is the update method. The update function is called when ”things +happen.” Its job is to look at what has happened and return an updated model in +response. It can also return a `Cmd` to make more things happen, but for now +don't worry about that part. + +In our case, when a user presses the down arrow, `Update`’s job is to notice +that the down arrow was pressed and move the cursor accordingly (or not). + +The “something happened” comes in the form of a `Msg`, which can be any type. +Messages are the result of some I/O that took place, such as a keypress, timer +tick, or a response from a server. + +We usually figure out which type of `Msg` we received with a type switch, but +you could also use a type assertion. + +For now, we'll just deal with `tea.KeyMsg` messages, which are automatically +sent to the update function when keys are pressed. + +```go +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + // Is it a key press? + case tea.KeyMsg: + + // Cool, what was the actual key pressed? + switch msg.String() { + + // These keys should exit the program. + case "ctrl+c", "q": + return m, tea.Quit + + // The "up" and "k" keys move the cursor up + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + + // The "down" and "j" keys move the cursor down + case "down", "j": + if m.cursor < len(m.choices)-1 { + m.cursor++ + } + + // The "enter" key and the spacebar (a literal space) toggle + // the selected state for the item that the cursor is pointing at. + case "enter", " ": + _, ok := m.selected[m.cursor] + if ok { + delete(m.selected, m.cursor) + } else { + m.selected[m.cursor] = struct{}{} + } + } + } + + // Return the updated model to the Bubble Tea runtime for processing. + // Note that we're not returning a command. + return m, nil +} +``` + +You may have noticed that ctrl+c and q above return +a `tea.Quit` command with the model. That’s a special command which instructs +the Bubble Tea runtime to quit, exiting the program. + +### The View Method + +At last, it’s time to render our UI. Of all the methods, the view is the +simplest. We look at the model in its current state and use it to return +a `string`. That string is our UI! + +Because the view describes the entire UI of your application, you don’t have to +worry about redrawing logic and stuff like that. Bubble Tea takes care of it +for you. + +```go +func (m model) View() string { + // The header + s := "What should we buy at the market?\n\n" + + // Iterate over our choices + for i, choice := range m.choices { + + // Is the cursor pointing at this choice? + cursor := " " // no cursor + if m.cursor == i { + cursor = ">" // cursor! + } + + // Is this choice selected? + checked := " " // not selected + if _, ok := m.selected[i]; ok { + checked = "x" // selected! + } + + // Render the row + s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) + } + + // The footer + s += "\nPress q to quit.\n" + + // Send the UI for rendering + return s +} +``` + +### All Together Now + +The last step is to simply run our program. We pass our initial model to +`tea.NewProgram` and let it rip: + +```go +func main() { + p := tea.NewProgram(initialModel()) + if _, err := p.Run(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } +} +``` \ No newline at end of file diff --git a/.cursor/skills/create-ticloud-cli-command/references/cobra.md b/.cursor/skills/create-ticloud-cli-command/references/cobra.md new file mode 100644 index 00000000..f398c30a --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/references/cobra.md @@ -0,0 +1,66 @@ +# Cobra Knowledge + +# Overview + +Cobra is a library providing a simple interface to create powerful modern CLI +interfaces similar to git & go tools. + +Cobra provides: +* Easy subcommand-based CLIs: `app server`, `app fetch`, etc. +* Fully POSIX-compliant flags (including short & long versions) +* Nested subcommands +* Global, local and cascading flags +* Intelligent suggestions (`app srver`... did you mean `app server`?) +* Automatic help generation for commands and flags +* Grouping help for subcommands +* Automatic help flag recognition of `-h`, `--help`, etc. +* Automatically generated shell autocomplete for your application (bash, zsh, fish, powershell) +* Automatically generated man pages for your application +* Command aliases so you can change things without breaking them +* The flexibility to define your own help, usage, etc. +* Optional seamless integration with viper for 12-factor apps + +# Concepts + +Cobra is built on a structure of commands, arguments & flags. + +**Commands** represent actions, **Args** are things and **Flags** are modifiers for those actions. + +The best applications read like sentences when used, and as a result, users +intuitively know how to interact with them. + +The pattern to follow is +`APPNAME VERB NOUN --ADJECTIVE` + or +`APPNAME COMMAND ARG --FLAG`. + +A few good real world examples may better illustrate this point. + +In the following example, 'server' is a command, and 'port' is a flag: + + hugo server --port=1313 + +In this command we are telling Git to clone the url bare. + + git clone URL --bare + +## Commands + +Command is the central point of the application. Each interaction that +the application supports will be contained in a Command. A command can +have children commands and optionally run an action. + +In the example above, 'server' is the command. + +## Flags + +A flag is a way to modify the behavior of a command. Cobra supports +fully POSIX-compliant flags as well as the Go flag package. +A Cobra command can define flags that persist through to children commands +and flags that are only available to that command. + +In the example above, 'port' is the flag. + +Flag functionality is provided by the pflag +library, a fork of the flag standard library +which maintains the same interface while adding POSIX compliance. \ No newline at end of file diff --git a/.cursor/skills/create-ticloud-cli-command/references/flag.md b/.cursor/skills/create-ticloud-cli-command/references/flag.md new file mode 100644 index 00000000..736b847a --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/references/flag.md @@ -0,0 +1,185 @@ +# Flag inference + +1. Infer flags if the user does not provide the command with flags. + +Skip this if the user has already provided flags. + +Infer flags from the Swagger spec and SDK client parameters. For example, see the branch create command in `internal/cli/serverless/branch/create.go`. + +The SDK client parameter `Branch` is as follows: + +``` +type Branch struct { + // The unique identifier for the branch. + Name *string `json:"name,omitempty"` + // The system-generated ID of the branch. + BranchId *string `json:"branchId,omitempty"` + // The user-defined name of the branch. + DisplayName string `json:"displayName"` + // The ID of the cluster to which the branch belongs. + ClusterId *string `json:"clusterId,omitempty"` + // The ID of the branch parent. + ParentId *string `json:"parentId,omitempty"` + // The email address of the user who create the branch. + CreatedBy *string `json:"createdBy,omitempty"` + // The state of the branch. + State *BranchState `json:"state,omitempty"` + // The connection endpoints for accessing the branch. + Endpoints *BranchEndpoints `json:"endpoints,omitempty"` + // The unique prefix automatically generated for SQL usernames on this cluster. TiDB Cloud uses this prefix to distinguish between clusters. For more information, see [User name prefix](https://docs.pingcap.com/tidbcloud/select-cluster-tier/#user-name-prefix). + UserPrefix NullableString `json:"userPrefix,omitempty"` + // The timestamp when the branch was created, in the [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format. + CreateTime *time.Time `json:"createTime,omitempty"` + // The timestamp when the branch was last updated, in the [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format. + UpdateTime *time.Time `json:"updateTime,omitempty"` + // The annotations for the branch. + Annotations *map[string]string `json:"annotations,omitempty"` + // The display name of the parent branch from which the branch was created. + ParentDisplayName *string `json:"parentDisplayName,omitempty"` + // The point in time on the parent branch from which the branch is created. The timestamp is truncated to seconds without rounding. + ParentTimestamp NullableTime `json:"parentTimestamp,omitempty"` + // The root password of the branch. It must be between 8 and 64 characters long and can contain letters, numbers, and special characters. + RootPassword *string `json:"rootPassword,omitempty" validate:"regexp=^.{8,64}$"` + AdditionalProperties map[string]interface{} +} +``` + +The part of the swagger is as follows: + +``` + "parameters": [ + { + "name": "clusterId", + "description": "The ID of the cluster to which the branch belongs.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "branch", + "description": "The branch being created.", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Branch" + } + } + ], + +"Branch": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The unique identifier for the branch.", + "readOnly": true + }, + "branchId": { + "type": "string", + "description": "The system-generated ID of the branch.", + "readOnly": true + }, + "displayName": { + "type": "string", + "description": "The user-defined name of the branch." + }, + "clusterId": { + "type": "string", + "description": "The ID of the cluster to which the branch belongs.", + "readOnly": true + }, + "parentId": { + "type": "string", + "description": "The ID of the branch parent." + }, + "createdBy": { + "type": "string", + "description": "The email address of the user who create the branch.", + "readOnly": true + }, + "state": { + "description": "The state of the branch.", + "readOnly": true, + "allOf": [ + { + "$ref": "#/definitions/Branch.State" + } + ] + }, + "endpoints": { + "description": "The connection endpoints for accessing the branch.", + "readOnly": true, + "allOf": [ + { + "$ref": "#/definitions/Branch.Endpoints" + } + ] + }, + "userPrefix": { + "type": "string", + "x-nullable": true, + "description": "The unique prefix automatically generated for SQL usernames on this cluster. TiDB Cloud uses this prefix to distinguish between clusters. For more information, see [User name prefix](https://docs.pingcap.com/tidbcloud/select-cluster-tier/#user-name-prefix).", + "readOnly": true + }, + "createTime": { + "type": "string", + "format": "date-time", + "description": "The timestamp when the branch was created, in the [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format.", + "readOnly": true + }, + "updateTime": { + "type": "string", + "format": "date-time", + "description": "The timestamp when the branch was last updated, in the [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format.", + "readOnly": true + }, + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "The annotations for the branch." + }, + "parentDisplayName": { + "type": "string", + "description": "The display name of the parent branch from which the branch was created.", + "readOnly": true + }, + "parentTimestamp": { + "type": "string", + "format": "date-time", + "x-nullable": true, + "description": "The point in time on the parent branch from which the branch is created. The timestamp is truncated to seconds without rounding." + }, + "rootPassword": { + "type": "string", + "example": "my-shining-password", + "description": "The root password of the branch. It must be between 8 and 64 characters long and can contain letters, numbers, and special characters.", + "maxLength": 64, + "minLength": 8, + "pattern": "^.{8,64}$" + } + }, + "description": "Message for branch.", + "required": [ + "displayName" + ] + } +``` + +Generally, flags should exclude readOnly fields in the Swagger spec and include other fields. Exceptions may occur, so this rule is not mandatory. + +``` + createCmd.Flags().StringP(flag.DisplayName, flag.DisplayNameShort, "", "The displayName of the branch to be created.") + createCmd.Flags().StringP(flag.ClusterID, flag.ClusterIDShort, "", "The ID of the cluster, in which the branch will be created.") + createCmd.Flags().StringP(flag.ParentID, "", "", "The ID of the branch parent, default is cluster id.") + createCmd.Flags().StringP(flag.ParentTimestamp, "", "", "The timestamp of the parent branch, default is current time. (RFC3339 format, e.g., 2024-01-01T00:00:00Z)") +``` + +2. Infer flag types from the SDK client parameter types. + +For example: if the parameter is a string type, use `Flags().String`. + +Flag inference may be inaccurate, so: +- Always ask the user when unsure about flags or flag types. +- Always ask the user to confirm the final result. diff --git a/.cursor/skills/create-ticloud-cli-command/references/sdk.md b/.cursor/skills/create-ticloud-cli-command/references/sdk.md new file mode 100644 index 00000000..b4641cb7 --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/references/sdk.md @@ -0,0 +1,18 @@ +# TiDB Cloud SDK knowledge + +TiDB Cloud CLI keeps the TiDB Cloud Open API SDK inside the project. + +Key points about the SDK: + +1. Swagger files are the single source of truth for the SDK; they live under `/pkg/tidbcloud`. +2. The openapi-generator library generates the SDK from Swagger. You do not need to know openapi-generator in detail; use the commands in the `Makefile` to invoke it. +3. `internal/service/cloud/api_client.go` combines the generated SDKs into a single client. +4. Mocks for `api_client.go` must also be generated. + +It is the user's responsibility to add or change Swagger files under `pkg/tidbcloud/`. + +It is the AI agent's responsibility to generate the SDK. Follow the workflow below: + +1. If new swagger is added, add the generate client script in Makefile, refer the format under generate-v1beta1-serverless-client +2. Run the appropriate command based on where Swagger files were added or changed. For example, if `pkg/tidbcloud/v1beta1/serverless` changed, run `generate-v1beta1-serverless-client`. If you cannot determine which Swagger changed, run `make generate-v1beta1-client` to generate all. +3. Update `internal/service/cloud/api_client.go` according to the generated SDK. If new methods appear in the SDK, add new interface methods and implementations in `api_client.go` following the existing code style. If new API clients appear, wrap them in the `ClientDelegate` struct. \ No newline at end of file diff --git a/.cursor/skills/create-ticloud-cli-command/references/tidbcloud-cli.md b/.cursor/skills/create-ticloud-cli-command/references/tidbcloud-cli.md new file mode 100644 index 00000000..8142dff0 --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/references/tidbcloud-cli.md @@ -0,0 +1,63 @@ +# TiDB Cloud CLI Knowledge + +## TiDB Cloud + +TiDB Cloud is a fully-managed Database-as-a-Service (DBaaS) that brings TiDB, an open-source Hybrid Transactional and Analytical Processing (HTAP) database, to your cloud. TiDB Cloud offers an easy way to deploy and manage databases to let you focus on your applications, not the complexities of the databases. You can create TiDB Cloud clusters to quickly build mission-critical applications on Amazon Web Services (AWS), Google Cloud, Microsoft Azure, and Alibaba Cloud. + +## TiDB Cloud CLI + +TiDB Cloud provides a command-line interface (CLI) `ticloud` for you to interact with TiDB Cloud from your terminal with a few lines of commands. For example, you can easily perform the following operations using `ticloud`: + +- Create, delete, and list your clusters. +- Import data to your clusters. +- Export data from your clusters. + +### TiDB Cloud CLI Auth + +- Create a user profile with your TiDB Cloud API key + + ```shell + ticloud config create + ``` + + > **Warning:** + > + > The profile name **MUST NOT** contain `.`. + +- Log into TiDB Cloud with authentication: + + ```shell + ticloud auth login + ``` + + After successful login, an OAuth token will be assigned to the current profile. If no profiles exist, the token will be assigned to a profile named `default`. + +> **Note:** +> +> In the preceding two methods, the TiDB Cloud API key takes precedence over the OAuth token. If both are available, the API key will be used. + +### Use the TiDB Cloud CLI + +View all commands available: + +```shell +ticloud --help +``` + +Verify that you are using the latest version: + +```shell +ticloud version +``` + +If not, update to the latest version: + +```shell +ticloud update +``` + +Create a TiDB Cloud cluster: + +```shell +ticloud serverless create +``` \ No newline at end of file diff --git a/.cursor/skills/create-ticloud-cli-command/references/ui.md b/.cursor/skills/create-ticloud-cli-command/references/ui.md new file mode 100644 index 00000000..2c957b71 --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/references/ui.md @@ -0,0 +1,53 @@ +# TiDB Cloud UI + +## Overview + +The basic UI lives under `internal/ui`; command-specific UI is under each command package (e.g. `assets/example/ui.go`). + +Live example: `internal/cli/serverless/changefeed/ui.go` + +## Forbidden + +github.com/AlecAivazis/survey/v2 is no longer maintained. + +Use it only for deletion confirmation. Do not use it for any other case. + +Deletion confirmation example: +``` +if err := survey.AskOne(&survey.Input{Message: DeleteConfirmPrompt}, &confirm); err != nil { + return err + } +``` + +## Selection UI (IDs, enums, predefined options) + +Use selection UI for IDs, enum fields, and any fixed option set. + +- Use `ui.InitialSelectModel(items, prompt)` with `bubbletea`. +- Enable pagination and filter: + - `model.EnablePagination(6)` + - `model.EnableFilter()` +- Handle interrupt: + - If model reports `Interrupted`, return `util.InterruptError`. +- Example pattern: see `assets/example/ui.go` (`GetSelectedExample`). + +Live Example: see `internal/service/cloud/logic.go` (`GetSelectedChangefeed`,`GetSelectedCluster`) + +## Simple input UI (single field) + +Use `ui.InitialOneInputModel` for any single field. + +- Example: `GetDisplayNameInput` in `assets/example/ui.go`. + +## Complex composite input (multiple related fields) + +For grouped or multi-field inputs, use `ui.InitialInputModel`. + +- Provide a list of keys and a description map for each field. +- Read values from `textInput.Inputs[i].Value()`. +- Validate each required field and return explicit errors. +- Example pattern: `GetS3Inputs` in `assets/example/ui.go`. + +## Others + +More UI components are in `internal/ui`. If what you need is not there, create a basic UI under `internal/ui` based on Bubbletea. See `references/bubbletea.md` for more information. diff --git a/.cursor/skills/create-ticloud-cli-command/references/ut.md b/.cursor/skills/create-ticloud-cli-command/references/ut.md new file mode 100644 index 00000000..9c4dd622 --- /dev/null +++ b/.cursor/skills/create-ticloud-cli-command/references/ut.md @@ -0,0 +1,16 @@ +# Unit tests + +This document describes how to write TiDB Cloud CLI unit tests. + +Follow the patterns under `internal/cli/serverless/branch`: + +- Use generated mocks for service calls. +- Use `suite.Suite` with `SetupTest` to set `NO_COLOR`, create `iostream.Test()`, and inject a mock client. +- Use table-driven tests for flag combinations and error cases. +- Validate both `stdout` and `stderr` contents exactly. +- Assert mock expectations only on success paths. +- Cover: + - Required flags errors. + - Shorthand flags (`-c`, `-b`, `-o`, etc.). + - Output formats (`json` vs human). + - Multi-page list behavior when applicable. \ No newline at end of file diff --git a/.cursor/skills/update-ticloud-cli-command/LICENSE.txt b/.cursor/skills/update-ticloud-cli-command/LICENSE.txt new file mode 100644 index 00000000..f433b1a5 --- /dev/null +++ b/.cursor/skills/update-ticloud-cli-command/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/.cursor/skills/update-ticloud-cli-command/SKILL.md b/.cursor/skills/update-ticloud-cli-command/SKILL.md new file mode 100644 index 00000000..b1af08cf --- /dev/null +++ b/.cursor/skills/update-ticloud-cli-command/SKILL.md @@ -0,0 +1,75 @@ +--- +name: update-ticloud-cli-command +description: Update TiDB Cloud CLI commands. Use when adding new flags or updating existing commands. +license: Complete terms in LICENSE.txt +metadata: + author: shiyuhang0 +--- + +# Update TiCloud CLI Command + +This skill updates TiDB Cloud CLI existing commands, helping users update existing commands with production-ready code. + +## When to use + +- The user asks to add flags to existing commands. +- The user asks to update logic of existing commands. + +## About TiDB Cloud CLI + +TiDB Cloud CLI (TiCloud CLI) is a command-line interface for interacting with TiDB Cloud, built on the Cobra library. + +Key design of TiDB Cloud CLI: +- Built on the Cobra library. +- Uses the TiDB Cloud Open API as the client and keeps the SDK inside the project. +- Every command supports both interactive and non-interactive modes. + +- More about TiDB Cloud CLI: `references/tidbcloud-cli.md`. +- More about Cobra: `references/cobra.md`. + +## Workflow + +Must follow the workflow below: + +### Generate SDK phase + +Always prompt the user: "Do you need to add or update swagger? Please provide the swagger path if you need." + +Skip this phase if user does not need. + +Once in this phase, follow the guide in `references/sdk.md` to generate the SDK. + +After SDK is generated, ask user to use go>=1.24 to run `make generate-mocks` manually! + +### Plan phase + +The agent needs to switch to plan mode if supported. This phase can be skipped if user already provide enough informations. + +The agent must ask the user for the following information during the plan phase: +1. **Updating Information**: Ask the user for details of the update, including whether new flags need to be added (if yes, request the user to provide them), and whether existing logic needs to be modified (if yes, ask the user to provide a specific description of the logic). +2. **Other necessary information** + +Generate the plan after the user confirms all the information. + +### Agent phase + +The agent must switch to agent mode in this phase. Follow the workflow below: + +1. **Update command** +2. **Write and run tests** + +#### Update command + +Update the command according to user-provided information and current command code style. + +If new flags are added: +- Refer to `references/flag.md` for the flag definition. +- Refer to `references/ui.md` for the UI design. + +#### Write and run the tests + +Skip this step if the test file does not exist. Otherwise, write unit tests following `references/ut.md`. Then run tests with: `go test -race -cover -count=1 path -v` + +For example, to run tests under `internal/cli/serverless/branch`: `go test -race -cover -count=1 ./internal/cli/serverless/branch -v` + +Ensure all tests pass. If they do not pass, fix the implementation or the tests. \ No newline at end of file diff --git a/.cursor/skills/update-ticloud-cli-command/references/bubbletea.md b/.cursor/skills/update-ticloud-cli-command/references/bubbletea.md new file mode 100644 index 00000000..a617a895 --- /dev/null +++ b/.cursor/skills/update-ticloud-cli-command/references/bubbletea.md @@ -0,0 +1,205 @@ +## Bubbletea Knowledge + +Bubble Tea is based on the functional design paradigms of The Elm +Architecture, which happens to work nicely with Go. It's a delightful way +to build applications. + +This tutorial assumes you have a working knowledge of Go. + +### Enough! Let's get to it. + +For this tutorial, we're making a shopping list. + +To start we'll define our package and import some libraries. Our only external +import will be the Bubble Tea library, which we'll call `tea` for short. + +```go +package main + +// These imports will be used later on the tutorial. If you save the file +// now, Go might complain they are unused, but that's fine. +// You may also need to run `go mod tidy` to download bubbletea and its +// dependencies. +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" +) +``` + +Bubble Tea programs are comprised of a **model** that describes the application +state and three simple methods on that model: + +- **Init**, a function that returns an initial command for the application to run. +- **Update**, a function that handles incoming events and updates the model accordingly. +- **View**, a function that renders the UI based on the data in the model. + +### The Model + +So let's start by defining our model which will store our application's state. +It can be any type, but a `struct` usually makes the most sense. + +```go +type model struct { + choices []string // items on the to-do list + cursor int // which to-do list item our cursor is pointing at + selected map[int]struct{} // which to-do items are selected +} +``` + +### Initialization + +Next, we’ll define our application’s initial state. In this case, we’re defining +a function to return our initial model, however, we could just as easily define +the initial model as a variable elsewhere, too. + +```go +func initialModel() model { + return model{ + // Our to-do list is a grocery list + choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"}, + + // A map which indicates which choices are selected. We're using + // the map like a mathematical set. The keys refer to the indexes + // of the `choices` slice, above. + selected: make(map[int]struct{}), + } +} +``` + +Next, we define the `Init` method. `Init` can return a `Cmd` that could perform +some initial I/O. For now, we don't need to do any I/O, so for the command, +we'll just return `nil`, which translates to "no command." + +```go +func (m model) Init() tea.Cmd { + // Just return `nil`, which means "no I/O right now, please." + return nil +} +``` + +### The Update Method + +Next up is the update method. The update function is called when ”things +happen.” Its job is to look at what has happened and return an updated model in +response. It can also return a `Cmd` to make more things happen, but for now +don't worry about that part. + +In our case, when a user presses the down arrow, `Update`’s job is to notice +that the down arrow was pressed and move the cursor accordingly (or not). + +The “something happened” comes in the form of a `Msg`, which can be any type. +Messages are the result of some I/O that took place, such as a keypress, timer +tick, or a response from a server. + +We usually figure out which type of `Msg` we received with a type switch, but +you could also use a type assertion. + +For now, we'll just deal with `tea.KeyMsg` messages, which are automatically +sent to the update function when keys are pressed. + +```go +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + // Is it a key press? + case tea.KeyMsg: + + // Cool, what was the actual key pressed? + switch msg.String() { + + // These keys should exit the program. + case "ctrl+c", "q": + return m, tea.Quit + + // The "up" and "k" keys move the cursor up + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + + // The "down" and "j" keys move the cursor down + case "down", "j": + if m.cursor < len(m.choices)-1 { + m.cursor++ + } + + // The "enter" key and the spacebar (a literal space) toggle + // the selected state for the item that the cursor is pointing at. + case "enter", " ": + _, ok := m.selected[m.cursor] + if ok { + delete(m.selected, m.cursor) + } else { + m.selected[m.cursor] = struct{}{} + } + } + } + + // Return the updated model to the Bubble Tea runtime for processing. + // Note that we're not returning a command. + return m, nil +} +``` + +You may have noticed that ctrl+c and q above return +a `tea.Quit` command with the model. That’s a special command which instructs +the Bubble Tea runtime to quit, exiting the program. + +### The View Method + +At last, it’s time to render our UI. Of all the methods, the view is the +simplest. We look at the model in its current state and use it to return +a `string`. That string is our UI! + +Because the view describes the entire UI of your application, you don’t have to +worry about redrawing logic and stuff like that. Bubble Tea takes care of it +for you. + +```go +func (m model) View() string { + // The header + s := "What should we buy at the market?\n\n" + + // Iterate over our choices + for i, choice := range m.choices { + + // Is the cursor pointing at this choice? + cursor := " " // no cursor + if m.cursor == i { + cursor = ">" // cursor! + } + + // Is this choice selected? + checked := " " // not selected + if _, ok := m.selected[i]; ok { + checked = "x" // selected! + } + + // Render the row + s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) + } + + // The footer + s += "\nPress q to quit.\n" + + // Send the UI for rendering + return s +} +``` + +### All Together Now + +The last step is to simply run our program. We pass our initial model to +`tea.NewProgram` and let it rip: + +```go +func main() { + p := tea.NewProgram(initialModel()) + if _, err := p.Run(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } +} +``` \ No newline at end of file diff --git a/.cursor/skills/update-ticloud-cli-command/references/cobra.md b/.cursor/skills/update-ticloud-cli-command/references/cobra.md new file mode 100644 index 00000000..f398c30a --- /dev/null +++ b/.cursor/skills/update-ticloud-cli-command/references/cobra.md @@ -0,0 +1,66 @@ +# Cobra Knowledge + +# Overview + +Cobra is a library providing a simple interface to create powerful modern CLI +interfaces similar to git & go tools. + +Cobra provides: +* Easy subcommand-based CLIs: `app server`, `app fetch`, etc. +* Fully POSIX-compliant flags (including short & long versions) +* Nested subcommands +* Global, local and cascading flags +* Intelligent suggestions (`app srver`... did you mean `app server`?) +* Automatic help generation for commands and flags +* Grouping help for subcommands +* Automatic help flag recognition of `-h`, `--help`, etc. +* Automatically generated shell autocomplete for your application (bash, zsh, fish, powershell) +* Automatically generated man pages for your application +* Command aliases so you can change things without breaking them +* The flexibility to define your own help, usage, etc. +* Optional seamless integration with viper for 12-factor apps + +# Concepts + +Cobra is built on a structure of commands, arguments & flags. + +**Commands** represent actions, **Args** are things and **Flags** are modifiers for those actions. + +The best applications read like sentences when used, and as a result, users +intuitively know how to interact with them. + +The pattern to follow is +`APPNAME VERB NOUN --ADJECTIVE` + or +`APPNAME COMMAND ARG --FLAG`. + +A few good real world examples may better illustrate this point. + +In the following example, 'server' is a command, and 'port' is a flag: + + hugo server --port=1313 + +In this command we are telling Git to clone the url bare. + + git clone URL --bare + +## Commands + +Command is the central point of the application. Each interaction that +the application supports will be contained in a Command. A command can +have children commands and optionally run an action. + +In the example above, 'server' is the command. + +## Flags + +A flag is a way to modify the behavior of a command. Cobra supports +fully POSIX-compliant flags as well as the Go flag package. +A Cobra command can define flags that persist through to children commands +and flags that are only available to that command. + +In the example above, 'port' is the flag. + +Flag functionality is provided by the pflag +library, a fork of the flag standard library +which maintains the same interface while adding POSIX compliance. \ No newline at end of file diff --git a/.cursor/skills/update-ticloud-cli-command/references/flag.md b/.cursor/skills/update-ticloud-cli-command/references/flag.md new file mode 100644 index 00000000..736b847a --- /dev/null +++ b/.cursor/skills/update-ticloud-cli-command/references/flag.md @@ -0,0 +1,185 @@ +# Flag inference + +1. Infer flags if the user does not provide the command with flags. + +Skip this if the user has already provided flags. + +Infer flags from the Swagger spec and SDK client parameters. For example, see the branch create command in `internal/cli/serverless/branch/create.go`. + +The SDK client parameter `Branch` is as follows: + +``` +type Branch struct { + // The unique identifier for the branch. + Name *string `json:"name,omitempty"` + // The system-generated ID of the branch. + BranchId *string `json:"branchId,omitempty"` + // The user-defined name of the branch. + DisplayName string `json:"displayName"` + // The ID of the cluster to which the branch belongs. + ClusterId *string `json:"clusterId,omitempty"` + // The ID of the branch parent. + ParentId *string `json:"parentId,omitempty"` + // The email address of the user who create the branch. + CreatedBy *string `json:"createdBy,omitempty"` + // The state of the branch. + State *BranchState `json:"state,omitempty"` + // The connection endpoints for accessing the branch. + Endpoints *BranchEndpoints `json:"endpoints,omitempty"` + // The unique prefix automatically generated for SQL usernames on this cluster. TiDB Cloud uses this prefix to distinguish between clusters. For more information, see [User name prefix](https://docs.pingcap.com/tidbcloud/select-cluster-tier/#user-name-prefix). + UserPrefix NullableString `json:"userPrefix,omitempty"` + // The timestamp when the branch was created, in the [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format. + CreateTime *time.Time `json:"createTime,omitempty"` + // The timestamp when the branch was last updated, in the [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format. + UpdateTime *time.Time `json:"updateTime,omitempty"` + // The annotations for the branch. + Annotations *map[string]string `json:"annotations,omitempty"` + // The display name of the parent branch from which the branch was created. + ParentDisplayName *string `json:"parentDisplayName,omitempty"` + // The point in time on the parent branch from which the branch is created. The timestamp is truncated to seconds without rounding. + ParentTimestamp NullableTime `json:"parentTimestamp,omitempty"` + // The root password of the branch. It must be between 8 and 64 characters long and can contain letters, numbers, and special characters. + RootPassword *string `json:"rootPassword,omitempty" validate:"regexp=^.{8,64}$"` + AdditionalProperties map[string]interface{} +} +``` + +The part of the swagger is as follows: + +``` + "parameters": [ + { + "name": "clusterId", + "description": "The ID of the cluster to which the branch belongs.", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "branch", + "description": "The branch being created.", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Branch" + } + } + ], + +"Branch": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The unique identifier for the branch.", + "readOnly": true + }, + "branchId": { + "type": "string", + "description": "The system-generated ID of the branch.", + "readOnly": true + }, + "displayName": { + "type": "string", + "description": "The user-defined name of the branch." + }, + "clusterId": { + "type": "string", + "description": "The ID of the cluster to which the branch belongs.", + "readOnly": true + }, + "parentId": { + "type": "string", + "description": "The ID of the branch parent." + }, + "createdBy": { + "type": "string", + "description": "The email address of the user who create the branch.", + "readOnly": true + }, + "state": { + "description": "The state of the branch.", + "readOnly": true, + "allOf": [ + { + "$ref": "#/definitions/Branch.State" + } + ] + }, + "endpoints": { + "description": "The connection endpoints for accessing the branch.", + "readOnly": true, + "allOf": [ + { + "$ref": "#/definitions/Branch.Endpoints" + } + ] + }, + "userPrefix": { + "type": "string", + "x-nullable": true, + "description": "The unique prefix automatically generated for SQL usernames on this cluster. TiDB Cloud uses this prefix to distinguish between clusters. For more information, see [User name prefix](https://docs.pingcap.com/tidbcloud/select-cluster-tier/#user-name-prefix).", + "readOnly": true + }, + "createTime": { + "type": "string", + "format": "date-time", + "description": "The timestamp when the branch was created, in the [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format.", + "readOnly": true + }, + "updateTime": { + "type": "string", + "format": "date-time", + "description": "The timestamp when the branch was last updated, in the [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format.", + "readOnly": true + }, + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "The annotations for the branch." + }, + "parentDisplayName": { + "type": "string", + "description": "The display name of the parent branch from which the branch was created.", + "readOnly": true + }, + "parentTimestamp": { + "type": "string", + "format": "date-time", + "x-nullable": true, + "description": "The point in time on the parent branch from which the branch is created. The timestamp is truncated to seconds without rounding." + }, + "rootPassword": { + "type": "string", + "example": "my-shining-password", + "description": "The root password of the branch. It must be between 8 and 64 characters long and can contain letters, numbers, and special characters.", + "maxLength": 64, + "minLength": 8, + "pattern": "^.{8,64}$" + } + }, + "description": "Message for branch.", + "required": [ + "displayName" + ] + } +``` + +Generally, flags should exclude readOnly fields in the Swagger spec and include other fields. Exceptions may occur, so this rule is not mandatory. + +``` + createCmd.Flags().StringP(flag.DisplayName, flag.DisplayNameShort, "", "The displayName of the branch to be created.") + createCmd.Flags().StringP(flag.ClusterID, flag.ClusterIDShort, "", "The ID of the cluster, in which the branch will be created.") + createCmd.Flags().StringP(flag.ParentID, "", "", "The ID of the branch parent, default is cluster id.") + createCmd.Flags().StringP(flag.ParentTimestamp, "", "", "The timestamp of the parent branch, default is current time. (RFC3339 format, e.g., 2024-01-01T00:00:00Z)") +``` + +2. Infer flag types from the SDK client parameter types. + +For example: if the parameter is a string type, use `Flags().String`. + +Flag inference may be inaccurate, so: +- Always ask the user when unsure about flags or flag types. +- Always ask the user to confirm the final result. diff --git a/.cursor/skills/update-ticloud-cli-command/references/sdk.md b/.cursor/skills/update-ticloud-cli-command/references/sdk.md new file mode 100644 index 00000000..b0090a0c --- /dev/null +++ b/.cursor/skills/update-ticloud-cli-command/references/sdk.md @@ -0,0 +1,18 @@ +# TiDB Cloud SDK knowledge + +TiDB Cloud CLI keeps the TiDB Cloud Open API SDK inside the project. + +Key points about the SDK: + +1. Swagger files are the single source of truth for the SDK; they live under `/pkg/tidbcloud/v1beta1`. +2. The openapi-generator library generates the SDK from Swagger. You do not need to know openapi-generator in detail; use the commands in the `Makefile` to invoke it. +3. `internal/service/cloud/api_client.go` combines the generated SDKs into a single client. +4. Mocks for `api_client.go` must also be generated. + +It is the user's responsibility to add or change Swagger files under `pkg/tidbcloud/`. + +It is the AI agent's responsibility to generate the SDK. Follow the workflow below: + +1. Run the appropriate command based on where Swagger files were added or changed. For example, if `pkg/tidbcloud/serverless` changed, run `generate-v1beta1-serverless-client`. If you cannot determine which Swagger changed, run `make generate-v1beta1-client` to generate all. +2. Update `internal/service/cloud/api_client.go` according to the generated SDK. If new methods appear in the SDK, add new interface methods and implementations in `api_client.go` following the existing code style. If new API clients appear, wrap them in the `ClientDelegate` struct. +3. Run `make generate-mocks` to refresh mocks. Do not edit mock files by hand; they must be auto-generated by this command. \ No newline at end of file diff --git a/.cursor/skills/update-ticloud-cli-command/references/tidbcloud-cli.md b/.cursor/skills/update-ticloud-cli-command/references/tidbcloud-cli.md new file mode 100644 index 00000000..8142dff0 --- /dev/null +++ b/.cursor/skills/update-ticloud-cli-command/references/tidbcloud-cli.md @@ -0,0 +1,63 @@ +# TiDB Cloud CLI Knowledge + +## TiDB Cloud + +TiDB Cloud is a fully-managed Database-as-a-Service (DBaaS) that brings TiDB, an open-source Hybrid Transactional and Analytical Processing (HTAP) database, to your cloud. TiDB Cloud offers an easy way to deploy and manage databases to let you focus on your applications, not the complexities of the databases. You can create TiDB Cloud clusters to quickly build mission-critical applications on Amazon Web Services (AWS), Google Cloud, Microsoft Azure, and Alibaba Cloud. + +## TiDB Cloud CLI + +TiDB Cloud provides a command-line interface (CLI) `ticloud` for you to interact with TiDB Cloud from your terminal with a few lines of commands. For example, you can easily perform the following operations using `ticloud`: + +- Create, delete, and list your clusters. +- Import data to your clusters. +- Export data from your clusters. + +### TiDB Cloud CLI Auth + +- Create a user profile with your TiDB Cloud API key + + ```shell + ticloud config create + ``` + + > **Warning:** + > + > The profile name **MUST NOT** contain `.`. + +- Log into TiDB Cloud with authentication: + + ```shell + ticloud auth login + ``` + + After successful login, an OAuth token will be assigned to the current profile. If no profiles exist, the token will be assigned to a profile named `default`. + +> **Note:** +> +> In the preceding two methods, the TiDB Cloud API key takes precedence over the OAuth token. If both are available, the API key will be used. + +### Use the TiDB Cloud CLI + +View all commands available: + +```shell +ticloud --help +``` + +Verify that you are using the latest version: + +```shell +ticloud version +``` + +If not, update to the latest version: + +```shell +ticloud update +``` + +Create a TiDB Cloud cluster: + +```shell +ticloud serverless create +``` \ No newline at end of file diff --git a/.cursor/skills/update-ticloud-cli-command/references/ui.md b/.cursor/skills/update-ticloud-cli-command/references/ui.md new file mode 100644 index 00000000..2c957b71 --- /dev/null +++ b/.cursor/skills/update-ticloud-cli-command/references/ui.md @@ -0,0 +1,53 @@ +# TiDB Cloud UI + +## Overview + +The basic UI lives under `internal/ui`; command-specific UI is under each command package (e.g. `assets/example/ui.go`). + +Live example: `internal/cli/serverless/changefeed/ui.go` + +## Forbidden + +github.com/AlecAivazis/survey/v2 is no longer maintained. + +Use it only for deletion confirmation. Do not use it for any other case. + +Deletion confirmation example: +``` +if err := survey.AskOne(&survey.Input{Message: DeleteConfirmPrompt}, &confirm); err != nil { + return err + } +``` + +## Selection UI (IDs, enums, predefined options) + +Use selection UI for IDs, enum fields, and any fixed option set. + +- Use `ui.InitialSelectModel(items, prompt)` with `bubbletea`. +- Enable pagination and filter: + - `model.EnablePagination(6)` + - `model.EnableFilter()` +- Handle interrupt: + - If model reports `Interrupted`, return `util.InterruptError`. +- Example pattern: see `assets/example/ui.go` (`GetSelectedExample`). + +Live Example: see `internal/service/cloud/logic.go` (`GetSelectedChangefeed`,`GetSelectedCluster`) + +## Simple input UI (single field) + +Use `ui.InitialOneInputModel` for any single field. + +- Example: `GetDisplayNameInput` in `assets/example/ui.go`. + +## Complex composite input (multiple related fields) + +For grouped or multi-field inputs, use `ui.InitialInputModel`. + +- Provide a list of keys and a description map for each field. +- Read values from `textInput.Inputs[i].Value()`. +- Validate each required field and return explicit errors. +- Example pattern: `GetS3Inputs` in `assets/example/ui.go`. + +## Others + +More UI components are in `internal/ui`. If what you need is not there, create a basic UI under `internal/ui` based on Bubbletea. See `references/bubbletea.md` for more information. diff --git a/.cursor/skills/update-ticloud-cli-command/references/ut.md b/.cursor/skills/update-ticloud-cli-command/references/ut.md new file mode 100644 index 00000000..9c4dd622 --- /dev/null +++ b/.cursor/skills/update-ticloud-cli-command/references/ut.md @@ -0,0 +1,16 @@ +# Unit tests + +This document describes how to write TiDB Cloud CLI unit tests. + +Follow the patterns under `internal/cli/serverless/branch`: + +- Use generated mocks for service calls. +- Use `suite.Suite` with `SetupTest` to set `NO_COLOR`, create `iostream.Test()`, and inject a mock client. +- Use table-driven tests for flag combinations and error cases. +- Validate both `stdout` and `stderr` contents exactly. +- Assert mock expectations only on success paths. +- Cover: + - Required flags errors. + - Shorthand flags (`-c`, `-b`, `-o`, etc.). + - Output formats (`json` vs human). + - Multi-page list behavior when applicable. \ No newline at end of file diff --git a/Makefile b/Makefile index 051a8013..cd6ae38a 100644 --- a/Makefile +++ b/Makefile @@ -22,9 +22,9 @@ setup: deps devtools ## Set up dev env generate-mocks: ## Generate mock objects @echo "==> Generating mock objects" go install github.com/vektra/mockery/v2@v2.53.5 - mockery --name TiDBCloudClient --recursive --output=internal/mock --outpkg mock --filename api_client.go - mockery --name EventsSender --recursive --output=internal/mock --outpkg mock --filename sender.go - mockery --name Uploader --recursive --output=internal/mock --outpkg mock --filename uploader.go + mockery --name TiDBCloudClient --recursive --dir=internal/service/cloud --output=internal/mock --outpkg mock --filename api_client.go + mockery --name EventsSender --recursive --dir=internal/telemetry --output=internal/mock --outpkg mock --filename sender.go + mockery --name Uploader --recursive --dir=internal/service/aws/s3 --output=internal/mock --outpkg mock --filename uploader.go .PHONY: addcopy addcopy: ## Add copyright to all files diff --git a/internal/flag/flag.go b/internal/flag/flag.go index 315d91b4..e81a11df 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -15,6 +15,7 @@ package flag const ( + ExampleID string = "example-id" ClusterID string = "cluster-id" ClusterIDShort string = "c" LocalConcurrency string = "local.concurrency" diff --git a/internal/ui/test_one_input_model.go b/internal/ui/test_one_input_model.go new file mode 100644 index 00000000..395a0ac8 --- /dev/null +++ b/internal/ui/test_one_input_model.go @@ -0,0 +1,91 @@ +// Copyright 2026 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/juju/errors" + "github.com/tidbcloud/tidbcloud-cli/internal/config" + "github.com/tidbcloud/tidbcloud-cli/internal/util" +) + +type TextOneInputModel struct { + Prompt string + Input textinput.Model + Err error + Interrupted bool +} + +func (m TextOneInputModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m TextOneInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + return m, tea.Quit + case tea.KeyCtrlC, tea.KeyEsc: + m.Interrupted = true + return m, tea.Quit + } + + case errMsg: + m.Err = msg + return m, nil + } + + m.Input, cmd = m.Input.Update(msg) + return m, cmd +} + +func (m TextOneInputModel) View() string { + return fmt.Sprintf( + "%s\n\n%s\n\n%s\n\n", + m.Prompt, + m.Input.View(), + helpMessageStyle("Press Enter to submit (esc to quit)"), + ) +} + +// InitialOneInputModel runs an interactive single-line input and returns the model and any error. +// view is the prompt line shown above the input. placeholder is used as the input placeholder text. +func InitialOneInputModel(prompt, placeholder string) (TextOneInputModel, error) { + ti := textinput.New() + ti.Placeholder = placeholder + ti.Focus() + ti.PromptStyle = config.FocusedStyle + ti.TextStyle = config.FocusedStyle + + p := tea.NewProgram(TextOneInputModel{Prompt: prompt, Input: ti}) + model, err := p.Run() + finalModel := model.(TextOneInputModel) + if err != nil { + return finalModel, errors.Trace(err) + } + if finalModel.Interrupted { + return finalModel, util.InterruptError + } + if finalModel.Err != nil { + return finalModel, finalModel.Err + } + return finalModel, nil +}