From b570911fdef235c3495a0aa8c89284ed9cf0fb02 Mon Sep 17 00:00:00 2001 From: Jonathan Irwin Date: Sun, 9 Aug 2026 18:15:41 -0400 Subject: [PATCH] feat(cli): scaffold AGENTS.md and print next steps after deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shipping machine-readable commands does not mean agents will find them. Agents run --help to learn a command's flags, not to discover that a capability they had no reason to expect exists. Two changes put the commands in front of them. `cerebrium init` now writes an AGENTS.md alongside main.py and cerebrium.toml. Coding agents read that file at the start of a session without being asked, so it is the one place instructions are seen before any work begins. It covers verifying a deploy, right-sizing hardware from measured usage, and the traps that look like failures — scale-to-zero returning no containers, and config changes needing a redeploy. `cerebrium deploy` now ends with the commands worth running next. An agent reliably reads the output of the command it just ran, and a deploy exiting 0 only means the build succeeded, not that the app is serving. Next-step padding is measured on the uncoloured command, since padding a string that already carries escape codes skews every row. Co-Authored-By: Claude Opus 5 (1M context) --- internal/commands/init.go | 54 ++++++++++++++++ internal/commands/init_test.go | 11 ++++ internal/ui/commands/deploy.go | 59 +++++++++++++++-- internal/ui/commands/deploy_nextsteps_test.go | 63 +++++++++++++++++++ 4 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 internal/ui/commands/deploy_nextsteps_test.go diff --git a/internal/commands/init.go b/internal/commands/init.go index 2af7aea..787cae4 100644 --- a/internal/commands/init.go +++ b/internal/commands/init.go @@ -22,6 +22,52 @@ const exampleMain = `def run(prompt: str): # cerebrium deploy ` +// agentsTemplate is written to AGENTS.md. Coding agents read this file at the +// start of a session without being asked, which is the only reliable way they +// learn the CLI has commands worth running after a deploy — they do not go +// looking through --help for capabilities they have no reason to expect. +const agentsTemplate = `# %[1]s + +A Cerebrium app. ` + "`main.py`" + ` holds the functions that get served; ` + "`cerebrium.toml`" + ` declares +hardware, scaling and dependencies. + +## Deploying + + cerebrium deploy + +A deploy builds a new image and rolls it out. It takes minutes, not seconds, and +the app is not serving the new code until it finishes. + +## Checking a deploy worked + +Do not assume a deploy is live because the command exited 0. Verify it: + + cerebrium containers list %[1]s # what is running right now + cerebrium logs %[1]s # runtime logs + cerebrium runs list %[1]s # recent invocations and their status + cerebrium apps get %[1]s # configured hardware, scaling, build status + +Every command above accepts ` + "`--output json`" + ` for machine-readable output. Prefer it +over parsing the table format. + +## Right-sizing the hardware + + cerebrium metrics resources %[1]s --since 24h + +Reports peak CPU (cores), memory (GB) and GPU memory (GB) over the window, so you +can compare against ` + "`[cerebrium.hardware]`" + ` in cerebrium.toml and adjust. A metric +that reports ` + "`-`" + ` had no samples in the window, which is not the same as zero. + +## Things that surprise people + +- ` + "`min_replicas = 0`" + ` scales the app to zero when idle, so the first request after a + quiet period pays a cold start. ` + "`cerebrium containers list`" + ` returning nothing is + normal for an idle app, not a failed deploy. +- Secrets belong in ` + "`cerebrium secrets`" + `, never in cerebrium.toml — that file is + committed and uploaded with the build. +- Changing ` + "`[cerebrium.hardware]`" + ` or dependencies requires a redeploy to take effect. +` + // NewInitCmd creates a new init command func NewInitCmd() *cobra.Command { var dir string @@ -101,6 +147,7 @@ func runInit(cmd *cobra.Command, name string, dir string) error { projectPath := filepath.Join(dir, name) tomlPath := filepath.Join(projectPath, "cerebrium.toml") mainPath := filepath.Join(projectPath, "main.py") + agentsPath := filepath.Join(projectPath, "AGENTS.md") // Verify the resulting path is safe (no path traversal) absDir, err := filepath.Abs(dir) @@ -145,6 +192,13 @@ func runInit(cmd *cobra.Command, name string, dir string) error { return ui.NewFileSystemError(fmt.Errorf("failed to create cerebrium.toml: %w", err)) } + // Create AGENTS.md so coding agents working in this project know how to + // verify a deploy and size the hardware + agentsContent := fmt.Sprintf(agentsTemplate, name) + if err := os.WriteFile(agentsPath, []byte(agentsContent), 0644); err != nil { //nolint:gosec // Project files need to be readable + return ui.NewFileSystemError(fmt.Errorf("failed to create AGENTS.md: %w", err)) + } + fmt.Println("Cerebrium Cortex project initialized successfully!") fmt.Printf("cd %s && cerebrium deploy to get started\n", projectPath) diff --git a/internal/commands/init_test.go b/internal/commands/init_test.go index 0b6c1d9..914651e 100644 --- a/internal/commands/init_test.go +++ b/internal/commands/init_test.go @@ -43,6 +43,17 @@ func TestRunInit(t *testing.T) { assert.Contains(t, string(mainContent), "Running on Cerebrium") assert.Contains(t, string(mainContent), "cerebrium deploy") + // Check AGENTS.md exists and names the commands an agent needs after a deploy + agentsPath := filepath.Join(projectPath, "AGENTS.md") + agentsContent, err := os.ReadFile(agentsPath) + require.NoError(t, err) + assert.Contains(t, string(agentsContent), "# "+projectName) + assert.Contains(t, string(agentsContent), "cerebrium containers list "+projectName) + assert.Contains(t, string(agentsContent), "cerebrium metrics resources "+projectName) + assert.Contains(t, string(agentsContent), "--output json") + // A stray format verb would ship a broken file to every new project + assert.NotContains(t, string(agentsContent), "%!") + // Check requirements.txt does NOT exist (dependencies are in cerebrium.toml) requirementsPath := filepath.Join(projectPath, "requirements.txt") _, err = os.Stat(requirementsPath) diff --git a/internal/ui/commands/deploy.go b/internal/ui/commands/deploy.go index dcd4a9c..104b25d 100644 --- a/internal/ui/commands/deploy.go +++ b/internal/ui/commands/deploy.go @@ -103,6 +103,40 @@ type DeployView struct { } // NewDeployView creates a new deploy view +// nextStepLines renders the commands worth running once an app is live. A deploy +// exiting 0 only means the build succeeded, so the useful next move is to check +// what is actually running and how much hardware it is using. +func nextStepLines(appName string, colorize bool) []string { + steps := []struct { + command string + description string + }{ + {fmt.Sprintf("cerebrium logs %s", appName), "stream runtime logs"}, + {fmt.Sprintf("cerebrium containers list %s", appName), "see what is running"}, + {fmt.Sprintf("cerebrium metrics resources %s", appName), "check CPU, memory and GPU usage"}, + } + + width := 0 + for _, step := range steps { + if len(step.command) > width { + width = len(step.command) + } + } + + lines := make([]string, 0, len(steps)) + for _, step := range steps { + // Pad against the uncoloured length; escape codes would skew a %-*s width + padding := strings.Repeat(" ", width-len(step.command)) + command := step.command + if colorize { + command = ui.CyanStyle.Render(command) + } + lines = append(lines, " "+command+padding+" "+step.description) + } + + return lines +} + func NewDeployView(ctx context.Context, conf DeployConfig) *DeployView { initialState := StateConfirmation isPartnerDeploy := conf.Config.PartnerService != nil @@ -518,30 +552,43 @@ func (m *DeployView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.status == "success" || msg.status == "ready" { m.state = StateDeploySuccess + appName := m.conf.Config.Deployment.Name + if m.conf.SimpleOutput() { fmt.Println("✓ Build complete!") fmt.Println() - fmt.Printf("✓ %s is now live!\n", m.conf.Config.Deployment.Name) + fmt.Printf("✓ %s is now live!\n", appName) fmt.Println() fmt.Printf("App Dashboard: %s\n", m.appResponse.DashboardURL) fmt.Println("\nEndpoint:") fmt.Printf("POST %s/{function_name}\n", m.appResponse.InternalEndpoint) + fmt.Println("\nNext steps:") + for _, line := range nextStepLines(appName, false) { + fmt.Println(line) + } return m, tea.Quit } // Print success message to scrollback in interactive mode - return m, tea.Sequence( + lines := []tea.Cmd{ tea.Println(""), tea.Println(ui.SuccessStyle.Render("✓ Built app")), tea.Println(""), - tea.Println(ui.GreenStyle.Render(fmt.Sprintf("✓ %s is now live!", m.conf.Config.Deployment.Name))), + tea.Println(ui.GreenStyle.Render(fmt.Sprintf("✓ %s is now live!", appName))), tea.Println(""), tea.Println(fmt.Sprintf("App Dashboard: %s", m.appResponse.DashboardURL)), tea.Println(""), tea.Println("Endpoint:"), - tea.Println(ui.CyanStyle.Render("POST")+" "+m.appResponse.InternalEndpoint+"/{function_name}"), - tea.Quit, - ) + tea.Println(ui.CyanStyle.Render("POST") + " " + m.appResponse.InternalEndpoint + "/{function_name}"), + tea.Println(""), + tea.Println("Next steps:"), + } + for _, line := range nextStepLines(appName, true) { + lines = append(lines, tea.Println(line)) + } + lines = append(lines, tea.Quit) + + return m, tea.Sequence(lines...) } else { m.state = StateDeployError err := ui.NewAPIError(fmt.Errorf("build failed with status: %s", msg.status)) diff --git a/internal/ui/commands/deploy_nextsteps_test.go b/internal/ui/commands/deploy_nextsteps_test.go new file mode 100644 index 0000000..415abdf --- /dev/null +++ b/internal/ui/commands/deploy_nextsteps_test.go @@ -0,0 +1,63 @@ +package commands + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNextStepLines(t *testing.T) { + lines := nextStepLines("my-app", false) + require.Len(t, lines, 3) + + joined := strings.Join(lines, "\n") + assert.Contains(t, joined, "cerebrium logs my-app") + assert.Contains(t, joined, "cerebrium containers list my-app") + assert.Contains(t, joined, "cerebrium metrics resources my-app") +} + +// Descriptions line up because padding is measured on the uncoloured command. +// Padding a string that already carries escape codes would skew every row, so +// the coloured variant has to align identically to the plain one. +func TestNextStepLinesAlignDescriptions(t *testing.T) { + descriptions := []string{ + "stream runtime logs", + "see what is running", + "check CPU, memory and GPU usage", + } + + for _, colorize := range []bool{false, true} { + lines := nextStepLines("my-app", colorize) + require.Len(t, lines, len(descriptions)) + + var columns []int + for i, line := range lines { + column := strings.Index(stripANSI(line), descriptions[i]) + require.NotEqual(t, -1, column, "description %q missing from %q", descriptions[i], line) + columns = append(columns, column) + } + + for _, column := range columns { + assert.Equal(t, columns[0], column, + "descriptions should start at the same column (colorize=%v)", colorize) + } + } +} + +func stripANSI(s string) string { + var out strings.Builder + inEscape := false + for _, r := range s { + switch { + case r == '\x1b': + inEscape = true + case inEscape && r == 'm': + inEscape = false + case !inEscape: + out.WriteRune(r) + } + } + return out.String() +}