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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions cmd/completion.go
Original file line number Diff line number Diff line change
@@ -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)
}
23 changes: 21 additions & 2 deletions cmd/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"fmt"
"os"
"strings"
"time"

"github.com/aceteam-ai/citadel-cli/internal/network"
Expand All @@ -14,6 +15,7 @@ import (

var (
logoutKeepRegistration bool
logoutForce bool
)

var logoutCmd = &cobra.Command{
Expand All @@ -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()

Expand Down Expand Up @@ -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.")
}
2 changes: 1 addition & 1 deletion cmd/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
20 changes: 19 additions & 1 deletion cmd/nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Copyright © 2025 Jason Sun <[email protected]>
package cmd

import (
"encoding/json"
"fmt"
"os"
"text/tabwriter"
Expand All @@ -14,6 +15,8 @@ import (
"github.com/spf13/cobra"
)

var nodesJSON bool

// nodesCmd represents the nodes command
var nodesCmd = &cobra.Command{
Use: "nodes",
Expand All @@ -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
}

Expand All @@ -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")
}
17 changes: 16 additions & 1 deletion cmd/peers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd

import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
Expand All @@ -18,6 +19,7 @@ var (
peersCapability string
peersOnlineOnly bool
peersIncludeStats bool
peersJSON bool
)

var peersCmd = &cobra.Command{
Expand Down Expand Up @@ -76,14 +78,26 @@ 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.")
}
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 {
Expand Down Expand Up @@ -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")
}
8 changes: 8 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
}
Expand Down
28 changes: 22 additions & 6 deletions cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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")
}
Loading