| name | agentctl-cli |
| description | Build CLI tools using Go with Cobra and Viper. Use for implementing agentctl commands, interactive prompts, configuration management, and output formatting. Triggers on "CLI", "agentctl", "command line", "cobra", "terminal application", "interactive prompt", or when implementing spec/009-developer-experience.md CLI section. |
agentctl CLI Development
Overview
Build the agentctl CLI tool for AgentStack platform interaction. Implements authentication, project management, agent operations, development workflows, and evaluation commands.
CLI Architecture
┌─────────────────────────────────────────────────────────────────┐
│ agentctl Architecture │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ cmd/agentctl/ │ │
│ │ main.go → root.go → [auth|project|agent|dev|eval].go │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ internal/cli/ │ │
│ │ config/ │ client/ │ output/ │ prompt/ │ spinner/ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ pkg/agentstack/ │ │
│ │ SDK client for API communication │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Project Structure
agentctl/
├── cmd/agentctl/
│ ├── main.go
│ ├── root.go # Root command, global flags
│ ├── auth.go # auth login, logout, whoami
│ ├── project.go # project create, list, switch
│ ├── agent.go # agent init, deploy, list, logs, delete
│ ├── dev.go # dev (local development)
│ ├── eval.go # eval run, dataset, report, compare
│ └── version.go # version command
├── internal/cli/
│ ├── config/ # Configuration management
│ ├── client/ # API client wrapper
│ ├── output/ # Table, JSON, YAML output
│ ├── prompt/ # Interactive prompts
│ └── spinner/ # Progress indicators
├── pkg/agentstack/ # SDK (can be external package)
├── templates/ # Agent scaffolding templates
└── Makefile
Root Command Setup
package main
import (
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
cfgFile string
output string
)
var rootCmd = &cobra.Command{
Use: "agentctl",
Short: "AgentStack CLI - Deploy and manage AI agents",
Long: `agentctl is the command-line interface for the AgentStack platform.
It allows you to create, deploy, and manage AI agents with full
observability and evaluation capabilities.`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return initConfig()
},
}
func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
"config file (default is $HOME/.agentctl/config.yaml)")
rootCmd.PersistentFlags().StringVarP(&output, "output", "o", "table",
"output format: table, json, yaml")
rootCmd.PersistentFlags().Bool("no-color", false,
"disable colored output")
viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
rootCmd.AddCommand(authCmd)
rootCmd.AddCommand(projectCmd)
rootCmd.AddCommand(agentCmd)
rootCmd.AddCommand(devCmd)
rootCmd.AddCommand(evalCmd)
rootCmd.AddCommand(versionCmd)
}
func {
cfgFile != {
viper.SetConfigFile(cfgFile)
} {
home, err := os.UserHomeDir()
err != {
err
}
viper.AddConfigPath(home + )
viper.SetConfigName()
viper.SetConfigType()
}
viper.SetEnvPrefix()
viper.AutomaticEnv()
viper.ReadInConfig()
}
{
err := rootCmd.Execute(); err != {
os.Exit()
}
}
Authentication Commands
package main
import (
"fmt"
"github.com/spf13/cobra"
)
var authCmd = &cobra.Command{
Use: "auth",
Short: "Manage authentication",
}
var authLoginCmd = &cobra.Command{
Use: "login",
Short: "Login to AgentStack",
RunE: func(cmd *cobra.Command, args []string) error {
token, _ := cmd.Flags().GetString("token")
if token != "" {
return loginWithToken(token)
}
return loginBrowser()
},
}
var authWhoamiCmd = &cobra.Command{
Use: "whoami",
Short: "Display current identity",
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
if cfg.Token == "" {
return fmt.Errorf("not logged in, run: agentctl auth login")
}
client := client.New(cfg)
user, err := client.Auth.WhoAmI(cmd.Context())
if err != nil {
return err
}
output.Print(user, output.Format(viper.GetString("output")))
return nil
},
}
func init() {
authLoginCmd.Flags().String("token", , )
authCmd.AddCommand(authLoginCmd)
authCmd.AddCommand(authWhoamiCmd)
authCmd.AddCommand(&cobra.Command{
Use: ,
Short: ,
RunE: {
config.ClearCredentials()
},
})
}
{
fmt.Println()
server := oauth.NewCallbackServer()
server.Start()
url := fmt.Sprintf(, config.Load().Endpoint)
browser.Open(url)
token := <-server.TokenChan
config.SaveCredentials(token)
}
Agent Commands
package main
import (
"fmt"
"os"
"text/template"
"github.com/spf13/cobra"
)
var agentCmd = &cobra.Command{
Use: "agent",
Short: "Manage agents",
}
var agentInitCmd = &cobra.Command{
Use: "init <name>",
Short: "Scaffold a new agent",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
tmpl, _ := cmd.Flags().GetString("template")
return scaffoldAgent(name, tmpl)
},
}
var agentDeployCmd = &cobra.Command{
Use: "deploy [path]",
Short: "Deploy an agent to the platform",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := "."
if len(args) > 0 {
path = args[0]
}
wait, _ := cmd.Flags().GetBool("wait")
return deployAgent(cmd.Context(), path, wait)
},
}
var agentLogsCmd = &cobra.Command{
Use: "logs <name>",
Short: "Stream agent logs",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []) {
follow, _ := cmd.Flags().GetBool()
tail, _ := cmd.Flags().GetInt()
streamLogs(cmd.Context(), args[], follow, tail)
},
}
{
agentInitCmd.Flags().StringP(, , ,
)
agentDeployCmd.Flags().BoolP(, , ,
)
agentLogsCmd.Flags().BoolP(, , , )
agentLogsCmd.Flags().IntP(, , , )
agentCmd.AddCommand(agentInitCmd)
agentCmd.AddCommand(agentDeployCmd)
agentCmd.AddCommand(agentLogsCmd)
agentCmd.AddCommand(&cobra.Command{
Use: ,
Short: ,
RunE: listAgents,
})
agentCmd.AddCommand(&cobra.Command{
Use: ,
Short: ,
Args: cobra.ExactArgs(),
RunE: deleteAgent,
})
}
{
spinner := spinner.New( + name)
spinner.Start()
err := os.MkdirAll(name, ); err != {
spinner.Fail()
err
}
spinner.Success()
files := templates.Get(tmpl)
_, f := files {
spinner.Update( + f.Name)
err := generateFile(name, f); err != {
spinner.Fail( + f.Name)
err
}
spinner.Success( + f.Name)
}
fmt.Printf(, name)
fmt.Println()
fmt.Printf(, name)
fmt.Println()
fmt.Println()
}
Development Commands
package main
import (
"context"
"fmt"
"os"
"os/exec"
"os/signal"
"github.com/spf13/cobra"
)
var devCmd = &cobra.Command{
Use: "dev",
Short: "Start local development server",
RunE: runDev,
}
func init() {
devCmd.Flags().IntP("port", "p", 8080, "port to run on")
devCmd.Flags().Bool("mock-llm", false, "use mock LLM provider")
}
func runDev(cmd *cobra.Command, args []string) error {
port, _ := cmd.Flags().GetInt("port")
mockLLM, _ := cmd.Flags().GetBool("mock-llm")
fmt.Println("Starting local development server...")
if _, err := os.Stat("agent.yaml"); os.IsNotExist(err) {
return fmt.Errorf("agent.yaml not found. Run this from an agent directory")
}
spinner := spinner.New("Loading agent.yaml")
spinner.Start()
agentCfg, err := loadAgentConfig("agent.yaml")
if err != nil {
spinner.Fail("Failed to load agent.yaml")
return err
}
spinner.Success("Loaded agent.yaml")
spinner.Update()
err := buildContainer(); err != {
spinner.Fail()
err
}
spinner.Success()
spinner.Update()
deps, err := startDependencies(mockLLM)
err != {
spinner.Fail()
err
}
deps.Stop()
spinner.Success()
spinner.Update()
agent, err := startAgent(port, agentCfg)
err != {
spinner.Fail()
err
}
spinner.Success(fmt.Sprintf(, port))
fmt.Println()
fmt.Printf(, port)
fmt.Printf(, port)
fmt.Printf(, port)
fmt.Println()
watchFiles(agent)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
cancel()
<-ctx.Done()
fmt.Println()
agent.Stop()
}
Evaluation Commands
package main
import (
"fmt"
"github.com/spf13/cobra"
)
var evalCmd = &cobra.Command{
Use: "eval",
Short: "Run agent evaluations",
}
var evalRunCmd = &cobra.Command{
Use: "run [agent]",
Short: "Run evaluation suite",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
agent := ""
if len(args) > 0 {
agent = args[0]
}
dataset, _ := cmd.Flags().GetString("dataset")
scorers, _ := cmd.Flags().GetStringSlice("scorer")
return runEvaluation(cmd.Context(), agent, dataset, scorers)
},
}
var evalCompareCmd = &cobra.Command{
Use: "compare <run1> <run2>",
Short: "Compare two evaluation runs",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return compareRuns(cmd.Context(), args[0], args[1])
},
}
func init() {
evalRunCmd.Flags().StringP("dataset", "d", "", "evaluation dataset")
evalRunCmd.Flags().StringSliceP(, , ,
)
evalCmd.AddCommand(evalRunCmd)
evalCmd.AddCommand(evalCompareCmd)
evalCmd.AddCommand(&cobra.Command{
Use: ,
Short: ,
Args: cobra.ExactArgs(),
RunE: {
showReport(cmd.Context(), args[])
},
})
}
{
spinner := spinner.New()
spinner.Start()
client := client.New(config.Load())
run, err := client.Eval.Start(ctx, &EvalRequest{
AgentID: agent,
Dataset: dataset,
Scorers: scorers,
})
err != {
spinner.Fail()
err
}
run.Status == {
spinner.Update(fmt.Sprintf(, run.Completed, run.Total))
time.Sleep( * time.Second)
run, _ = client.Eval.Get(ctx, run.ID)
}
run.Status == {
spinner.Fail()
fmt.Errorf(, run.Error)
}
spinner.Success()
fmt.Println()
output.PrintTable(run.Results, []{, , })
fmt.Printf(, run.ID)
}
Configuration Management
package config
import (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type Config struct {
CurrentContext string `yaml:"current-context"`
Contexts []Context `yaml:"contexts"`
}
type Context struct {
Name string `yaml:"name"`
Endpoint string `yaml:"endpoint"`
Project string `yaml:"project"`
Token string `yaml:"token,omitempty"`
}
func Load() *Config {
home, _ := os.UserHomeDir()
path := filepath.Join(home, ".agentctl", "config.yaml")
data, err := os.ReadFile(path)
if err != nil {
return &Config{
CurrentContext: "default",
Contexts: []Context{{
Name: "default",
Endpoint: "https://api.agentstack.io",
}},
}
}
var cfg Config
yaml.Unmarshal(data, &cfg)
return &cfg
}
func (c *Config) Current() *Context {
for _, ctx := range c.Contexts {
if ctx.Name == c.CurrentContext {
return &ctx
}
}
return nil
}
Save() {
home, _ := os.UserHomeDir()
dir := filepath.Join(home, )
os.MkdirAll(dir, )
path := filepath.Join(dir, )
data, _ := yaml.Marshal(c)
os.WriteFile(path, data, )
}
Output Formatting
package output
import (
"encoding/json"
"fmt"
"os"
"github.com/olekukonko/tablewriter"
"gopkg.in/yaml.v3"
)
type Format string
const (
FormatTable Format = "table"
FormatJSON Format = "json"
FormatYAML Format = "yaml"
)
func Print(v interface{}, format Format) {
switch format {
case FormatJSON:
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.Encode(v)
case FormatYAML:
enc := yaml.NewEncoder(os.Stdout)
enc.Encode(v)
default:
printTable(v)
}
}
func PrintTable(data [][]string, headers []string) {
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader(headers)
table.SetBorder(false)
table.SetHeaderLine(true)
table.AppendBulk(data)
table.Render()
}
func Success(msg string) {
fmt.Printf("✓ %s\n", msg)
}
func Error(msg string) {
fmt.Fprintf(os.Stderr, "✗ %s\n", msg)
}
{
fmt.Printf(, msg)
}
Interactive Prompts
package prompt
import (
"github.com/AlecAivazis/survey/v2"
)
func Confirm(message string) (bool, error) {
var result bool
err := survey.AskOne(&survey.Confirm{
Message: message,
}, &result)
return result, err
}
func Select(message string, options []string) (string, error) {
var result string
err := survey.AskOne(&survey.Select{
Message: message,
Options: options,
}, &result)
return result, err
}
func Input(message string, defaultValue string) (string, error) {
var result string
err := survey.AskOne(&survey.Input{
Message: message,
Default: defaultValue,
}, &result)
return result, err
}
func Password(message string) (string, error) {
var result string
err := survey.AskOne(&survey.Password{
Message: message,
}, &result)
return result, err
}
Dependencies
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.0
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/olekukonko/tablewriter v0.0.5
github.com/briandowns/spinner v1.23.0
github.com/fatih/color v1.16.0
github.com/fsnotify/fsnotify v1.7.0
gopkg.in/yaml.v3 v3.0.1
Testing
package main
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
)
func TestAgentInit(t *testing.T) {
dir := t.TempDir()
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetArgs([]string{"agent", "init", "test-agent", "-t", "google-adk"})
err := rootCmd.Execute()
assert.NoError(t, err)
assert.FileExists(t, dir+"/test-agent/agent.yaml")
assert.FileExists(t, dir+"/test-agent/Dockerfile")
assert.DirExists(t, dir+"/test-agent/src")
}
Resources
references/cobra-patterns.md - Advanced Cobra patterns
assets/templates/ - Agent scaffolding templates