From 5a8cfacad600fc80c4e0c9c70f015750a086a295 Mon Sep 17 00:00:00 2001 From: Wil Stuckey Date: Wed, 11 Mar 2026 14:47:30 -0500 Subject: [PATCH 1/2] feat(mzcld): Initial framework for mozcld cli --- .gitignore | 1 + go.work | 4 +- go.work.sum | 5 +- tools/mzcld/Makefile | 40 ++++ tools/mzcld/README.md | 55 +++++ tools/mzcld/cmd/claude/claude.go | 14 ++ tools/mzcld/cmd/claude/install.go | 319 ++++++++++++++++++++++++++ tools/mzcld/cmd/init/init.go | 169 ++++++++++++++ tools/mzcld/cmd/root.go | 49 ++++ tools/mzcld/go.mod | 40 ++++ tools/mzcld/go.sum | 85 +++++++ tools/mzcld/internal/executil/exec.go | 36 +++ tools/mzcld/internal/ui/ui.go | 107 +++++++++ tools/mzcld/main.go | 7 + 14 files changed, 928 insertions(+), 3 deletions(-) create mode 100644 tools/mzcld/Makefile create mode 100644 tools/mzcld/README.md create mode 100644 tools/mzcld/cmd/claude/claude.go create mode 100644 tools/mzcld/cmd/claude/install.go create mode 100644 tools/mzcld/cmd/init/init.go create mode 100644 tools/mzcld/cmd/root.go create mode 100644 tools/mzcld/go.mod create mode 100644 tools/mzcld/go.sum create mode 100644 tools/mzcld/internal/executil/exec.go create mode 100644 tools/mzcld/internal/ui/ui.go create mode 100644 tools/mzcld/main.go diff --git a/.gitignore b/.gitignore index 1588c82..e7047ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .cache/** **/mozcloud-chart-migration-workspace/** +tools/**/bin diff --git a/go.work b/go.work index 2b95311..a4e6b58 100644 --- a/go.work +++ b/go.work @@ -1,5 +1,7 @@ -go 1.25.3 +go 1.25.5 use ./tools/render-diff use ./tools/mozcloud-mcp + +use ./tools/mzcld diff --git a/go.work.sum b/go.work.sum index 9d1e724..c928d78 100644 --- a/go.work.sum +++ b/go.work.sum @@ -10,6 +10,8 @@ github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMo github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/cilium/ebpf v0.9.1/go.mod h1:+OhNOIXx/Fnu1IE8bJz2dzOA+VSfyTfdNUVdlQnxUFY= github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/containerd/aufs v1.0.0/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU= @@ -36,7 +38,6 @@ github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03V github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= @@ -96,6 +97,7 @@ github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= @@ -124,7 +126,6 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= diff --git a/tools/mzcld/Makefile b/tools/mzcld/Makefile new file mode 100644 index 0000000..ff535b9 --- /dev/null +++ b/tools/mzcld/Makefile @@ -0,0 +1,40 @@ +LOCALBIN ?= $(shell pwd)/bin +$(LOCALBIN): + mkdir -p $(LOCALBIN) + +VERSION ?= dev +LDFLAGS = -ldflags "-X github.com/mozilla/mozcloud/tools/mzcld/cmd.Version=$(VERSION)" + +.PHONY: all build install lint vet fmt test + +all: build + +build: $(LOCALBIN) + go build $(LDFLAGS) -o $(LOCALBIN)/mzcld . + +install: + go install $(LDFLAGS) . + +lint: $(LOCALBIN)/golangci-lint + $(LOCALBIN)/golangci-lint run + +vet: + go vet ./... + +fmt: + go fmt ./... + +test: + go test ./... + +## golangci-lint + +GOLANGCI_LINT_VERSION ?= v2.6.0 +GOLANGCI_LINT = $(LOCALBIN)/golangci-lint-$(GOLANGCI_LINT_VERSION) + +$(LOCALBIN)/golangci-lint: $(GOLANGCI_LINT) + ln -sf $(GOLANGCI_LINT) $(LOCALBIN)/golangci-lint + +$(GOLANGCI_LINT): $(LOCALBIN) + GOBIN=$(LOCALBIN) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) + mv $(LOCALBIN)/golangci-lint $(GOLANGCI_LINT) diff --git a/tools/mzcld/README.md b/tools/mzcld/README.md new file mode 100644 index 0000000..172cf4a --- /dev/null +++ b/tools/mzcld/README.md @@ -0,0 +1,55 @@ +# mzcld + +Unified CLI for the MozCloud platform. + +## Install + +```bash +go install github.com/mozilla/mozcloud/tools/mzcld@latest +``` + +Or build locally: + +```bash +make build # outputs bin/mzcld +make install # installs to $GOPATH/bin +``` + +## Commands + +### `mzcld init` + +Check that your local environment has the required tools installed and that OCI registry authentication is configured. + +```bash +mzcld init +``` + +### `mzcld claude install` + +Interactively install Claude Code skills, agents, and the `mozcloud-mcp` server from this repository. Run from anywhere inside the mozcloud repo. + +```bash +mzcld claude install +``` + +Non-interactive: + +```bash +mzcld claude install --all --scope user +``` + +Update the MCP binary to the latest published version: + +```bash +mzcld claude install --update +``` + +## Development + +```bash +make vet # go vet +make fmt # go fmt +make test # go test +make lint # golangci-lint +``` diff --git a/tools/mzcld/cmd/claude/claude.go b/tools/mzcld/cmd/claude/claude.go new file mode 100644 index 0000000..28b81ab --- /dev/null +++ b/tools/mzcld/cmd/claude/claude.go @@ -0,0 +1,14 @@ +// Package claude implements the `mzcld claude` subcommand group. +package claude + +import "github.com/spf13/cobra" + +// Cmd is the `mzcld claude` parent command. +var Cmd = &cobra.Command{ + Use: "claude", + Short: "Manage Claude Code skills, agents, and MCP servers for MozCloud", +} + +func init() { + Cmd.AddCommand(installCmd) +} diff --git a/tools/mzcld/cmd/claude/install.go b/tools/mzcld/cmd/claude/install.go new file mode 100644 index 0000000..a2b4cab --- /dev/null +++ b/tools/mzcld/cmd/claude/install.go @@ -0,0 +1,319 @@ +package claude + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/huh" + "github.com/mozilla/mozcloud/tools/mzcld/internal/executil" + "github.com/mozilla/mozcloud/tools/mzcld/internal/ui" + "github.com/spf13/cobra" +) + +const mcpModule = "github.com/mozilla/mozcloud/tools/mozcloud-mcp" + +var installCmd = &cobra.Command{ + Use: "install", + Short: "Install Claude skills, agents, and MCP servers from the MozCloud repo", + Long: `install discovers available skills, agents, and MCP servers in the claude/ +directory of the MozCloud repository and lets you choose which to install. + +Skills and agents are symlinked into your Claude configuration directory. +The mozcloud-mcp server is installed via go install and registered with Claude. + +Run this command from anywhere inside the MozCloud repository.`, + RunE: runInstall, +} + +var ( + scopeFlag string + updateFlag bool + allFlag bool +) + +func init() { + installCmd.Flags().StringVar(&scopeFlag, "scope", "", "Scope: user or project (skips prompt)") + installCmd.Flags().BoolVar(&updateFlag, "update", false, "Update the mozcloud-mcp binary to the latest published version") + installCmd.Flags().BoolVar(&allFlag, "all", false, "Install all available items without prompting") + installCmd.Flags().SortFlags = false +} + +// installable represents a skill, agent, or MCP server that can be installed. +type installable struct { + kind string // "skill", "agent", "mcp" + name string + label string // display label for the TUI + src string // source path (for symlinking); empty for mcp +} + +func runInstall(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + claudeDir, err := findClaudeDir() + if err != nil { + return err + } + + items, err := discover(claudeDir) + if err != nil { + return err + } + if len(items) == 0 { + ui.Warn("No skills, agents, or MCP servers found in " + claudeDir) + return nil + } + + // Determine scope + scope := scopeFlag + if scope == "" && !allFlag { + scope, err = promptScope() + if err != nil { + return err + } + } + if scope == "" { + scope = "user" + } + + // Determine which items to install + selected := items + if !allFlag { + selected, err = promptItems(items) + if err != nil { + return err + } + } + if len(selected) == 0 { + ui.Info("Nothing selected.") + return nil + } + + // Determine target claude dir + targetDir, err := targetClaudeDir(scope, claudeDir) + if err != nil { + return err + } + + ui.Header("Installing...") + + for _, item := range selected { + switch item.kind { + case "skill", "agent": + if err := installSymlink(item, targetDir); err != nil { + ui.Error(fmt.Sprintf("%s: %s", item.name, err)) + } + case "mcp": + if err := installMCP(ctx, scope, updateFlag); err != nil { + ui.Error("mozcloud-mcp: " + err.Error()) + } + } + } + + fmt.Println() + return nil +} + +// findClaudeDir locates the claude/ directory in the MozCloud repo by walking +// up from the CWD using git to find the repo root. +func findClaudeDir() (string, error) { + out, err := executil.Output(context.Background(), "git", "rev-parse", "--show-toplevel") + if err != nil { + return "", fmt.Errorf("not inside a git repository: run this command from within the mozcloud repository") + } + root := strings.TrimSpace(out) + claudeDir := filepath.Join(root, "claude") + if _, err := os.Stat(claudeDir); os.IsNotExist(err) { + return "", fmt.Errorf("claude/ directory not found at %s: are you in the mozcloud repository?", claudeDir) + } + return claudeDir, nil +} + +// discover returns all installable items found in claudeDir. +func discover(claudeDir string) ([]installable, error) { + var items []installable + + // Skills: each subdirectory under claude/skills/ + skillsDir := filepath.Join(claudeDir, "skills") + if entries, err := os.ReadDir(skillsDir); err == nil { + for _, e := range entries { + if e.IsDir() { + items = append(items, installable{ + kind: "skill", + name: e.Name(), + label: "skill / " + e.Name(), + src: filepath.Join(skillsDir, e.Name()), + }) + } + } + } + + // Agents: each .md file under claude/agents/ + agentsDir := filepath.Join(claudeDir, "agents") + if entries, err := os.ReadDir(agentsDir); err == nil { + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { + items = append(items, installable{ + kind: "agent", + name: e.Name(), + label: "agent / " + strings.TrimSuffix(e.Name(), ".md"), + src: filepath.Join(agentsDir, e.Name()), + }) + } + } + } + + // MCP server: always offer mozcloud-mcp + items = append(items, installable{ + kind: "mcp", + name: "mozcloud-mcp", + label: "mcp / mozcloud-mcp", + }) + + return items, nil +} + +func promptScope() (string, error) { + var scope string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Scope"). + Description("Where should skills and agents be installed?"). + Options( + huh.NewOption("User — ~/.claude/ (available in all projects)", "user"), + huh.NewOption("Project — .claude/ (this repository only)", "project"), + ). + Value(&scope), + ), + ) + if err := form.Run(); err != nil { + return "", err + } + return scope, nil +} + +func promptItems(items []installable) ([]installable, error) { + options := make([]huh.Option[string], len(items)) + for i, item := range items { + options[i] = huh.NewOption(item.label, item.name) + } + + var selected []string + // Default all selected + defaults := make([]string, len(items)) + for i, item := range items { + defaults[i] = item.name + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Select items to install"). + Options(options...). + Value(&selected), + ), + ) + if err := form.Run(); err != nil { + return nil, err + } + + selectedSet := make(map[string]bool, len(selected)) + for _, s := range selected { + selectedSet[s] = true + } + + var result []installable + for _, item := range items { + if selectedSet[item.name] { + result = append(result, item) + } + } + return result, nil +} + +func targetClaudeDir(scope, claudeDir string) (string, error) { + if scope == "project" { + // Project root is one level up from claude/ + repoRoot := filepath.Dir(claudeDir) + return filepath.Join(repoRoot, ".claude"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + return filepath.Join(home, ".claude"), nil +} + +func installSymlink(item installable, targetDir string) error { + var subdir string + switch item.kind { + case "skill": + subdir = "skills" + case "agent": + subdir = "agents" + } + + dir := filepath.Join(targetDir, subdir) + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("cannot create %s: %w", dir, err) + } + + dst := filepath.Join(dir, item.name) + switch { + case isSymlink(dst): + ui.Dim("already linked: " + dst) + case exists(dst): + ui.Warn("skipped (file exists): " + dst) + default: + if err := os.Symlink(item.src, dst); err != nil { + return err + } + ui.Success("linked: " + dst) + } + return nil +} + +func installMCP(ctx context.Context, scope string, update bool) error { + alreadyRegistered := func() bool { + out, _ := executil.Output(ctx, "claude", "mcp", "list") + return strings.Contains(out, "mozcloud") + } + + if update { + ui.Info("Updating mozcloud-mcp...") + if out, err := executil.Combined(ctx, "go", "install", mcpModule+"@latest"); err != nil { + return fmt.Errorf("go install failed: %s", out) + } + ui.Success("mozcloud-mcp updated") + return nil + } + + if alreadyRegistered() { + ui.Dim("mozcloud-mcp already registered (use --update to upgrade)") + return nil + } + + ui.Info("Installing mozcloud-mcp...") + if out, err := executil.Combined(ctx, "go", "install", mcpModule+"@latest"); err != nil { + return fmt.Errorf("go install failed: %s", out) + } + if out, err := executil.Combined(ctx, "claude", "mcp", "add", + "--scope", scope, "mozcloud", "mozcloud-mcp", "--", "--transport", "stdio"); err != nil { + return fmt.Errorf("claude mcp add failed: %s", out) + } + ui.Success("mozcloud-mcp installed and registered (" + scope + " scope)") + return nil +} + +func isSymlink(path string) bool { + fi, err := os.Lstat(path) + return err == nil && fi.Mode()&os.ModeSymlink != 0 +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/tools/mzcld/cmd/init/init.go b/tools/mzcld/cmd/init/init.go new file mode 100644 index 0000000..ea74cf8 --- /dev/null +++ b/tools/mzcld/cmd/init/init.go @@ -0,0 +1,169 @@ +// Package init implements the `mzcld init` command, which checks that the +// local environment has the tools required to work with MozCloud. +package init + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mozilla/mozcloud/tools/mzcld/internal/executil" + "github.com/mozilla/mozcloud/tools/mzcld/internal/ui" + "github.com/spf13/cobra" +) + +// Cmd is the `mzcld init` cobra command. +var Cmd = &cobra.Command{ + Use: "init", + Short: "Check that your local environment is ready for MozCloud", + Long: `init verifies that required and optional tools are installed and that +OCI registry authentication is configured. + +Required tools must be present for mzcld to function. Optional tools +are recommended but their absence only produces a warning.`, + RunE: run, +} + +var registryFlag string + +func init() { + Cmd.Flags().StringVar(®istryFlag, "registry", "us-west1-docker.pkg.dev", + "OCI registry to check authentication for") +} + +func run(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + ui.Header("Checking MozCloud dependencies...") + + checks := []ui.CheckResult{ + checkBinary(ctx, "git", "git", []string{"--version"}, parseFirstWord, true, + "https://git-scm.com/downloads"), + checkBinary(ctx, "helm", "helm", []string{"version", "--short"}, trimV, true, + "brew install helm"), + checkBinary(ctx, "gcloud", "gcloud", []string{"version"}, parseGcloud, true, + "https://cloud.google.com/sdk/docs/install"), + checkBinary(ctx, "kubectl", "kubectl", []string{"version", "--client"}, parseKubectl, true, + "brew install kubectl"), + checkBinary(ctx, "kubeconform", "kubeconform", []string{"-v"}, trimV, false, + "brew install kubeconform"), + checkBinary(ctx, "render-diff", "render-diff", []string{"--version"}, parseRenderDiff, false, + "go install github.com/mozilla/mozcloud/tools/render-diff@latest"), + checkOCIAuth(registryFlag), + } + + failures := ui.PrintChecks(checks) + + switch { + case failures == 0: + ui.Success("Environment is ready.") + case failures == 1: + ui.Error("1 required tool is missing.") + return fmt.Errorf("init failed") + default: + ui.Error(fmt.Sprintf("%d required tools are missing.", failures)) + return fmt.Errorf("init failed") + } + + return nil +} + +// checkBinary builds a CheckResult by running cmd with args. +// parse extracts the version string from command output. +// required=false means the check produces a warning, not a failure. +func checkBinary(ctx context.Context, name, bin string, args []string, parse func(string) string, required bool, fix string) ui.CheckResult { + if !executil.LookPath(bin) { + return ui.CheckResult{Name: name, OK: false, Warn: !required, Fix: fix} + } + out, err := executil.Output(ctx, bin, args...) + version := "" + if err == nil { + version = parse(out) + } + return ui.CheckResult{Name: name, Version: version, OK: true} +} + +func checkOCIAuth(registry string) ui.CheckResult { + name := "OCI auth (" + registry + ")" + home, err := os.UserHomeDir() + if err != nil { + return ui.CheckResult{Name: name, OK: false, Fix: "run: gcloud auth configure-docker " + registry} + } + + data, err := os.ReadFile(filepath.Join(home, ".docker", "config.json")) + if err != nil { + return ui.CheckResult{Name: name, OK: false, Fix: "run: gcloud auth configure-docker " + registry} + } + + var cfg struct { + Auths map[string]json.RawMessage `json:"auths"` + CredHelpers map[string]string `json:"credHelpers"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + return ui.CheckResult{Name: name, OK: false, Fix: "run: gcloud auth configure-docker " + registry} + } + + _, inAuths := cfg.Auths[registry] + _, inHelpers := cfg.CredHelpers[registry] + if inAuths || inHelpers { + return ui.CheckResult{Name: name, Version: "authenticated", OK: true} + } + return ui.CheckResult{Name: name, OK: false, Fix: "run: gcloud auth configure-docker " + registry} +} + +// --- version parsers --- + +// trimV strips a leading "v" and returns the first whitespace-delimited token. +func trimV(s string) string { + if f := strings.Fields(s); len(f) > 0 { + return strings.TrimPrefix(f[0], "v") + } + return s +} + +// parseFirstWord extracts the version from "git version 2.47.1". +func parseFirstWord(s string) string { + parts := strings.Fields(s) + if len(parts) >= 3 { + return parts[2] + } + return s +} + +// parseGcloud extracts the version from "Google Cloud SDK 534.0.0". +func parseGcloud(s string) string { + for _, line := range strings.Split(s, "\n") { + if strings.HasPrefix(line, "Google Cloud SDK") { + parts := strings.Fields(line) + if len(parts) >= 4 { + return parts[3] + } + } + } + return s +} + +// parseKubectl extracts the version from "Client Version: v1.32.0". +func parseKubectl(s string) string { + for _, line := range strings.Split(s, "\n") { + if strings.HasPrefix(line, "Client Version:") { + parts := strings.Fields(line) + if len(parts) >= 3 { + return strings.TrimPrefix(parts[2], "v") + } + } + } + return trimV(s) +} + +// parseRenderDiff extracts the version from "render-diff version v0.3.4". +func parseRenderDiff(s string) string { + parts := strings.Fields(s) + if len(parts) >= 3 { + return strings.TrimPrefix(parts[2], "v") + } + return trimV(s) +} diff --git a/tools/mzcld/cmd/root.go b/tools/mzcld/cmd/root.go new file mode 100644 index 0000000..fe8c478 --- /dev/null +++ b/tools/mzcld/cmd/root.go @@ -0,0 +1,49 @@ +// Package cmd implements the mzcld command-line interface. +package cmd + +import ( + "os" + + "github.com/mozilla/mozcloud/tools/mzcld/cmd/claude" + mzinit "github.com/mozilla/mozcloud/tools/mzcld/cmd/init" + "github.com/mozilla/mozcloud/tools/mzcld/internal/ui" + "github.com/spf13/cobra" +) + +var ( + debugFlag bool + noColorFlag bool + + // Version is set at build time via -ldflags. + Version = "dev" +) + +var rootCmd = &cobra.Command{ + Use: "mzcld", + Short: "MozCloud CLI", + Long: "mzcld is the unified CLI for interacting with the MozCloud platform.", + Version: Version, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + if noColorFlag { + os.Setenv("NO_COLOR", "1") + } + ui.SetDebug(debugFlag) + }, + SilenceUsage: true, +} + +// Execute runs the root command. +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().BoolVarP(&debugFlag, "debug", "d", false, "Enable verbose debug output") + rootCmd.PersistentFlags().BoolVar(&noColorFlag, "no-color", false, "Disable colored output") + rootCmd.PersistentFlags().SortFlags = false + + rootCmd.AddCommand(mzinit.Cmd) + rootCmd.AddCommand(claude.Cmd) +} diff --git a/tools/mzcld/go.mod b/tools/mzcld/go.mod new file mode 100644 index 0000000..8ea69b6 --- /dev/null +++ b/tools/mzcld/go.mod @@ -0,0 +1,40 @@ +module github.com/mozilla/mozcloud/tools/mzcld + +go 1.25.5 + +require ( + github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect +) diff --git a/tools/mzcld/go.sum b/tools/mzcld/go.sum new file mode 100644 index 0000000..f51d71a --- /dev/null +++ b/tools/mzcld/go.sum @@ -0,0 +1,85 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/tools/mzcld/internal/executil/exec.go b/tools/mzcld/internal/executil/exec.go new file mode 100644 index 0000000..d6fd043 --- /dev/null +++ b/tools/mzcld/internal/executil/exec.go @@ -0,0 +1,36 @@ +// Package executil provides lightweight helpers for running external commands. +package executil + +import ( + "bytes" + "context" + "os/exec" + "strings" + + "github.com/mozilla/mozcloud/tools/mzcld/internal/ui" +) + +// LookPath reports whether name is available in PATH. +func LookPath(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +// Output runs name with args and returns trimmed stdout. +// Stderr is discarded. Returns an error if the command fails. +func Output(ctx context.Context, name string, args ...string) (string, error) { + ui.Debug("exec: " + name + " " + strings.Join(args, " ")) + cmd := exec.CommandContext(ctx, name, args...) + var out bytes.Buffer + cmd.Stdout = &out + err := cmd.Run() + return strings.TrimSpace(out.String()), err +} + +// Combined runs name with args and returns trimmed combined stdout+stderr. +func Combined(ctx context.Context, name string, args ...string) (string, error) { + ui.Debug("exec: " + name + " " + strings.Join(args, " ")) + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} diff --git a/tools/mzcld/internal/ui/ui.go b/tools/mzcld/internal/ui/ui.go new file mode 100644 index 0000000..477b5cb --- /dev/null +++ b/tools/mzcld/internal/ui/ui.go @@ -0,0 +1,107 @@ +// Package ui provides consistent terminal output helpers for mzcld commands. +package ui + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +var debug bool + +// SetDebug enables or disables debug output. +func SetDebug(v bool) { debug = v } + +// IsDebug reports whether debug mode is active. +func IsDebug() bool { return debug } + +var ( + green = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + yellow = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + red = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + gray = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + bold = lipgloss.NewStyle().Bold(true) +) + +// Header prints a bold section heading. +func Header(msg string) { + fmt.Println("\n" + bold.Render(msg)) +} + +// Success prints a green check line. +func Success(msg string) { + fmt.Println(green.Render(" ✓ ") + msg) +} + +// Warn prints a yellow warning line. +func Warn(msg string) { + fmt.Println(yellow.Render(" ! ") + msg) +} + +// Error prints a red error line to stderr. +func Error(msg string) { + fmt.Fprintln(os.Stderr, red.Render(" ✗ ") + msg) +} + +// Info prints a plain info line. +func Info(msg string) { + fmt.Println(" " + msg) +} + +// Dim prints a dimmed line. +func Dim(msg string) { + fmt.Println(gray.Render(" " + msg)) +} + +// Debug prints a line only when debug mode is enabled. +func Debug(msg string) { + if debug { + fmt.Println(gray.Render(" … " + msg)) + } +} + +// CheckResult represents the outcome of a single preflight check. +type CheckResult struct { + Name string + Version string // populated on success + Fix string // install hint on failure + OK bool + Warn bool // true = optional, not a hard failure +} + +// PrintChecks renders a table of check results and returns the number of failures. +func PrintChecks(checks []CheckResult) (failures int) { + nameWidth := 0 + for _, c := range checks { + if len(c.Name) > nameWidth { + nameWidth = len(c.Name) + } + } + + fmt.Println() + for _, c := range checks { + pad := strings.Repeat(" ", nameWidth-len(c.Name)+2) + switch { + case c.OK: + fmt.Printf(" %s %s%s%s\n", + green.Render("✓"), + bold.Render(c.Name), pad, + gray.Render(c.Version)) + case c.Warn: + fmt.Printf(" %s %s%s%s\n", + yellow.Render("!"), + bold.Render(c.Name), pad, + yellow.Render("not found → "+c.Fix)) + default: + fmt.Printf(" %s %s%s%s\n", + red.Render("✗"), + bold.Render(c.Name), pad, + red.Render("not found → "+c.Fix)) + failures++ + } + } + fmt.Println() + return failures +} diff --git a/tools/mzcld/main.go b/tools/mzcld/main.go new file mode 100644 index 0000000..42f6759 --- /dev/null +++ b/tools/mzcld/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/mozilla/mozcloud/tools/mzcld/cmd" + +func main() { + cmd.Execute() +} From 182222cd6ffa0e2f0e52456c5ea6cc3f89531ec5 Mon Sep 17 00:00:00 2001 From: Wil Stuckey Date: Fri, 13 Mar 2026 15:14:13 -0500 Subject: [PATCH 2/2] chart command --- tools/mzcld/README.md | 8 + tools/mzcld/cmd/chart/Chart.yaml.tmpl | 11 ++ tools/mzcld/cmd/chart/chart.go | 14 ++ tools/mzcld/cmd/chart/new.go | 224 ++++++++++++++++++++++++ tools/mzcld/cmd/claude/claude.go | 1 + tools/mzcld/cmd/claude/uninstall.go | 186 ++++++++++++++++++++ tools/mzcld/cmd/root.go | 2 + tools/mzcld/internal/scaffold/schema.go | 219 +++++++++++++++++++++++ 8 files changed, 665 insertions(+) create mode 100644 tools/mzcld/cmd/chart/Chart.yaml.tmpl create mode 100644 tools/mzcld/cmd/chart/chart.go create mode 100644 tools/mzcld/cmd/chart/new.go create mode 100644 tools/mzcld/cmd/claude/uninstall.go create mode 100644 tools/mzcld/internal/scaffold/schema.go diff --git a/tools/mzcld/README.md b/tools/mzcld/README.md index 172cf4a..c77eb4c 100644 --- a/tools/mzcld/README.md +++ b/tools/mzcld/README.md @@ -45,6 +45,14 @@ Update the MCP binary to the latest published version: mzcld claude install --update ``` +### `mzcld claude uninstall` + +Remove installed skills, agents, and the MCP server. + +```bash +mzcld claude uninstall +``` + ## Development ```bash diff --git a/tools/mzcld/cmd/chart/Chart.yaml.tmpl b/tools/mzcld/cmd/chart/Chart.yaml.tmpl new file mode 100644 index 0000000..e34658e --- /dev/null +++ b/tools/mzcld/cmd/chart/Chart.yaml.tmpl @@ -0,0 +1,11 @@ +apiVersion: v2 +name: {{ .Name }} +description: {{ .Description }} +type: application +version: 0.1.0 + +dependencies: + - name: mozcloud + version: "{{ .MozcloudVersion }}" + repository: "oci://us-west1-docker.pkg.dev/moz-fx-platform-artifacts/mozcloud-charts" + condition: mozcloud.enabled diff --git a/tools/mzcld/cmd/chart/chart.go b/tools/mzcld/cmd/chart/chart.go new file mode 100644 index 0000000..c14facb --- /dev/null +++ b/tools/mzcld/cmd/chart/chart.go @@ -0,0 +1,14 @@ +// Package chart implements the `mzcld chart` subcommand group. +package chart + +import "github.com/spf13/cobra" + +// Cmd is the `mzcld chart` parent command. +var Cmd = &cobra.Command{ + Use: "chart", + Short: "Work with MozCloud Helm charts", +} + +func init() { + Cmd.AddCommand(newCmd) +} diff --git a/tools/mzcld/cmd/chart/new.go b/tools/mzcld/cmd/chart/new.go new file mode 100644 index 0000000..1d98ebd --- /dev/null +++ b/tools/mzcld/cmd/chart/new.go @@ -0,0 +1,224 @@ +package chart + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" + + _ "embed" + + "github.com/charmbracelet/huh" + "github.com/mozilla/mozcloud/tools/mzcld/internal/scaffold" + "github.com/mozilla/mozcloud/tools/mzcld/internal/ui" + "github.com/spf13/cobra" +) + +//go:embed Chart.yaml.tmpl +var chartYAMLTmpl string + +const ( + defaultRepository = "us-west1-docker.pkg.dev/moz-fx-platform-artifacts/mozcloud-charts" + dependencyChart = "mozcloud" +) + +var newCmd = &cobra.Command{ + Use: "new", + Short: "Scaffold a new MozCloud Helm chart", + Long: `new creates a new Helm chart pre-configured to use the mozcloud dependency chart. + +It fetches the latest mozcloud values schema from the OCI registry and generates +an annotated values.yaml so your values stay aligned with the chart's schema.`, + RunE: runNew, +} + +var ( + nameFlag string + descFlag string + outputFlag string + repositoryFlag string + versionFlag string +) + +func init() { + newCmd.Flags().StringVar(&nameFlag, "name", "", "Chart name (skips prompt)") + newCmd.Flags().StringVar(&descFlag, "description", "", "Chart description (skips prompt)") + newCmd.Flags().StringVar(&outputFlag, "output", "", "Output directory (default: ./)") + newCmd.Flags().StringVar(&repositoryFlag, "repository", defaultRepository, "OCI repository for the mozcloud chart") + newCmd.Flags().StringVar(&versionFlag, "version", "", "mozcloud chart version (default: latest)") + newCmd.Flags().SortFlags = false +} + +type chartParams struct { + Name string + Description string + MozcloudVersion string +} + +func runNew(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + params, err := gatherParams(ctx) + if err != nil { + return err + } + + outDir := outputFlag + if outDir == "" { + outDir = params.Name + } + + if _, err := os.Stat(outDir); err == nil { + return fmt.Errorf("output directory %q already exists", outDir) + } + + // Fetch schema from OCI registry + ui.Info("Fetching mozcloud schema from registry...") + schemaJSON, err := fetchSchema(ctx, repositoryFlag, versionFlag) + if err != nil { + return fmt.Errorf("failed to fetch schema: %w\n\nEnsure you are authenticated: run `gcloud auth configure-docker %s`", err, repositoryFlag) + } + + var root scaffold.Schema + if err := json.Unmarshal([]byte(schemaJSON), &root); err != nil { + return fmt.Errorf("failed to parse schema: %w", err) + } + + // Generate files + if err := os.MkdirAll(outDir, 0o750); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + if err := writeChartYAML(outDir, params); err != nil { + return err + } + if err := writeValuesYAML(outDir, &root); err != nil { + return err + } + + ui.Header("Created " + outDir + "/") + ui.Success("Chart.yaml") + ui.Success("values.yaml (generated from mozcloud schema " + params.MozcloudVersion + ")") + fmt.Println() + ui.Info("Next steps:") + ui.Dim(" cd " + outDir) + ui.Dim(" helm dependency update") + ui.Dim(" # edit values.yaml, then:") + ui.Dim(" helm template . -f values.yaml") + + return nil +} + +func gatherParams(ctx context.Context) (*chartParams, error) { + params := &chartParams{ + Name: nameFlag, + Description: descFlag, + } + + // Resolve mozcloud version first (needed for display in prompts) + ui.Info("Resolving latest mozcloud chart version...") + version, err := resolveVersion(ctx, repositoryFlag, versionFlag) + if err != nil { + return nil, fmt.Errorf("failed to resolve chart version: %w", err) + } + params.MozcloudVersion = version + + // Prompt for any missing params + var fields []huh.Field + if params.Name == "" { + fields = append(fields, + huh.NewInput(). + Title("Chart name"). + Description("Lowercase, hyphens allowed (e.g. my-service)"). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("name is required") + } + return nil + }). + Value(¶ms.Name), + ) + } + if params.Description == "" { + fields = append(fields, + huh.NewInput(). + Title("Description"). + Value(¶ms.Description), + ) + } + + if len(fields) > 0 { + form := huh.NewForm(huh.NewGroup(fields...)) + if err := form.Run(); err != nil { + return nil, err + } + } + + return params, nil +} + +func resolveVersion(ctx context.Context, repo, version string) (string, error) { + if version != "" { + return version, nil + } + ociRef := fmt.Sprintf("oci://%s/%s", strings.TrimPrefix(repo, "oci://"), dependencyChart) + cmd := exec.CommandContext(ctx, "helm", "show", "chart", ociRef) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("helm show chart failed: %w", err) + } + for _, line := range strings.Split(string(out), "\n") { + if after, ok := strings.CutPrefix(line, "version:"); ok { + return strings.TrimSpace(after), nil + } + } + return "latest", nil +} + +func fetchSchema(ctx context.Context, repo, version string) (string, error) { + tmpDir, err := os.MkdirTemp("", "mzcld-schema-*") + if err != nil { + return "", err + } + defer os.RemoveAll(tmpDir) //nolint:errcheck + + ociRef := fmt.Sprintf("oci://%s/%s", strings.TrimPrefix(repo, "oci://"), dependencyChart) + args := []string{"pull", ociRef, "--untar", "--untardir", tmpDir} + if version != "" { + args = append(args, "--version", version) + } + + cmd := exec.CommandContext(ctx, "helm", args...) + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("%s", strings.TrimSpace(string(out))) + } + + schemaPath := filepath.Join(tmpDir, dependencyChart, "values.schema.json") + data, err := os.ReadFile(schemaPath) + if err != nil { + return "", fmt.Errorf("values.schema.json not found in chart: %w", err) + } + return string(data), nil +} + +func writeChartYAML(outDir string, params *chartParams) error { + tmpl, err := template.New("Chart.yaml").Parse(chartYAMLTmpl) + if err != nil { + return fmt.Errorf("failed to parse Chart.yaml template: %w", err) + } + f, err := os.Create(filepath.Join(outDir, "Chart.yaml")) + if err != nil { + return err + } + defer f.Close() //nolint:errcheck + return tmpl.Execute(f, params) +} + +func writeValuesYAML(outDir string, root *scaffold.Schema) error { + content := scaffold.GenerateYAML(root, dependencyChart) + return os.WriteFile(filepath.Join(outDir, "values.yaml"), []byte(content), 0o640) +} diff --git a/tools/mzcld/cmd/claude/claude.go b/tools/mzcld/cmd/claude/claude.go index 28b81ab..ce304b1 100644 --- a/tools/mzcld/cmd/claude/claude.go +++ b/tools/mzcld/cmd/claude/claude.go @@ -11,4 +11,5 @@ var Cmd = &cobra.Command{ func init() { Cmd.AddCommand(installCmd) + Cmd.AddCommand(uninstallCmd) } diff --git a/tools/mzcld/cmd/claude/uninstall.go b/tools/mzcld/cmd/claude/uninstall.go new file mode 100644 index 0000000..776183f --- /dev/null +++ b/tools/mzcld/cmd/claude/uninstall.go @@ -0,0 +1,186 @@ +package claude + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/huh" + "github.com/mozilla/mozcloud/tools/mzcld/internal/executil" + "github.com/mozilla/mozcloud/tools/mzcld/internal/ui" + "github.com/spf13/cobra" +) + +var uninstallCmd = &cobra.Command{ + Use: "uninstall", + Short: "Remove installed Claude skills, agents, and MCP servers", + Long: `uninstall scans your Claude configuration directory for installed MozCloud +skills and agents (symlinks) and lets you choose which to remove. + +Run this command from anywhere inside the MozCloud repository, or pass +--scope explicitly if running outside it.`, + RunE: runUninstall, +} + +var uninstallScopeFlag string + +func init() { + uninstallCmd.Flags().StringVar(&uninstallScopeFlag, "scope", "", "Scope to uninstall from: user or project (skips prompt)") +} + +func runUninstall(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + scope := uninstallScopeFlag + if scope == "" { + var err error + scope, err = promptScope() + if err != nil { + return err + } + } + + targetDir, err := resolveTargetDir(scope, cmd) + if err != nil { + return err + } + + installed, err := scanInstalled(ctx, targetDir) + if err != nil { + return err + } + if len(installed) == 0 { + ui.Info("Nothing installed at " + scope + " scope.") + return nil + } + + selected, err := promptUninstall(installed) + if err != nil { + return err + } + if len(selected) == 0 { + ui.Info("Nothing selected.") + return nil + } + + ui.Header("Uninstalling...") + + for _, item := range selected { + switch item.kind { + case "skill", "agent": + if err := os.Remove(item.src); err != nil { + ui.Error(fmt.Sprintf("%s: %s", item.name, err)) + } else { + ui.Success("removed: " + item.src) + } + case "mcp": + if out, err := executil.Combined(ctx, "claude", "mcp", "remove", "mozcloud"); err != nil { + ui.Error("mozcloud-mcp: " + out) + } else { + ui.Success("mozcloud-mcp unregistered") + } + } + } + + fmt.Println() + return nil +} + +// resolveTargetDir returns the claude config dir for the given scope. +// If scope is "project" it tries to find the repo root; falls back to CWD/.claude. +func resolveTargetDir(scope string, _ *cobra.Command) (string, error) { + if scope == "user" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + return filepath.Join(home, ".claude"), nil + } + + // project scope — try to find repo root, fall back to CWD + root, err := executil.Output(context.Background(), "git", "rev-parse", "--show-toplevel") + if err != nil { + cwd, _ := os.Getwd() + return filepath.Join(cwd, ".claude"), nil + } + return filepath.Join(strings.TrimSpace(root), ".claude"), nil +} + +// scanInstalled returns all MozCloud-installed items found in targetDir. +func scanInstalled(ctx context.Context, targetDir string) ([]installable, error) { + var items []installable + + for _, sub := range []struct{ kind, dir string }{ + {"skill", "skills"}, + {"agent", "agents"}, + } { + dir := filepath.Join(targetDir, sub.dir) + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + continue + } + if err != nil { + return nil, err + } + for _, e := range entries { + path := filepath.Join(dir, e.Name()) + fi, err := os.Lstat(path) + if err != nil || fi.Mode()&os.ModeSymlink == 0 { + continue + } + items = append(items, installable{ + kind: sub.kind, + name: e.Name(), + label: sub.kind + " / " + strings.TrimSuffix(e.Name(), ".md"), + src: path, // for uninstall, src is the symlink path to remove + }) + } + } + + // Check if mozcloud-mcp is registered + out, _ := executil.Output(ctx, "claude", "mcp", "list") + if strings.Contains(out, "mozcloud") { + items = append(items, installable{ + kind: "mcp", + name: "mozcloud-mcp", + label: "mcp / mozcloud-mcp", + }) + } + + return items, nil +} + +func promptUninstall(items []installable) ([]installable, error) { + options := make([]huh.Option[string], len(items)) + for i, item := range items { + options[i] = huh.NewOption(item.label, item.name) + } + + var selected []string + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Select items to uninstall"). + Options(options...). + Value(&selected), + ), + ) + if err := form.Run(); err != nil { + return nil, err + } + + selectedSet := make(map[string]bool, len(selected)) + for _, s := range selected { + selectedSet[s] = true + } + + var result []installable + for _, item := range items { + if selectedSet[item.name] { + result = append(result, item) + } + } + return result, nil +} diff --git a/tools/mzcld/cmd/root.go b/tools/mzcld/cmd/root.go index fe8c478..8dbf72d 100644 --- a/tools/mzcld/cmd/root.go +++ b/tools/mzcld/cmd/root.go @@ -4,6 +4,7 @@ package cmd import ( "os" + "github.com/mozilla/mozcloud/tools/mzcld/cmd/chart" "github.com/mozilla/mozcloud/tools/mzcld/cmd/claude" mzinit "github.com/mozilla/mozcloud/tools/mzcld/cmd/init" "github.com/mozilla/mozcloud/tools/mzcld/internal/ui" @@ -46,4 +47,5 @@ func init() { rootCmd.AddCommand(mzinit.Cmd) rootCmd.AddCommand(claude.Cmd) + rootCmd.AddCommand(chart.Cmd) } diff --git a/tools/mzcld/internal/scaffold/schema.go b/tools/mzcld/internal/scaffold/schema.go new file mode 100644 index 0000000..04f4677 --- /dev/null +++ b/tools/mzcld/internal/scaffold/schema.go @@ -0,0 +1,219 @@ +// Package scaffold generates annotated YAML values files from JSON schemas. +package scaffold + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// Schema is a minimal JSON Schema representation covering the fields we need +// to generate useful YAML output. +type Schema struct { + Type string `json:"type"` + Description string `json:"description"` + Properties map[string]*Schema `json:"properties"` + AdditionalProperties *AdditionalProps `json:"additionalProperties"` + Items *Schema `json:"items"` + Required []string `json:"required"` + Default interface{} `json:"default"` + AnyOf []*Schema `json:"anyOf"` + OneOf []*Schema `json:"oneOf"` + Ref string `json:"$ref"` + Defs map[string]*Schema `json:"$defs"` + Enum []interface{} `json:"enum"` +} + +// AdditionalProperties can be a bool or a schema object in JSON Schema. +// We unmarshal it manually to handle both. +type AdditionalProps struct { + Schema *Schema +} + +func (a *AdditionalProps) UnmarshalJSON(data []byte) error { + // Try schema first + var s Schema + if err := json.Unmarshal(data, &s); err == nil && s.Type != "" { + a.Schema = &s + return nil + } + // Ignore bool (true/false) + return nil +} + +// GenerateYAML produces an annotated YAML string from the schema, wrapped +// under a top-level key (e.g. "mozcloud"). +func GenerateYAML(root *Schema, topKey string) string { + g := &generator{root: root} + var sb strings.Builder + if topKey != "" { + sb.WriteString(topKey + ":\n") + g.writeObject(&sb, root, 1, root.Required) + } else { + g.writeObject(&sb, root, 0, root.Required) + } + return sb.String() +} + +type generator struct { + root *Schema +} + +func (g *generator) resolve(s *Schema) *Schema { + if s.Ref == "" { + return s + } + // Only handle local $defs refs: "#/$defs/Foo" + ref := strings.TrimPrefix(s.Ref, "#/$defs/") + if g.root.Defs != nil { + if def, ok := g.root.Defs[ref]; ok { + return def + } + } + return s +} + +// effectiveType returns the resolved type, unwrapping anyOf/oneOf nullables. +// e.g. anyOf: [{type: string}, {type: null}] → "string" +func (g *generator) effectiveType(s *Schema) string { + if s.Type != "" { + return s.Type + } + candidates := append(s.AnyOf, s.OneOf...) + for _, c := range candidates { + c = g.resolve(c) + if c.Type != "" && c.Type != "null" { + return c.Type + } + } + return "" +} + +// effectiveSchema returns the most informative schema from anyOf/oneOf nullable unions. +func (g *generator) effectiveSchema(s *Schema) *Schema { + if s.Type != "" || len(s.Properties) > 0 { + return s + } + candidates := append(s.AnyOf, s.OneOf...) + for _, c := range candidates { + c = g.resolve(c) + if c.Type != "" && c.Type != "null" { + return c + } + } + return s +} + +func (g *generator) writeObject(sb *strings.Builder, s *Schema, depth int, required []string) { + if len(s.Properties) == 0 { + return + } + + requiredSet := make(map[string]bool, len(required)) + for _, r := range required { + requiredSet[r] = true + } + + // Sort: required fields first, then alphabetical within each group. + keys := make([]string, 0, len(s.Properties)) + for k := range s.Properties { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + ri, rj := requiredSet[keys[i]], requiredSet[keys[j]] + if ri != rj { + return ri + } + return keys[i] < keys[j] + }) + + indent := strings.Repeat(" ", depth) + for _, key := range keys { + prop := g.resolve(s.Properties[key]) + prop = g.effectiveSchema(prop) + typ := g.effectiveType(prop) + + if prop.Description != "" { + for _, line := range strings.Split(prop.Description, "\n") { + sb.WriteString(indent + "# " + strings.TrimSpace(line) + "\n") + } + } + + switch typ { + case "object": + if len(prop.Properties) > 0 { + sb.WriteString(indent + key + ":\n") + g.writeObject(sb, prop, depth+1, prop.Required) + } else if prop.AdditionalProperties != nil && prop.AdditionalProperties.Schema != nil { + // Named map pattern (e.g. workloads, configMaps) — emit a commented example + g.writeAdditionalProps(sb, key, prop.AdditionalProperties.Schema, depth) + } else { + sb.WriteString(indent + key + ": {}\n") + } + case "array": + sb.WriteString(indent + key + ": []\n") + case "boolean": + val := g.defaultBool(prop) + sb.WriteString(fmt.Sprintf("%s%s: %v\n", indent, key, val)) + case "integer", "number": + val := g.defaultNumber(prop) + sb.WriteString(fmt.Sprintf("%s%s: %v\n", indent, key, val)) + default: // string, or unknown + val := g.defaultString(prop) + sb.WriteString(fmt.Sprintf("%s%s: %q\n", indent, key, val)) + } + + // Blank line between top-level keys for readability + if depth == 1 { + sb.WriteString("\n") + } + } +} + +// writeAdditionalProps emits a commented example entry for map-of-objects fields. +func (g *generator) writeAdditionalProps(sb *strings.Builder, key string, itemSchema *Schema, depth int) { + indent := strings.Repeat(" ", depth) + itemSchema = g.resolve(itemSchema) + itemSchema = g.effectiveSchema(itemSchema) + + sb.WriteString(indent + key + ":\n") + // Write a commented skeleton of one entry + inner := &strings.Builder{} + innerIndent := strings.Repeat(" ", depth+2) + innerG := &generator{root: g.root} + innerG.writeObject(inner, itemSchema, depth+2, itemSchema.Required) + + commentIndent := indent + " " + sb.WriteString(commentIndent + "# :\n") + for _, line := range strings.Split(strings.TrimRight(inner.String(), "\n"), "\n") { + sb.WriteString(commentIndent + "# " + strings.TrimPrefix(line, innerIndent) + "\n") + } + sb.WriteString("\n") +} + +func (g *generator) defaultString(s *Schema) string { + if s.Default != nil { + return fmt.Sprintf("%v", s.Default) + } + if len(s.Enum) > 0 { + return fmt.Sprintf("%v", s.Enum[0]) + } + return "" +} + +func (g *generator) defaultBool(s *Schema) bool { + if s.Default != nil { + if b, ok := s.Default.(bool); ok { + return b + } + } + return false +} + +func (g *generator) defaultNumber(s *Schema) interface{} { + if s.Default != nil { + return s.Default + } + return 0 +}