Skip to content

Commit 5b12089

Browse files
authored
feat(cli): add kernel status command (#123)
Displays overall system status and per-group/component breakdown from the API's /status endpoint, with color-coded output matching the dashboard indicator. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk: adds a read-only status check command and small refactors to centralize base URL selection, with minimal impact on existing deploy flows. > > **Overview** > Adds a new unauthenticated `kernel status` command that fetches `/status` from the API and prints a color-coded service/group/component health summary (or pretty JSON via `--output json`). > > Centralizes API base URL selection in `util.GetBaseURL()` and updates GitHub deploy and the new status command to use it; also registers `status` in `rootCmd` and exempts it from auth checks. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 95e2273. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 8898a01 commit 5b12089

File tree

4 files changed

+143
-19
lines changed

4 files changed

+143
-19
lines changed

cmd/deploy.go

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,7 @@ func runDeployGithub(cmd *cobra.Command, args []string) error {
147147
if strings.TrimSpace(apiKey) == "" {
148148
return fmt.Errorf("KERNEL_API_KEY is required for github deploy")
149149
}
150-
baseURL := os.Getenv("KERNEL_BASE_URL")
151-
if strings.TrimSpace(baseURL) == "" {
152-
baseURL = "https://api.onkernel.com"
153-
}
150+
baseURL := util.GetBaseURL()
154151

155152
var body bytes.Buffer
156153
mw := multipart.NewWriter(&body)
@@ -561,22 +558,22 @@ func followDeployment(ctx context.Context, client kernel.Client, deploymentID st
561558
if err == nil {
562559
fmt.Println(string(bs))
563560
}
564-
// Check for terminal states
565-
if data.Event == "deployment_state" {
566-
deploymentState := data.AsDeploymentState()
567-
status := deploymentState.Deployment.Status
568-
if status == string(kernel.DeploymentGetResponseStatusFailed) ||
569-
status == string(kernel.DeploymentGetResponseStatusStopped) {
570-
return fmt.Errorf("deployment %s: %s", status, deploymentState.Deployment.StatusReason)
561+
// Check for terminal states
562+
if data.Event == "deployment_state" {
563+
deploymentState := data.AsDeploymentState()
564+
status := deploymentState.Deployment.Status
565+
if status == string(kernel.DeploymentGetResponseStatusFailed) ||
566+
status == string(kernel.DeploymentGetResponseStatusStopped) {
567+
return fmt.Errorf("deployment %s: %s", status, deploymentState.Deployment.StatusReason)
568+
}
569+
if status == string(kernel.DeploymentGetResponseStatusRunning) {
570+
return nil
571+
}
571572
}
572-
if status == string(kernel.DeploymentGetResponseStatusRunning) {
573-
return nil
573+
if data.Event == "error" {
574+
errorEv := data.AsErrorEvent()
575+
return fmt.Errorf("%s: %s", errorEv.Error.Code, errorEv.Error.Message)
574576
}
575-
}
576-
if data.Event == "error" {
577-
errorEv := data.AsErrorEvent()
578-
return fmt.Errorf("%s: %s", errorEv.Error.Code, errorEv.Error.Message)
579-
}
580577
continue
581578
}
582579

cmd/root.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ func isAuthExempt(cmd *cobra.Command) bool {
9090

9191
// Check if the top-level command is in the exempt list
9292
switch topLevel.Name() {
93-
case "login", "logout", "help", "completion", "create", "mcp", "upgrade":
93+
case "login", "logout", "help", "completion", "create", "mcp", "upgrade", "status":
9494
return true
9595
case "auth":
9696
// Only exempt the auth command itself (status display), not its subcommands
@@ -147,6 +147,7 @@ func init() {
147147
rootCmd.AddCommand(createCmd)
148148
rootCmd.AddCommand(mcp.MCPCmd)
149149
rootCmd.AddCommand(upgradeCmd)
150+
rootCmd.AddCommand(statusCmd)
150151

151152
rootCmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error {
152153
// running synchronously so we never slow the command

cmd/status.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"os"
8+
"time"
9+
10+
"github.com/kernel/cli/pkg/util"
11+
"github.com/pterm/pterm"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
type statusComponent struct {
16+
Name string `json:"name"`
17+
Status string `json:"status"`
18+
}
19+
20+
type statusGroup struct {
21+
Name string `json:"name"`
22+
Status string `json:"status"`
23+
Components []statusComponent `json:"components"`
24+
}
25+
26+
type statusResponse struct {
27+
Status string `json:"status"`
28+
Groups []statusGroup `json:"groups"`
29+
}
30+
31+
var statusCmd = &cobra.Command{
32+
Use: "status",
33+
Short: "Check the operational status of Kernel services",
34+
RunE: runStatus,
35+
}
36+
37+
func init() {
38+
statusCmd.Flags().StringP("output", "o", "", "Output format (json)")
39+
}
40+
41+
func runStatus(cmd *cobra.Command, args []string) error {
42+
output, _ := cmd.Flags().GetString("output")
43+
44+
client := &http.Client{Timeout: 10 * time.Second}
45+
resp, err := client.Get(util.GetBaseURL() + "/status")
46+
if err != nil {
47+
pterm.Error.Println("Could not reach Kernel API. Check https://status.kernel.sh for updates.")
48+
return nil
49+
}
50+
defer resp.Body.Close()
51+
52+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
53+
pterm.Error.Println("Could not reach Kernel API. Check https://status.kernel.sh for updates.")
54+
return nil
55+
}
56+
57+
var status statusResponse
58+
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
59+
return fmt.Errorf("invalid response: %w", err)
60+
}
61+
62+
if output == "json" {
63+
enc := json.NewEncoder(os.Stdout)
64+
enc.SetIndent("", " ")
65+
return enc.Encode(status)
66+
}
67+
68+
printStatus(status)
69+
return nil
70+
}
71+
72+
// Colors match the dashboard's api-status-indicator.tsx
73+
var statusDisplay = map[string]struct {
74+
label string
75+
rgb pterm.RGB
76+
}{
77+
"operational": {label: "Operational", rgb: pterm.NewRGB(31, 163, 130)},
78+
"degraded_performance": {label: "Degraded Performance", rgb: pterm.NewRGB(245, 158, 11)},
79+
"partial_outage": {label: "Partial Outage", rgb: pterm.NewRGB(242, 85, 51)},
80+
"full_outage": {label: "Major Outage", rgb: pterm.NewRGB(239, 68, 68)},
81+
"maintenance": {label: "Maintenance", rgb: pterm.NewRGB(36, 99, 235)},
82+
"unknown": {label: "Unknown", rgb: pterm.NewRGB(128, 128, 128)},
83+
}
84+
85+
func getStatusDisplay(status string) (string, pterm.RGB) {
86+
if d, ok := statusDisplay[status]; ok {
87+
return d.label, d.rgb
88+
}
89+
return "Unknown", pterm.NewRGB(128, 128, 128)
90+
}
91+
92+
func coloredDot(rgb pterm.RGB) string {
93+
return rgb.Sprint("●")
94+
}
95+
96+
func printStatus(resp statusResponse) {
97+
label, rgb := getStatusDisplay(resp.Status)
98+
header := fmt.Sprintf("Kernel Status: %s", rgb.Sprint(label))
99+
pterm.Println()
100+
pterm.Println(" " + header)
101+
102+
for _, group := range resp.Groups {
103+
pterm.Println()
104+
if len(group.Components) == 0 {
105+
groupLabel, groupColor := getStatusDisplay(group.Status)
106+
pterm.Printf(" %s %s %s\n", coloredDot(groupColor), pterm.Bold.Sprint(group.Name), groupLabel)
107+
} else {
108+
pterm.Println(" " + pterm.Bold.Sprint(group.Name))
109+
for _, comp := range group.Components {
110+
compLabel, compColor := getStatusDisplay(comp.Status)
111+
pterm.Printf(" %s %-20s %s\n", coloredDot(compColor), comp.Name, compLabel)
112+
}
113+
}
114+
}
115+
pterm.Println()
116+
}

pkg/util/client.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"net/http"
99
"os"
10+
"strings"
1011
"sync/atomic"
1112

1213
"github.com/kernel/cli/pkg/update"
@@ -84,6 +85,15 @@ func showUpgradeMessage() {
8485
}
8586
}
8687

88+
// GetBaseURL returns the Kernel API base URL, falling back to production.
89+
// KERNEL_BASE_URL is never set in .env; it exists solely for internal dev/staging overrides.
90+
func GetBaseURL() string {
91+
if u := os.Getenv("KERNEL_BASE_URL"); strings.TrimSpace(u) != "" {
92+
return u
93+
}
94+
return "https://api.onkernel.com"
95+
}
96+
8797
// IsNotFound returns true if the error is a Kernel API error with HTTP 404.
8898
func IsNotFound(err error) bool {
8999
if err == nil {

0 commit comments

Comments
 (0)