Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
be842d4
fix(output): never emit a zero timestamp as data
hi-lei Aug 12, 2026
aebf432
fix(s3): honor VERDA_S3_* environment variables
hi-lei Aug 12, 2026
aa68b72
fix(sshkey,startupscript): accept an optional positional id on delete
hi-lei Aug 12, 2026
d73ac58
style(vm): name the delete action-name literal for goconst
hi-lei Aug 12, 2026
03d45e5
docs(help): state the four trapdoors the help text hid
hi-lei Aug 12, 2026
c5c9c93
test: cover the surfaces the hotfix changed but left untested
hi-lei Aug 12, 2026
c27e9df
fix(output): stop emitting a zero created_at for instances and jobs
hi-lei Aug 12, 2026
5ce318c
test(util): assert the view copies every field, not just every key
hi-lei Aug 12, 2026
ac0a287
docs(vm): drop the --description length limit; it does not exist
hi-lei Aug 12, 2026
0b0cd02
fix(errors): one error contract for CLI and MCP, and a usable ssh-key…
hi-lei Aug 12, 2026
eff5d86
fix(errors): classify the human path too; announce the contract over MCP
hi-lei Aug 13, 2026
d789c32
fix(tui): don't start the pager when output is not a terminal
hi-lei Aug 13, 2026
54932db
fix(volume): output contract for the volume package
hi-lei Aug 13, 2026
5b70d6c
docs(cost): position CLI money figures as estimates, not billing
hi-lei Aug 13, 2026
3ebae44
feat(pricing): one disclaimer on every surface that shows a price
hi-lei Aug 13, 2026
3391f40
refactor(pricing): shorten the disclaimer, pin it out of structured o…
hi-lei Aug 13, 2026
f7d6920
fix(output): strip ANSI at the stream boundary, not per command
hi-lei Aug 13, 2026
999e8a3
test(util): annotate the provably-safe uint conversion for gosec
hi-lei Aug 13, 2026
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
12 changes: 8 additions & 4 deletions cmd/verda/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,16 @@ func main() {
// agent-mode branch so a cancel stays silent there too.
return
} else if err != nil {
// In agent mode, always emit structured JSON errors.
// One classifier, two renderers: JSON envelope for agents, the same
// classified message as plain text for humans.
ae := cmdutil.ClassifyError(err)

if opts.Agent || cmdutil.IsAgentError(err) {
ae := cmdutil.ClassifyError(err)
cmdutil.WriteAgentError(os.Stderr, ae)
os.Exit(ae.ExitCode)
}
// Normal mode: plain text error.
msg := err.Error()

msg := ae.Message
// For auth-related errors, append profile context so the user
// knows which profile was used and how to switch.
if isAuthRelated(msg) && opts.AuthOptions != nil {
Expand All @@ -51,6 +53,8 @@ func main() {
msg += "\n hint: run 'verda auth use' to switch profile, or 'verda auth show' to check credentials"
}
fmt.Fprintln(os.Stderr, msg)
// Human failures stay exit 1; ae.ExitCode is agent-mode only, so `$?`
// keeps its meaning for existing callers.
os.Exit(1)
}
}
Expand Down
31 changes: 30 additions & 1 deletion docs/agent-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,31 @@ The requested resource does not exist.

**Agent action:** Verify the resource ID is correct. List resources to find the right one.

### `SSH_KEY_REQUIRED`

**Exit code:** 2

`POST /instances` requires at least one SSH key and the request carried none. The
API's own text lists an absent value as acceptable and then rejects it, so the
server wording is preserved under `details.api_message` rather than shown as the
message.

```json
{
"error": {
"code": "SSH_KEY_REQUIRED",
"message": "the API requires at least one SSH key to create an instance, and this request had none: pass --ssh-key <id> (CLI) or ssh_key_ids (MCP); list ids with \"verda ssh-key list\"",
"details": {
"status": 400,
"api_message": "SSH keys can be an array of UUID's, a single UUID string, null value or not defined"
}
}
}
```

**Agent action:** Call `list_ssh_keys` (or `verda ssh-key list`), then retry with
at least one key id. Creating a key first is `add_ssh_key` / `verda ssh-key add`.

### `INSUFFICIENT_BALANCE`

**Exit code:** 6
Expand Down Expand Up @@ -260,4 +285,8 @@ Tools exposed by `verda mcp serve` reuse this contract with one transport differ
{"error": {"code": "CONFIRMATION_REQUIRED", "message": "action \"delete\" creates billing or destructive changes and requires an explicit confirm: true argument", "details": {"action": "delete"}}}
```

All other tool failures (API errors, auth, unknown IDs) arrive as plain-text `isError` results. See `internal/verda-cli/cmd/mcp/README.md` for the full tool reference.
**One error type, one classifier.** MCP argument errors are plain `cmdutil.AgentError` values — not a parallel type — and `toolErrorResult` renders *every* failure through `ClassifyError`, the same funnel the CLI uses. So an API 404 reaches an agent as `NOT_FOUND` over MCP exactly as it does on the CLI, and a new code added to the classifier appears on both surfaces without touching either renderer.

> Changed 2026-08-12: MCP previously carried a private `argError` type, so only argument errors had a code and every other failure degraded to a bare string. Tool errors that used to be plain text now arrive as the envelope. Handlers still returning `mcp.NewToolResultError(err.Error())` directly bypass this — converting the remaining call sites is tracked separately.

See `internal/verda-cli/cmd/mcp/README.md` for the full tool reference.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ require (
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.15
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1
github.com/aws/smithy-go v1.25.0
github.com/charmbracelet/colorprofile v0.4.2
github.com/charmbracelet/x/term v0.2.2
github.com/google/go-containerregistry v0.21.5
github.com/mark3labs/mcp-go v0.47.0
Expand All @@ -45,7 +46,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
Expand Down
9 changes: 7 additions & 2 deletions internal/verda-cli/cmd/cost/cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ import (
func NewCmdCost(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command {
cmd := &cobra.Command{
Use: "cost",
Short: "Cost estimation, pricing, and billing",
Short: "Cost estimates, pricing, and account balance",
Long: cmdutil.LongDesc(`
Estimate costs, view price history, and check account balance.
Estimate costs, view pricing, and check account balance.

Figures here are estimates built from catalog prices, for planning.
The web console is the authority on what you are charged: it
accounts for credits, discounts and contract terms this CLI cannot
see. Where the two differ, the web console is right.
`),
Run: cmdutil.DefaultSubCommandRun(ioStreams.Out),
}
Expand Down
3 changes: 2 additions & 1 deletion internal/verda-cli/cmd/cost/estimate.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,8 @@ func renderEstimate(w interface{ Write([]byte) (int, error) }, e *Estimate) {
bold.Render(price.Render(fmt.Sprintf("%10s", formatPrice(e.Total.Hourly)))),
bold.Render(price.Render(fmt.Sprintf("%10s", formatPrice(e.Total.Daily)))),
bold.Render(price.Render(fmt.Sprintf("%12s", formatPrice(e.Total.Monthly)))))
_, _ = fmt.Fprintf(w, " %s\n\n", sep)
_, _ = fmt.Fprintf(w, " %s\n", sep)
_, _ = fmt.Fprintf(w, " %s\n\n", dim.Render(cmdutil.PriceDisclaimer))
}

func renderLine(w interface{ Write([]byte) (int, error) }, label string, item LineItem, priceStyle *lipgloss.Style) {
Expand Down
24 changes: 24 additions & 0 deletions internal/verda-cli/cmd/cost/estimate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package cost

import (
"bytes"
"math"
"strings"
"testing"
Expand Down Expand Up @@ -187,3 +188,26 @@ func TestEstimateTotals(t *testing.T) {
t.Fatalf("expected total monthly $381.20, got $%.2f", e.Total.Monthly)
}
}

// The disclaimer belongs in human output only: adding it to JSON/YAML would
// change the contract agents parse.
func TestEstimateStructuredOutputHasNoDisclaimer(t *testing.T) {
t.Parallel()

e := Estimate{
InstanceType: "CPU.4V.16G",
Instance: LineItem{Hourly: 0.0279, Daily: 0.6696, Monthly: 20.367},
}
e.computeTotals()

var buf bytes.Buffer
if _, err := cmdutil.WriteStructured(&buf, "json", e); err != nil {
t.Fatalf("WriteStructured: %v", err)
}
if strings.Contains(buf.String(), cmdutil.PriceDisclaimer) {
t.Errorf("disclaimer leaked into JSON:\n%s", buf.String())
}
if strings.Contains(buf.String(), "disclaimer") {
t.Errorf("JSON gained a disclaimer field:\n%s", buf.String())
}
}
16 changes: 10 additions & 6 deletions internal/verda-cli/cmd/cost/running.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,14 @@ import (
func newCmdRunning(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command {
return &cobra.Command{
Use: "running",
Short: "Show costs of currently running instances",
Short: "Estimate the ongoing cost of running instances",
Long: cmdutil.LongDesc(`
Calculate the cost of all currently running instances,
including their attached volumes. Shows per-instance
breakdown and total burn rate.
Estimate what currently running instances and their attached volumes
cost per hour, day and month, with a per-instance breakdown.

This is a sum of catalog prices, not an invoice: it excludes credits,
discounts, contract terms and partial-hour handling. Check the web console
for actual charges.
`),
Example: cmdutil.Examples(`
verda cost running
Expand Down Expand Up @@ -211,9 +214,10 @@ func renderRunning(w interface{ Write([]byte) (int, error) }, s *RunningCostSumm

_, _ = fmt.Fprintf(w, " %s\n", sep)
_, _ = fmt.Fprintf(w, " %s %s/hr %s/day %s/mo\n",
bold.Render("Total Burn"),
bold.Render("Est. Burn "),
bold.Render(price.Render(formatPrice(s.Total.Hourly))),
bold.Render(price.Render(formatPrice(s.Total.Daily))),
bold.Render(price.Render(formatPrice(s.Total.Monthly))))
_, _ = fmt.Fprintf(w, " %s\n\n", sep)
_, _ = fmt.Fprintf(w, " %s\n", sep)
_, _ = fmt.Fprintf(w, " %s\n\n", dim.Render(cmdutil.PriceDisclaimer))
}
29 changes: 29 additions & 0 deletions internal/verda-cli/cmd/cost/running_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package cost

import (
"strings"
"testing"

"github.com/verda-cloud/verdacloud-sdk-go/pkg/verda"
Expand Down Expand Up @@ -102,3 +103,31 @@ func TestRunningCostSummaryTotals(t *testing.T) {
t.Fatalf("expected total monthly 423.4, got %f", s.Total.Monthly)
}
}

// The web console is the billing source of truth, so a CLI total must read as an
// estimate. This pins the wording that says so.
func TestRenderRunningLabelsTheTotalAsAnEstimate(t *testing.T) {
t.Parallel()

s := &RunningCostSummary{
Instances: []RunningInstanceCost{{
Hostname: "box-a", InstanceType: "CPU.4V.16G",
Hourly: 0.0279, Daily: 0.6696, Monthly: 20.367,
}},
}
s.computeTotals()

var b strings.Builder
renderRunning(&b, s)
out := b.String()

if !strings.Contains(out, "Est. Burn") {
t.Errorf("total is not labeled as an estimate:\n%s", out)
}
if strings.Contains(out, "Total Burn") {
t.Errorf("total still reads as an authoritative charge:\n%s", out)
}
if !strings.Contains(out, cmdutil.PriceDisclaimer) {
t.Errorf("missing the shared price disclaimer:\n%s", out)
}
}
11 changes: 8 additions & 3 deletions internal/verda-cli/cmd/instancetypes/instancetypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,14 @@ func NewCmdInstanceTypes(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.
cmd := &cobra.Command{
Use: "instance-types",
Aliases: []string{"types"},
Short: "List available instance types with specs and pricing",
Short: "List the instance type catalog with specs and pricing",
Long: cmdutil.LongDesc(`
List all available instance types with their specifications
and pricing. Filter by GPU or CPU to narrow results.
List every instance type Verda offers, with specifications and pricing.
Filter by GPU or CPU to narrow results.

This is the catalog, not live stock: a type listed here may have no
capacity in any location right now. Run "verda availability" for what
is actually deployable, per location.
`),
Example: cmdutil.Examples(`
# All instance types
Expand Down Expand Up @@ -163,6 +167,7 @@ func renderTypes(w interface{ Write([]byte) (int, error) }, types []verda.Instan
}
}

_, _ = fmt.Fprintf(w, "\n %s\n", dim.Render(cmdutil.PriceDisclaimer))
_, _ = fmt.Fprintln(w)
}

Expand Down
Loading