diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..edc4cd9 --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,68 @@ +// cmd/completion.go +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Long: `Generate shell completion scripts for Citadel CLI. + +To load completions: + +Bash: + $ source <(citadel completion bash) + + # To load completions for each session, execute once: + # Linux: + $ citadel completion bash > /etc/bash_completion.d/citadel + # macOS: + $ citadel completion bash > $(brew --prefix)/etc/bash_completion.d/citadel + +Zsh: + # If shell completion is not already enabled in your environment, + # you will need to enable it. You can execute the following once: + $ echo "autoload -U compinit; compinit" >> ~/.zshrc + + # To load completions for each session, execute once: + $ citadel completion zsh > "${fpath[1]}/_citadel" + + # You will need to start a new shell for this setup to take effect. + +Fish: + $ citadel completion fish | source + + # To load completions for each session, execute once: + $ citadel completion fish > ~/.config/fish/completions/citadel.fish + +PowerShell: + PS> citadel completion powershell | Out-String | Invoke-Expression + + # To load completions for every new session, add the output to your profile: + PS> citadel completion powershell > citadel.ps1 + # and source this file from your PowerShell profile. +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + Run: func(cmd *cobra.Command, args []string) { + switch args[0] { + case "bash": + rootCmd.GenBashCompletionV2(os.Stdout, true) + case "zsh": + rootCmd.GenZshCompletion(os.Stdout) + case "fish": + rootCmd.GenFishCompletion(os.Stdout, true) + case "powershell": + rootCmd.GenPowerShellCompletionWithDesc(os.Stdout) + } + }, +} + +func init() { + rootCmd.AddCommand(completionCmd) +} diff --git a/cmd/logout.go b/cmd/logout.go index 775043f..d057304 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "os" + "strings" "time" "github.com/aceteam-ai/citadel-cli/internal/network" @@ -14,6 +15,7 @@ import ( var ( logoutKeepRegistration bool + logoutForce bool ) var logoutCmd = &cobra.Command{ @@ -35,14 +37,30 @@ to the same organization later.`, } func runLogout(cmd *cobra.Command, args []string) { - fmt.Println("--- Disconnecting from the AceTeam Network ---") - // Check if connected or has state if !network.IsGlobalConnected() && !network.HasState() { fmt.Println("Not connected to any network.") return } + // Confirm before disconnecting + if !logoutForce { + action := "Disconnect and deregister from the AceTeam Network?" + if logoutKeepRegistration { + action = "Disconnect from the AceTeam Network?" + } + fmt.Printf("%s (y/N) ", action) + var response string + fmt.Scanln(&response) + response = strings.TrimSpace(strings.ToLower(response)) + if response != "y" && response != "yes" { + fmt.Println("Aborted.") + return + } + } + + fmt.Println("--- Disconnecting from the AceTeam Network ---") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -115,4 +133,5 @@ func init() { logoutCmd.Flags().BoolVar(&logoutKeepRegistration, "keep-registration", false, "Only disconnect locally, keep node registered in Headscale (for temporary disconnects)") + logoutCmd.Flags().BoolVarP(&logoutForce, "force", "f", false, "Skip confirmation prompt.") } diff --git a/cmd/manifest.go b/cmd/manifest.go index ceb9950..05c9cd0 100644 --- a/cmd/manifest.go +++ b/cmd/manifest.go @@ -89,7 +89,7 @@ func findAndReadManifest() (*CitadelManifest, string, error) { manifestData, err := os.ReadFile(manifestPath) if err != nil { if os.IsNotExist(err) { - return nil, "", fmt.Errorf("manifest not found at %s. The configuration is incomplete or corrupt", manifestPath) + return nil, "", fmt.Errorf("manifest not found at %s. Run 'citadel init' to regenerate the configuration", manifestPath) } return nil, "", fmt.Errorf("could not read manifest from global path %s: %w", manifestPath, err) } diff --git a/cmd/nodes.go b/cmd/nodes.go index faa10d6..ba23a83 100644 --- a/cmd/nodes.go +++ b/cmd/nodes.go @@ -5,6 +5,7 @@ Copyright © 2025 Jason Sun package cmd import ( + "encoding/json" "fmt" "os" "text/tabwriter" @@ -14,6 +15,8 @@ import ( "github.com/spf13/cobra" ) +var nodesJSON bool + // nodesCmd represents the nodes command var nodesCmd = &cobra.Command{ Use: "nodes", @@ -33,7 +36,21 @@ registered compute nodes, showing their status, IP address, and last-seen time.` } if len(nodes) == 0 { - fmt.Println("🤷 No nodes found in your fabric.") + if nodesJSON { + fmt.Println("[]") + } else { + fmt.Println("🤷 No nodes found in your fabric.") + } + return + } + + if nodesJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(nodes); err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to encode JSON: %v\n", err) + os.Exit(1) + } return } @@ -58,4 +75,5 @@ registered compute nodes, showing their status, IP address, and last-seen time.` func init() { rootCmd.AddCommand(nodesCmd) + nodesCmd.Flags().BoolVar(&nodesJSON, "json", false, "Output in JSON format") } diff --git a/cmd/peers.go b/cmd/peers.go index b67eefb..340d0e7 100644 --- a/cmd/peers.go +++ b/cmd/peers.go @@ -3,6 +3,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "os" "strings" @@ -18,6 +19,7 @@ var ( peersCapability string peersOnlineOnly bool peersIncludeStats bool + peersJSON bool ) var peersCmd = &cobra.Command{ @@ -76,7 +78,9 @@ func runPeers(cmd *cobra.Command, args []string) { } if len(nodes) == 0 { - if len(capabilities) > 0 { + if peersJSON { + fmt.Println("[]") + } else if len(capabilities) > 0 { fmt.Printf("No peers found matching capabilities: %s\n", strings.Join(capabilities, ", ")) } else { fmt.Println("No peers found in your organization.") @@ -84,6 +88,16 @@ func runPeers(cmd *cobra.Command, args []string) { return } + if peersJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(nodes); err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to encode JSON: %v\n", err) + os.Exit(1) + } + return + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) if peersIncludeStats { @@ -159,4 +173,5 @@ func init() { peersCmd.Flags().StringVar(&peersCapability, "capability", "", "Filter by capability tags (comma-separated, e.g., gpu:a100,llm:llama3)") peersCmd.Flags().BoolVar(&peersOnlineOnly, "online-only", false, "Only show online peers") peersCmd.Flags().BoolVar(&peersIncludeStats, "stats", false, "Include hardware stats (CPU, memory, GPU usage)") + peersCmd.Flags().BoolVar(&peersJSON, "json", false, "Output in JSON format") } diff --git a/cmd/root.go b/cmd/root.go index f3f2134..cc2af47 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( "github.com/aceteam-ai/citadel-cli/internal/tui" "github.com/aceteam-ai/citadel-cli/internal/update" + "github.com/fatih/color" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -31,6 +32,7 @@ var nexusURL string var authServiceURL string var debugMode bool var daemonMode bool +var noColorGlobal bool // deferredUpdateNotification stores update info when TUI will handle the display var deferredUpdateNotification string @@ -127,6 +129,11 @@ Use 'citadel help' to see all available commands.`, } }, PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Handle global --no-color flag and NO_COLOR env var + if noColorGlobal || os.Getenv("NO_COLOR") != "" { + color.NoColor = true + } + // Always log the command (Log() handles console output based on --debug) fullCmd := "citadel" if cmd.Name() != "citadel" { @@ -229,6 +236,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&nexusURL, "nexus", "https://nexus.aceteam.ai", "The URL of the AceTeam Nexus server") rootCmd.PersistentFlags().StringVar(&authServiceURL, "auth-service", getEnvOrDefault("CITADEL_AUTH_HOST", "https://aceteam.ai"), "The URL of the authentication service") rootCmd.PersistentFlags().BoolVar(&debugMode, "debug", false, "Enable debug output") + rootCmd.PersistentFlags().BoolVar(&noColorGlobal, "no-color", false, "Disable colorized output") rootCmd.Flags().BoolVar(&daemonMode, "daemon", false, "Run in background daemon mode (no TUI)") // Cobra also supports local flags, which will only run diff --git a/cmd/run.go b/cmd/run.go index 5c85e65..959f9a4 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -93,6 +93,7 @@ func runAllServices() { fmt.Printf("🚀 Starting service: %s (native)\n", service.Name) if err := startNativeService(service.Name, configDir); err != nil { fmt.Fprintf(os.Stderr, " ❌ Failed to start service %s: %v\n", service.Name, err) + fmt.Fprintf(os.Stderr, " Hint: Run 'citadel logs %s' to see detailed output.\n", service.Name) os.Exit(1) } } else { @@ -105,6 +106,7 @@ func runAllServices() { fmt.Printf("🚀 Starting service: %s\n", service.Name) if err := startService(service.Name, fullComposePath); err != nil { fmt.Fprintf(os.Stderr, " ❌ Failed to start service %s: %v\n", service.Name, err) + fmt.Fprintf(os.Stderr, " Hint: Run 'citadel logs %s' to see detailed output.\n", service.Name) os.Exit(1) } } diff --git a/cmd/status.go b/cmd/status.go index 5f4bc20..e5a610a 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -31,8 +31,8 @@ var ( warnColor = color.New(color.FgYellow) badColor = color.New(color.FgRed) labelColor = color.New(color.Bold) - noColor bool // Flag to disable color interactiveUI bool // Flag to enable interactive TUI dashboard + statusJSON bool // Flag to output JSON format ) var statusCmd = &cobra.Command{ @@ -51,13 +51,14 @@ and the state of all managed services.`, # Interactive dashboard with live updates citadel status -i`, Run: func(cmd *cobra.Command, args []string) { - // Handle the --no-color flag - if noColor { - color.NoColor = true + // JSON output mode + if statusJSON { + runJSONStatus() + return } // Check if interactive mode requested and available - if interactiveUI && tui.ShouldUseInteractive(true, noColor) { + if interactiveUI && tui.ShouldUseInteractive(true, color.NoColor) { runInteractiveDashboard() return } @@ -67,6 +68,21 @@ and the state of all managed services.`, }, } +// runJSONStatus outputs status data as JSON +func runJSONStatus() { + data, err := gatherStatusData() + if err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to gather status: %v\n", err) + os.Exit(1) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(data); err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to encode JSON: %v\n", err) + os.Exit(1) + } +} + // runStandardStatus displays status using the traditional tabwriter format func runStandardStatus() { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) @@ -716,6 +732,6 @@ func formatBytes(b uint64) string { func init() { rootCmd.AddCommand(statusCmd) - statusCmd.Flags().BoolVar(&noColor, "no-color", false, "Disable colorized output") statusCmd.Flags().BoolVarP(&interactiveUI, "interactive", "i", false, "Launch interactive dashboard with live updates") + statusCmd.Flags().BoolVar(&statusJSON, "json", false, "Output in JSON format") } \ No newline at end of file diff --git a/cmd/stop.go b/cmd/stop.go index d829cb2..e7e1d85 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -12,7 +12,11 @@ import ( "github.com/spf13/cobra" ) -var removeContainer bool +var ( + removeContainer bool + forceStop bool + dryRunStop bool +) // stopCmd represents the stop command var stopCmd = &cobra.Command{ @@ -30,8 +34,14 @@ Available services: %s`, strings.Join(services.GetAvailableServices(), ", ")), # Stop all services in the manifest citadel stop - # Stop and remove the container - citadel stop ollama --rm`, + # Stop and remove the container and volumes + citadel stop ollama --rm + + # Skip confirmation prompts (for scripts) + citadel stop --force + + # Preview what would be stopped + citadel stop --dry-run`, Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { if len(args) == 0 { @@ -46,6 +56,30 @@ Available services: %s`, strings.Join(services.GetAvailableServices(), ", ")), }, } +// confirmPrompt asks the user a yes/no question. Returns true if confirmed. +// defaultYes controls the default when the user presses Enter without typing. +// Skips the prompt and returns true if --force is set. +func confirmPrompt(question string, defaultYes bool) bool { + if forceStop { + return true + } + + hint := "(y/N)" + if defaultYes { + hint = "(Y/n)" + } + fmt.Printf("%s %s ", question, hint) + + var response string + fmt.Scanln(&response) + response = strings.TrimSpace(strings.ToLower(response)) + + if response == "" { + return defaultYes + } + return response == "y" || response == "yes" +} + // stopAllServices stops all services defined in the manifest. func stopAllServices() { manifest, configDir, err := findAndReadManifest() @@ -59,6 +93,33 @@ func stopAllServices() { return } + // List services that will be affected + serviceNames := make([]string, len(manifest.Services)) + for i, s := range manifest.Services { + serviceNames[i] = s.Name + } + + if dryRunStop { + fmt.Printf("Would stop %d service(s): %s\n", len(manifest.Services), strings.Join(serviceNames, ", ")) + if removeContainer { + fmt.Println("Would also remove containers and volumes.") + } + return + } + + // Confirm before stopping all services + if removeContainer { + if !confirmPrompt(fmt.Sprintf("Remove %d service(s) and their volumes (%s)?", len(manifest.Services), strings.Join(serviceNames, ", ")), false) { + fmt.Println("Aborted.") + return + } + } else { + if !confirmPrompt(fmt.Sprintf("Stop %d service(s) (%s)?", len(manifest.Services), strings.Join(serviceNames, ", ")), true) { + fmt.Println("Aborted.") + return + } + } + fmt.Printf("--- 🛑 Stopping %d service(s) ---\n", len(manifest.Services)) // Process in reverse order for graceful shutdown @@ -86,6 +147,22 @@ func stopSingleService(serviceName string) { os.Exit(1) } + if dryRunStop { + fmt.Printf("Would stop service: %s\n", serviceName) + if removeContainer { + fmt.Println("Would also remove container and volumes.") + } + return + } + + // Confirm before removing volumes (data-destructive) + if removeContainer { + if !confirmPrompt(fmt.Sprintf("Remove service '%s' and its volumes?", serviceName), false) { + fmt.Println("Aborted.") + return + } + } + // Try to find the service in the manifest manifest, configDir, err := findAndReadManifest() if err != nil { @@ -141,7 +218,7 @@ func stopServiceByCompose(composePath string, remove bool) error { cmd := exec.Command("docker", args...) output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("docker compose down failed: %s", string(output)) + return fmt.Errorf("docker compose down failed:\n%s\n Hint: Is Docker running? Check with 'docker info'", strings.TrimSpace(string(output))) } return nil } @@ -154,7 +231,7 @@ func stopServiceByContainer(serviceName string) error { inspectCmd := exec.Command("docker", "inspect", "--format", "{{.State.Status}}", containerName) output, err := inspectCmd.Output() if err != nil { - return fmt.Errorf("container '%s' not found. Is the service running?", containerName) + return fmt.Errorf("container '%s' not found. Run 'citadel status' to see running services", containerName) } status := strings.TrimSpace(string(output)) @@ -196,4 +273,6 @@ func removeContainerByName(containerName string) error { func init() { rootCmd.AddCommand(stopCmd) stopCmd.Flags().BoolVar(&removeContainer, "rm", false, "Remove the container/volumes after stopping.") + stopCmd.Flags().BoolVarP(&forceStop, "force", "f", false, "Skip confirmation prompts.") + stopCmd.Flags().BoolVar(&dryRunStop, "dry-run", false, "Show what would be stopped without doing it.") } diff --git a/internal/tui/dashboard/panels.go b/internal/tui/dashboard/panels.go index 3d2658c..c28a85e 100644 --- a/internal/tui/dashboard/panels.go +++ b/internal/tui/dashboard/panels.go @@ -154,9 +154,9 @@ type ServicePanel struct { // ServiceStatus represents a service's status type ServiceStatus struct { - Name string - Status string // "running", "stopped", "error" - Uptime string // e.g., "2d 14h" + Name string `json:"name"` + Status string `json:"status"` // "running", "stopped", "error" + Uptime string `json:"uptime,omitempty"` // e.g., "2d 14h" } // Render renders the service panel @@ -209,11 +209,11 @@ type PeerPanel struct { // PeerInfo represents a network peer type PeerInfo struct { - Hostname string - IP string - Online bool - Latency string // e.g., "12ms" - ConnType string // "direct" or "relay" + Hostname string `json:"hostname"` + IP string `json:"ip"` + Online bool `json:"online"` + Latency string `json:"latency,omitempty"` // e.g., "12ms" + ConnType string `json:"connType,omitempty"` // "direct" or "relay" } // Render renders the peer panel diff --git a/internal/tui/dashboard/status.go b/internal/tui/dashboard/status.go index eb574a1..cc52570 100644 --- a/internal/tui/dashboard/status.go +++ b/internal/tui/dashboard/status.go @@ -14,47 +14,47 @@ import ( // StatusData holds all the data for the status dashboard type StatusData struct { - NodeName string - NodeIP string - OrgID string - Tags []string - Connected bool - Version string - LastUpdate time.Time + NodeName string `json:"nodeName"` + NodeIP string `json:"nodeIP"` + OrgID string `json:"orgID,omitempty"` + Tags []string `json:"tags,omitempty"` + Connected bool `json:"connected"` + Version string `json:"version"` + LastUpdate time.Time `json:"lastUpdate"` // System vitals - CPUPercent float64 - MemoryPercent float64 - MemoryUsed string - MemoryTotal string - DiskPercent float64 - DiskUsed string - DiskTotal string + CPUPercent float64 `json:"cpuPercent"` + MemoryPercent float64 `json:"memoryPercent"` + MemoryUsed string `json:"memoryUsed"` + MemoryTotal string `json:"memoryTotal"` + DiskPercent float64 `json:"diskPercent"` + DiskUsed string `json:"diskUsed"` + DiskTotal string `json:"diskTotal"` // GPU info - GPUs []GPUInfo + GPUs []GPUInfo `json:"gpus,omitempty"` // Services - Services []ServiceStatus + Services []ServiceStatus `json:"services,omitempty"` // Peers - Peers []PeerInfo + Peers []PeerInfo `json:"peers,omitempty"` // Job queue (optional) - JobQueueEnabled bool - JobChannel string - PendingJobs int64 - InProgressJobs int64 - FailedJobs int64 + JobQueueEnabled bool `json:"jobQueueEnabled,omitempty"` + JobChannel string `json:"jobChannel,omitempty"` + PendingJobs int64 `json:"pendingJobs,omitempty"` + InProgressJobs int64 `json:"inProgressJobs,omitempty"` + FailedJobs int64 `json:"failedJobs,omitempty"` } // GPUInfo holds GPU information type GPUInfo struct { - Name string - Memory string - Temperature string - Utilization float64 - Driver string + Name string `json:"name"` + Memory string `json:"memory"` + Temperature string `json:"temperature,omitempty"` + Utilization float64 `json:"utilization"` + Driver string `json:"driver,omitempty"` } // StatusModel is the BubbleTea model for the interactive status dashboard