Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
lint:
name: 🧹 Lint
runs-on: ubuntu-latest
if: github.actor != 'dependabot[bot]' && github.actor != 'dependabot-preview[bot]'
if: github.actor != 'renovate[bot]'

steps:
- name: πŸ“₯ Checkout repository
Expand Down
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Requirements

1. **Separation of concerns**
Keep argument/flag parsing at the edges. Commands should delegate to exported functions that contain the business logic (MVC-inspired: parsing β‰  logic).

2. **Command tree wiring**
The root command registers its direct children (e.g., `rootCmd.AddCommand(log.LogCmd)`), and each child registers its own subcommands.

3. **Pragmatic structure**
Avoid over-engineering: no DI frameworks and don’t create `internal/*` packages for every command. Place shared logic in importable modules so it’s reusable and testable without duplication.

4. **Dependency policy**
Prefer native/standard library solutions over third-party packages whenever possible.

5. **Testing**
For tests, use the `gotest.tools/v3/assert` library instead of `if/fail` conditions.

6. **Backwards compatibility**
This is not a public library; legacy compatibility isn’t required.

7. **CLI UX**
Add colorized output to make the CLI more user-friendly.

8. **Comments**
Do not not add comments unless specifically instructed
2 changes: 1 addition & 1 deletion cmd/feature/feature.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ func init() {
"Root directory of additional features",
)

FeatureCmd.AddCommand(installCmd, listCmd, infoCmd)
FeatureCmd.AddCommand(installCmd, listCmd, infoCmd, storeCmd)
}
43 changes: 4 additions & 39 deletions cmd/feature/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@ package feature

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"sort"
"strings"

"github.com/kloudkit/ws-cli/internals/features"
"github.com/kloudkit/ws-cli/internals/styles"
Expand All @@ -22,13 +19,12 @@ var installCmd = &cobra.Command{
featuresDir, _ := cmd.Flags().GetString("root")
featureName := args[0]

availableFeatures, err := features.ListFeatures(featuresDir)

result, err := features.ListFeatures(featuresDir)
if err != nil {
return fmt.Errorf("failed to list features: %w", err)
}

featureExists := slices.ContainsFunc(availableFeatures, func(f *features.Feature) bool {
featureExists := slices.ContainsFunc(result.Features, func(f *features.Feature) bool {
return f.Name == featureName
})

Expand All @@ -40,31 +36,6 @@ var installCmd = &cobra.Command{
},
}

func runAnsiblePlaybook(featurePath string, vars map[string]any) error {
args := []string{featurePath}

if len(vars) > 0 {
var extraVars []string
for key, value := range vars {
extraVars = append(extraVars, fmt.Sprintf("%s=%v", key, value))
}
args = append(args, "--extra-vars", strings.Join(extraVars, " "))
}

cmd := exec.Command("ansible-playbook", args...)
cmd.Env = append(os.Environ(),
"ANSIBLE_DISPLAY_OK_HOSTS=0",
"ANSIBLE_DISPLAY_FAILED_STDERR=0",
"ANSIBLE_DISPLAY_SKIPPED_HOSTS=0",
"ANSIBLE_SHOW_CUSTOM_STATS=0",
"ANSIBLE_STDOUT_CALLBACK=community.general.unixy",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

return cmd.Run()
}

func installFeatureByName(cmd *cobra.Command, featureName, featuresDir string) error {
fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.Title().Render(fmt.Sprintf("Installing %s", featureName)))

Expand All @@ -84,14 +55,8 @@ func installFeatureByName(cmd *cobra.Command, featureName, featuresDir string) e

featurePath := filepath.Join(featuresDir, featureName+".yaml")

if _, err := features.InfoFeature(featuresDir, featureName); err != nil {
return fmt.Errorf("feature installation failed: %w", err)
}

if err := runAnsiblePlaybook(featurePath, vars); err != nil {
fmt.Fprintln(cmd.ErrOrStderr())
styles.PrintError(cmd.ErrOrStderr(), err.Error())
os.Exit(1)
if err := features.RunPlaybook(featurePath, vars); err != nil {
return fmt.Errorf("installation failed: %w", err)
}

fmt.Fprintln(cmd.OutOrStdout())
Expand Down
29 changes: 11 additions & 18 deletions cmd/feature/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,35 +15,28 @@ var listCmd = &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
featuresDir, _ := cmd.Flags().GetString("root")

availableFeatures, err := features.ListFeatures(featuresDir)
result, err := features.ListFeatures(featuresDir)
if err != nil {
return fmt.Errorf("failed to list features: %w", err)
}

if len(availableFeatures) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), styles.Warning().Render("⚠ No features found"))
return nil
for _, w := range result.Warnings {
styles.PrintWarning(cmd.OutOrStdout(), w)
}

fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.TitleWithCount("Features Available", len(availableFeatures)))

maxNameLen := 0
for _, feature := range availableFeatures {
if len(feature.Name) > maxNameLen {
maxNameLen = len(feature.Name)
}
if len(result.Features) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), styles.Warning().Render("⚠ No features found"))
return nil
}

var featureItems []any
for _, feature := range availableFeatures {
item := styles.Key().Width(maxNameLen).Render(feature.Name) +
styles.Muted().Render(" β€” ") +
styles.Value().Render(feature.Description)
fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.TitleWithCount("Features Available", len(result.Features)))

featureItems = append(featureItems, item)
items := make([]styles.DescriptionItem, len(result.Features))
for i, f := range result.Features {
items[i] = styles.DescriptionItem{Name: f.Name, Description: f.Description}
}

fmt.Fprintln(cmd.OutOrStdout(), styles.List(featureItems...))
fmt.Fprintln(cmd.OutOrStdout(), styles.List(styles.DescriptionList(items)...))

styles.PrintHints(cmd.OutOrStdout(), [][]string{
{"ws-cli feature install <name>", "Install a feature"},
Expand Down
48 changes: 48 additions & 0 deletions cmd/feature/store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package feature

import (
"fmt"

"github.com/kloudkit/ws-cli/internals/config"
"github.com/kloudkit/ws-cli/internals/env"
"github.com/kloudkit/ws-cli/internals/features"
"github.com/kloudkit/ws-cli/internals/styles"
"github.com/spf13/cobra"
)

var storeCmd = &cobra.Command{
Use: "store",
Short: "List packages available in the feature store",
RunE: func(cmd *cobra.Command, args []string) error {
storeURL := env.String(config.EnvFeaturesStoreURL)
if storeURL == "" {
styles.PrintWarning(cmd.OutOrStdout(), "Feature store not configured (set WS_FEATURES_STORE_URL)")
return nil
}

manifest, err := features.FetchStoreManifest(storeURL)
if err != nil {
return fmt.Errorf("failed to fetch store manifest: %w", err)
}

if len(manifest.Artifacts) == 0 {
styles.PrintWarning(cmd.OutOrStdout(), "Feature store is empty")
return nil
}

fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.TitleWithCount("Feature Store", len(manifest.Artifacts)))

items := make([]styles.DescriptionItem, len(manifest.Artifacts))
for i, a := range manifest.Artifacts {
items[i] = styles.DescriptionItem{Name: a.Name, Description: a.Version}
}

fmt.Fprintln(cmd.OutOrStdout(), styles.List(styles.DescriptionList(items)...))

if manifest.Built != "" {
fmt.Fprintf(cmd.OutOrStdout(), "\n %s\n", styles.Muted().Render("Built: "+manifest.Built))
}

return nil
},
}
100 changes: 16 additions & 84 deletions cmd/info/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,81 +9,13 @@ import (
"github.com/kloudkit/ws-cli/internals/styles"
)

type workspaceMetrics struct {
cpuUsage float64
cpuSeconds float64
memoryTotal uint64
memoryUsed uint64
memoryRSS uint64
diskTotal uint64
diskUsed uint64
fdOpen uint64
fdLimit uint64
gpu *metrics.GPUStats
}

func formatCPUTime(seconds float64) string {
switch {
case seconds < 60:
return fmt.Sprintf("%.1fs", seconds)
case seconds < 3600:
return fmt.Sprintf("%.1fm", seconds/60)
default:
return fmt.Sprintf("%.1fh", seconds/3600)
}
}

func getMetrics(includeGPU bool) (*workspaceMetrics, error) {
cpuUsage, _ := metrics.GetCPUUsagePercent()

cpuStats, err := metrics.GetCPUStats()
if err != nil {
return nil, fmt.Errorf("failed to get CPU stats: %w", err)
}

memStats, err := metrics.GetMemoryStats()
if err != nil {
return nil, fmt.Errorf("failed to get memory stats: %w", err)
}

diskStats, err := metrics.GetDiskStats()
if err != nil {
return nil, fmt.Errorf("failed to get disk stats: %w", err)
}

fdStats, err := metrics.GetFileDescriptorStats()
if err != nil {
return nil, fmt.Errorf("failed to get file descriptor stats: %w", err)
}

m := &workspaceMetrics{
cpuUsage: cpuUsage,
cpuSeconds: cpuStats.UsageSeconds,
memoryTotal: memStats.LimitBytes,
memoryUsed: memStats.UsageBytes,
memoryRSS: memStats.RSSBytes,
diskTotal: diskStats.LimitBytes,
diskUsed: diskStats.UsageBytes,
fdOpen: fdStats.Open,
fdLimit: fdStats.Limit,
}

if includeGPU {
if gpuStats, err := metrics.GetGPUStats(); err == nil {
m.gpu = gpuStats
}
}

return m, nil
}

var metricsCmd = &cobra.Command{
Use: "metrics",
Short: "Display workspace metrics",
RunE: func(cmd *cobra.Command, args []string) error {
includeGPU, _ := cmd.Flags().GetBool("gpu")

m, err := getMetrics(includeGPU)
m, err := metrics.GetWorkspaceSummary(includeGPU)
if err != nil {
styles.PrintWarning(cmd.OutOrStdout(), "Could not read workspace metrics")
return nil
Expand All @@ -92,27 +24,27 @@ var metricsCmd = &cobra.Command{
fmt.Fprintf(cmd.OutOrStdout(), "%s\n", styles.Title().Render("Metrics"))

rows := [][]string{
{"CPU", fmt.Sprintf("%.1f%% (%s)", m.cpuUsage, formatCPUTime(m.cpuSeconds))},
{"CPU", fmt.Sprintf("%.1f%% (%s)", m.CPUUsage, styles.FormatCPUTime(m.CPUSeconds))},
{"Memory", fmt.Sprintf("%s / %s (%s)",
styles.FormatBytes(m.memoryUsed),
styles.FormatBytes(m.memoryTotal),
styles.FormatPercent(m.memoryUsed, m.memoryTotal))},
{"Memory RSS", styles.FormatBytes(m.memoryRSS)},
styles.FormatBytes(m.MemoryUsed),
styles.FormatBytes(m.MemoryTotal),
styles.FormatPercent(m.MemoryUsed, m.MemoryTotal))},
{"Memory RSS", styles.FormatBytes(m.MemoryRSS)},
{"Disk", fmt.Sprintf("%s / %s (%s)",
styles.FormatBytes(m.diskUsed),
styles.FormatBytes(m.diskTotal),
styles.FormatPercent(m.diskUsed, m.diskTotal))},
{"File Descriptors", fmt.Sprintf("%d / %d", m.fdOpen, m.fdLimit)},
styles.FormatBytes(m.DiskUsed),
styles.FormatBytes(m.DiskTotal),
styles.FormatPercent(m.DiskUsed, m.DiskTotal))},
{"File Descriptors", fmt.Sprintf("%d / %d", m.FDOpen, m.FDLimit)},
}

if m.gpu != nil && m.gpu.Available {
if m.GPU != nil && m.GPU.Available {
rows = append(rows,
[]string{"GPU Utilization", fmt.Sprintf("%.0f%%", m.gpu.UtilizationRatio*100)},
[]string{"GPU Utilization", fmt.Sprintf("%.0f%%", m.GPU.UtilizationRatio*100)},
[]string{"GPU Memory", fmt.Sprintf("%s / %s",
styles.FormatBytes(m.gpu.MemoryUsedBytes),
styles.FormatBytes(m.gpu.MemoryTotalBytes))},
[]string{"GPU Temperature", fmt.Sprintf("%.0fC", m.gpu.TemperatureCelsius)},
[]string{"GPU Power", fmt.Sprintf("%.1fW", m.gpu.PowerWatts)},
styles.FormatBytes(m.GPU.MemoryUsedBytes),
styles.FormatBytes(m.GPU.MemoryTotalBytes))},
[]string{"GPU Temperature", fmt.Sprintf("%.0fC", m.GPU.TemperatureCelsius)},
[]string{"GPU Power", fmt.Sprintf("%.1fW", m.GPU.PowerWatts)},
)
}

Expand Down
30 changes: 1 addition & 29 deletions cmd/info/uptime.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,13 @@ package info

import (
"fmt"
"strings"
"time"

"github.com/spf13/cobra"

"github.com/kloudkit/ws-cli/internals/config"
"github.com/kloudkit/ws-cli/internals/styles"
)

func humanizeDuration(duration time.Duration) string {
days := int(duration.Hours() / 24)
hours := int(duration.Hours()) % 24
minutes := int(duration.Minutes()) % 60

var parts []string

if days > 0 {
parts = append(parts, fmt.Sprintf("%d days", days))
}

if hours > 0 {
parts = append(parts, fmt.Sprintf("%d hours", hours))
}

if minutes > 0 {
parts = append(parts, fmt.Sprintf("%d minutes", minutes))
}

if len(parts) == 0 {
return "just now"
}

return strings.Join(parts, ", ") + " ago"
}

var uptimeCmd = &cobra.Command{
Use: "uptime",
Short: "Display the workspace uptime",
Expand All @@ -62,7 +34,7 @@ var uptimeCmd = &cobra.Command{

t := styles.Table().Rows(
[]string{"Started at", styles.Code().Render(started.Format("2006-01-02 15:04:05 MST"))},
[]string{"Running for", humanizeDuration(running)},
[]string{"Running for", styles.FormatDuration(running)},
[]string{"Status", statusValue},
)

Expand Down
2 changes: 1 addition & 1 deletion cmd/info/version.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package info

var Version = "0.0.49"
var Version = "0.0.50"
Loading
Loading