// Package cmd implements the CLI command tree. package cmd import ( "bytes" "encoding/json" "fmt" "os" "runtime" "text/tabwriter" "github.com/spf13/cobra" "github.com/spf13/viper" "go.moleculesai.app/cli/internal/client" "gopkg.in/yaml.v3" ) // Version is set at build time via -ldflags. var Version = "dev" // Global flags. var ( verbose bool outputFormat string jsonOutput bool configPath string apiURL string ) // rootCmd is the top-level molecule command. var rootCmd = &cobra.Command{ Use: "molecule", Version: Version, Short: "molecule — Molecule AI platform CLI", Long: `molecule is the CLI for the Molecule AI agent platform. Manage workspaces, inspect agents, audit the platform, and configure agent behaviour from the terminal. Quick start: molecule workspace list molecule agent list molecule platform health`, SilenceUsage: true, SilenceErrors: true, } func init() { rootCmd.PersistentFlags().StringVar(&apiURL, "api-url", envOr("MOLECULE_API_URL", "http://localhost:8080"), "Platform API base URL (env: MOLECULE_API_URL)") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose (DEBUG-level) output to stderr") rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "table", "Output format: table | json | yaml") rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Shorthand for --output json") rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to config file (default ~/.config/molecule.yaml or ./molecule.yaml)") // Bind flags to viper so config-file / env values can override defaults. _ = viper.BindPFlag("api_url", rootCmd.PersistentFlags().Lookup("api-url")) _ = viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose")) _ = viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output")) _ = viper.BindPFlag("json", rootCmd.PersistentFlags().Lookup("json")) // --json wins over -o; resolved before any command runs. rootCmd.PersistentPreRun = func(_ *cobra.Command, _ []string) { if jsonOutput { outputFormat = "json" } } rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { return &exitError{code: 2, msg: err.Error()} }) rootCmd.SetErr(os.Stderr) } // Execute runs the CLI. func Execute() error { // Load config file. if configPath != "" { viper.SetConfigFile(configPath) } else { viper.SetConfigName("molecule") viper.AddConfigPath("$HOME/.config") viper.AddConfigPath(".") } viper.AutomaticEnv() _ = viper.ReadInConfig() // ignore not-found; env vars win // Sync config-file / env values back into the globals so cobra flags // reflect the full viper precedence chain (flag > env > config > default). if v := viper.GetString("api_url"); v != "" { apiURL = v } if viper.GetBool("verbose") { verbose = true } if v := viper.GetString("output"); v != "" { outputFormat = v } if viper.GetBool("json") { jsonOutput = true outputFormat = "json" } // rootCmd has SilenceErrors=true, so cobra prints nothing on error. // Route the error through handleErr so user-facing messages (including // exitError fail-fast guidance like the org-verb credential errors) are // printed to stderr with the right exit code instead of being swallowed. return handleErr(rootCmd.Execute()) } // envOr returns the value of env var key, or fallback if unset/empty. func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // newClient builds a Platform client authenticated with the management API // key (MOLECULE_API_KEY) and org id (MOLECULE_ORG_ID). All management verbs // go through this so they don't 401 a hardened tenant. func newClient() *client.Platform { return client.NewWithAuth(apiURL, authToken(), orgID()) } // cpURL is the control-plane base URL for org-lifecycle verbs (org // list/get/create/export). Org ops live on the CP (api.moleculesai.app), // not the per-org tenant host. Defaults to the tenant api-url when unset so // a combined dev host still works. func cpURL() string { if v := os.Getenv("MOLECULE_CP_URL"); v != "" { return v } // Never fall back to apiURL — that would send CP-admin tokens to a tenant host. return "https://api.moleculesai.app" } // cpAdminClient builds a Platform client for the CP ADMIN surface // (/api/v1/admin/orgs). It authenticates with the dedicated CP-admin bearer // (MOLECULE_CP_ADMIN_TOKEN) and sends NO X-Molecule-Org-Id header — the admin // routes are not org-scoped at the gate. Crucially it never carries the tenant // Org API Key (MOLECULE_API_KEY), so the org credential is never leaked to the // control plane. Returns an error when the admin token is unset so callers // fail fast with a clear two-credential message instead of a bare 401. func cpAdminClient() (*client.Platform, error) { tok := cpAdminToken() if tok == "" { return nil, &exitError{code: 2, msg: "this verb hits the control-plane admin API and requires a CP admin bearer token in MOLECULE_CP_ADMIN_TOKEN (distinct from the tenant MOLECULE_API_KEY / Org API Key, which has no standing on the control plane). See `molecule org --help`."} } // OrgID is intentionally empty: AdminGate does not route on it. return client.NewWithAuth(cpURL(), tok, ""), nil } // init registers all subcommand trees. func init() { rootCmd.AddCommand(workspaceCmd) rootCmd.AddCommand(agentCmd) rootCmd.AddCommand(platformCmd) rootCmd.AddCommand(configCmd) rootCmd.AddCommand(initCmd) rootCmd.AddCommand(connectCmd) // Management verbs (PLATFORM-MANAGEMENT-API.md §5(b)). rootCmd.AddCommand(orgCmd) rootCmd.AddCommand(secretCmd) rootCmd.AddCommand(templateCmd) rootCmd.AddCommand(bundleCmd) rootCmd.AddCommand(eventsCmd) rootCmd.AddCommand(approvalsCmd) } // exitError wraps a user-facing error with a specific exit code. type exitError struct { code int msg string } func (e *exitError) Error() string { return e.msg } // handleErr converts an error to the right exit code. func handleErr(err error) error { if err == nil { return nil } if ee, ok := err.(*exitError); ok { fmt.Fprintf(os.Stderr, "%s\n", ee.msg) os.Exit(ee.code) } fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) return nil } // printJSON writes v as JSON to stdout. func printJSON(v interface{}) error { return json.NewEncoder(os.Stdout).Encode(v) } // printRaw pretty-prints a raw JSON response body to stdout. Used by verbs // that wrap loose/pass-through endpoints (events, secrets lists, budget, // exports, allowlist). Honors --output yaml by re-marshaling through a // generic value; otherwise prints indented JSON. func printRaw(raw []byte) error { if len(raw) == 0 { return nil } if outputFormat == "yaml" { var v interface{} if err := json.Unmarshal(raw, &v); err != nil { return err } return printYAML(v) } var buf bytes.Buffer if err := json.Indent(&buf, raw, "", " "); err != nil { // Not valid JSON — print verbatim. fmt.Println(string(raw)) return nil } fmt.Println(buf.String()) return nil } // printYAML writes v as YAML to stdout. func printYAML(v interface{}) error { enc := yaml.NewEncoder(os.Stdout) enc.SetIndent(2) return enc.Encode(v) } // kv writes a key-value pair to the tabwriter (only if v is non-empty). func kv(w *tabwriter.Writer, k, v string) { if v == "" { return } fmt.Fprintf(w, "%s:\t%s\n", k, v) } func versionInfo() string { return fmt.Sprintf("molecule %s (go %s)", Version, runtime.Version()) }