From 2045f25ae2cedc05154595ebffe8e540e4d3de14 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 12:41:18 +0530 Subject: [PATCH] feat: confirm destructive operations Add confirmDestructive, a [y/N] prompt gated on CanPrompt (interactive TTY, not --quiet). Non-interactive/scripted runs auto-approve so automation is never blocked; a declined prompt prints 'Cancelled.' and exits cleanly. Apply it to the hard-to-undo commands: agent remove, credentials remove, checkpoint delete, skills remove, trust remove, permissions reset, taste reset (explicitly 'cannot be undone'), and learn clear. Skip eval cache-clear (disposable cache). Extract parseConfirm for the yes/no decision and unit-test it. --- cmd/agent.go | 9 +++++++++ cmd/checkpoint.go | 8 ++++++++ cmd/confirm_test.go | 18 ++++++++++++++++++ cmd/credentials.go | 8 ++++++++ cmd/learn_cmd.go | 8 ++++++++ cmd/permissions.go | 8 ++++++++ cmd/skills_cmd.go | 8 ++++++++ cmd/taste.go | 8 ++++++++ cmd/trust.go | 8 ++++++++ cmd/ui.go | 26 ++++++++++++++++++++++++++ 10 files changed, 109 insertions(+) create mode 100644 cmd/confirm_test.go diff --git a/cmd/agent.go b/cmd/agent.go index 26fa564e..0497e189 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -174,6 +174,15 @@ func runAgentRemove(_ *cobra.Command, args []string) error { return err } + ok, err := confirmDestructive(fmt.Sprintf("Remove agent %q (%s)?", a.Name, a.FilePath)) + if err != nil { + return err + } + if !ok { + fmt.Printf("%s\n", auditTint("Cancelled.", textMuted)) + return nil + } + if err := os.Remove(a.FilePath); err != nil { return fmt.Errorf("remove %s: %w", a.FilePath, err) } diff --git a/cmd/checkpoint.go b/cmd/checkpoint.go index 46b87b46..4d0325b6 100644 --- a/cmd/checkpoint.go +++ b/cmd/checkpoint.go @@ -105,6 +105,14 @@ var checkpointDeleteCmd = &cobra.Command{ Short: "Delete a named checkpoint", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + ok, err := confirmDestructive(fmt.Sprintf("Delete checkpoint %q?", args[0])) + if err != nil { + return err + } + if !ok { + cmd.Printf("%s\n", auditTint("Cancelled.", textMuted)) + return nil + } if err := session.DeleteNamedCheckpoint(args[0]); err != nil { return err } diff --git a/cmd/confirm_test.go b/cmd/confirm_test.go new file mode 100644 index 00000000..04b302e2 --- /dev/null +++ b/cmd/confirm_test.go @@ -0,0 +1,18 @@ +package cmd + +import "testing" + +func TestParseConfirm(t *testing.T) { + yes := []string{"y", "Y", "yes", "Yes", "YES", " y ", "\tyes\n"} + for _, in := range yes { + if !parseConfirm(in) { + t.Errorf("parseConfirm(%q) = false, want true", in) + } + } + no := []string{"", "n", "N", "no", "No", "NO", "maybe", "1", "true", "yess", " yyy"} + for _, in := range no { + if parseConfirm(in) { + t.Errorf("parseConfirm(%q) = true, want false", in) + } + } +} diff --git a/cmd/credentials.go b/cmd/credentials.go index a9cac742..175621ab 100644 --- a/cmd/credentials.go +++ b/cmd/credentials.go @@ -30,6 +30,14 @@ var credentialsRemoveCmd = &cobra.Command{ Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() + ok, err := confirmDestructive(fmt.Sprintf("Remove stored API key(s) for %q from %s?", args[0], graycodeconfig.CredentialStoreName())) + if err != nil { + return err + } + if !ok { + cmd.Printf("%s\n", auditTint("Cancelled.", textMuted)) + return nil + } removed, err := graycodeconfig.RemoveStoredCredential(ctx, args[0]) if err != nil { return err diff --git a/cmd/learn_cmd.go b/cmd/learn_cmd.go index fd19eaa1..c7575c8c 100644 --- a/cmd/learn_cmd.go +++ b/cmd/learn_cmd.go @@ -73,6 +73,14 @@ var learnClearCmd = &cobra.Command{ cmd.Println(auditTint("no lessons to clear", textMuted)) return nil } + ok, err := confirmDestructive(fmt.Sprintf("Remove all %d lesson(s)?", n)) + if err != nil { + return err + } + if !ok { + cmd.Println(auditTint("Cancelled.", textMuted)) + return nil + } si.Clear() cmd.Println(auditTint("cleared "+strconv.Itoa(n)+" lesson(s)", textPrimary)) return nil diff --git a/cmd/permissions.go b/cmd/permissions.go index 44ab3ecc..26a733e5 100644 --- a/cmd/permissions.go +++ b/cmd/permissions.go @@ -137,6 +137,14 @@ var permissionsResetCmd = &cobra.Command{ cmd.Println(auditTint("No persisted permission rules.", textMuted)) return nil } + ok, err := confirmDestructive("Remove all persisted permission rules?") + if err != nil { + return err + } + if !ok { + cmd.Println(auditTint("Cancelled.", textMuted)) + return nil + } if err := store.Save(); err != nil { return err } diff --git a/cmd/skills_cmd.go b/cmd/skills_cmd.go index 1ce94132..70d3f78c 100644 --- a/cmd/skills_cmd.go +++ b/cmd/skills_cmd.go @@ -112,6 +112,14 @@ var skillsRemoveCmd = &cobra.Command{ Short: "Remove an installed skill", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + ok, err := confirmDestructive(fmt.Sprintf("Remove skill %q?", args[0])) + if err != nil { + return err + } + if !ok { + fmt.Printf("%s\n", auditTint("Cancelled.", textMuted)) + return nil + } if err := plugin.Remove(args[0]); err != nil { return err } diff --git a/cmd/taste.go b/cmd/taste.go index 29df55f7..145499ff 100644 --- a/cmd/taste.go +++ b/cmd/taste.go @@ -171,6 +171,14 @@ func runTasteReset(_ *cobra.Command, _ []string) error { } projectID := getProjectID() + ok, err := confirmDestructive(fmt.Sprintf("Clear all taste preferences for project %q? This cannot be undone.", projectID)) + if err != nil { + return err + } + if !ok { + fmt.Printf("%s\n", auditTint("Cancelled.", textMuted)) + return nil + } if err := store.Delete(projectID); err != nil { return fmt.Errorf("reset profile: %w", err) } diff --git a/cmd/trust.go b/cmd/trust.go index c96e9444..3abf64ce 100644 --- a/cmd/trust.go +++ b/cmd/trust.go @@ -71,6 +71,14 @@ var trustRemoveCmd = &cobra.Command{ if err != nil { return err } + ok, err := confirmDestructive(fmt.Sprintf("Remove trust for %q?", path)) + if err != nil { + return err + } + if !ok { + cmd.Printf("%s\n", auditTint("Cancelled.", textMuted)) + return nil + } if err := s.Untrust(path); err != nil { return err } diff --git a/cmd/ui.go b/cmd/ui.go index aa1d2156..44c391d3 100644 --- a/cmd/ui.go +++ b/cmd/ui.go @@ -2,6 +2,7 @@ package cmd import ( "os" + "strings" "golang.org/x/term" ) @@ -19,6 +20,31 @@ func CanPrompt() bool { return !quietFlag && stdinIsTerminal() } +// confirmDestructive prompts the user to confirm a destructive, hard-to-undo +// operation. It returns true only on an explicit "y"/"yes". When the user is +// not interactive (--quiet, piped stdin, or no TTY), it returns true so +// scripts and automation are never blocked. A declined prompt returns false +// with no error; callers should abort the operation and exit cleanly. +func confirmDestructive(prompt string) (bool, error) { + if !CanPrompt() { + return true, nil + } + input := openPromptInput() + defer input.close() + answer, err := input.readLine(prompt + " [y/N] ") + if err != nil { + return false, err + } + return parseConfirm(answer), nil +} + +// parseConfirm reports whether a user answer is an explicit affirmative +// ("y" / "yes", case-insensitive, trimmed). Anything else declines. +func parseConfirm(answer string) bool { + answer = strings.ToLower(strings.TrimSpace(answer)) + return answer == "y" || answer == "yes" +} + // ShouldColor returns true when colored output is appropriate for stdout. // Respects --quiet, NO_COLOR, FORCE_COLOR, and TTY state. func ShouldColor() bool {