From a1d4bb02eb6c418d6e6160bc0c89a33eae33b51a Mon Sep 17 00:00:00 2001 From: lei Date: Fri, 10 Apr 2026 15:35:45 +0300 Subject: [PATCH 1/2] fix: skills install for Claude Code uses correct directory structure Claude Code requires skills in /SKILL.md format, not flat .md files. Updated manifest file_map, install to create subdirs, uninstall to clean empty dirs, and stale cleanup to handle flat-to-subdir transition. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/skills/files/verda-cloud.md | 7 +- internal/skills/files/verda-reference.md | 39 +++++----- internal/skills/manifest.json | 6 +- internal/verda-cli/cmd/skills/install.go | 17 ++++- internal/verda-cli/cmd/skills/install_test.go | 74 +++++++++++++++++++ internal/verda-cli/cmd/skills/uninstall.go | 8 +- .../verda-cli/cmd/skills/uninstall_test.go | 34 +++++++++ 7 files changed, 162 insertions(+), 23 deletions(-) diff --git a/internal/skills/files/verda-cloud.md b/internal/skills/files/verda-cloud.md index bc1c317..a50e30e 100644 --- a/internal/skills/files/verda-cloud.md +++ b/internal/skills/files/verda-cloud.md @@ -37,7 +37,8 @@ description: Use when the user mentions Verda Cloud, GPU/CPU VMs, cloud instance ### Explore -- Available instances: `verda --agent instance-types [--gpu|--cpu] -o json` → present name, GPU, VRAM, RAM, price_per_hour sorted by price. **Stop.** +- What's available now: `verda --agent vm availability -o json` → shows what's **in stock** with location and pricing. Filter with `--kind gpu` or `--kind cpu` (NOT `--type gpu`). If result is empty or null, tell the user **nothing is in stock** for that kind — do NOT fall back to showing a different kind. **Stop.** +- Full catalog (all types, not just in stock): `verda --agent instance-types [--gpu|--cpu] -o json` → specs and pricing. **Stop.** - Overview/dashboard: `verda --agent status -o json` → instances, volumes, balance, burn rate. **Stop.** - Running costs: `verda --agent cost running -o json` → per-instance breakdown. **Stop.** @@ -50,8 +51,8 @@ Otherwise walk this chain. **ALWAYS** steps must run even if user specified valu 1. **Billing** *(skip if known)* — spot ("cheap", "testing") or on-demand (default) 2. **Compute** *(skip if known)* — GPU (ML/training/CUDA) or CPU (web/API/dev) 3. **Instance type** *(skip if user specified)* — `verda --agent instance-types [--gpu|--cpu] -o json`, present top 3 by price -4. **ALWAYS: Availability** — `verda --agent availability --type [--spot] -o json`. Location depends on availability, NOT the reverse -5. **ALWAYS: Images** — `verda --agent images --type -o json`. **NEVER guess slugs** — they vary by instance type +4. **ALWAYS: Availability** — `verda --agent vm availability --type [--spot] -o json`. Location depends on availability, NOT the reverse +5. **ALWAYS: Images** — `verda --agent images --type -o json`. Use `image_type` field for `--os` flag. **NEVER guess** — they vary by instance type 6. **ALWAYS: SSH keys** — `verda --agent ssh-key list -o json`. If user named a key, find its ID 7. **ALWAYS: Cost** — `verda --agent cost balance -o json` + `verda --agent cost estimate --type --os-volume 50 -o json`. Warn if runway < 24h 8. **Confirm** — show summary, wait for "yes" diff --git a/internal/skills/files/verda-reference.md b/internal/skills/files/verda-reference.md index ea7592b..6f78c9c 100644 --- a/internal/skills/files/verda-reference.md +++ b/internal/skills/files/verda-reference.md @@ -21,8 +21,8 @@ All commands: `--agent -o json` (except `verda ssh` and `verda auth show`). | "template", "saved config", "preset", "my templates" | `template list` (alias: `tmpl`) | | "deploy from template", "use template", "quick deploy" | `vm create --from ` | | "status", "overview", "dashboard", "summary" | `status` (alias: `dash`) | -| "what's available", "stock", "capacity" | `availability` | -| "instance types", "GPU types", "CPU types", "specs", "flavors" | `instance-types` | +| "what's available", "in stock", "can I get", "available right now" | `vm availability` (real-time stock + pricing by location) | +| "instance types", "GPU types", "CPU types", "specs", "flavors", "catalog" | `instance-types` (full catalog, not filtered by stock) | | "pricing", "how much", "cost per hour" | `instance-types` or `cost estimate` | | "images", "OS", "Ubuntu", "CUDA" | `images` (NOT `images list`) with `--type` (NOT `--instance-type`) | | "locations", "regions", "datacenters" | `locations` | @@ -41,8 +41,8 @@ All commands: `--agent -o json` (except `verda ssh` and `verda auth show`). |---------|-----------|---------------| | `verda locations -o json` | — | `code`, `city`, `country` | | `verda instance-types -o json` | `--gpu`, `--cpu`, `--spot` | `name`, `price_per_hour`, `spot_price`, `gpu.number_of_gpus`, `gpu_memory.size_in_gigabytes`, `memory.size_in_gigabytes` | -| `verda availability -o json` | `--type`, `--location`, `--spot` | `location_code`, `available` | -| `verda images -o json` | `--type` (NOT `--instance-type`) | `slug` (use in --os), `name`, `category` | +| `verda vm availability -o json` | `--kind` (gpu/cpu), `--type`, `--location`, `--spot`. Use `--kind gpu` NOT `--type gpu` | `location`, `instance_type`, `gpu`, `ram`, `cpu_cores`, `price_per_hour`, `spot_price` | +| `verda images -o json` | `--type` (instance type filter, NOT `--instance-type`), `--category` (e.g. ubuntu, pytorch) | `image_type` (use in --os), `name`, `category` | ## VM Create — Required Flags (`--agent` mode) @@ -50,30 +50,35 @@ All commands: `--agent -o json` (except `verda ssh` and `verda auth show`). |------|-------------------| | `--kind` | `gpu` or `cpu` — user intent | | `--instance-type` | `instance-types -o json` → `name` | -| `--os` | `images --type -o json` → `slug` | +| `--os` | `images --type -o json` → `image_type` field | | `--hostname` | User-provided or auto-generate | -**Optional flags:** `--location` (default FIN-01), `--ssh-key` (repeatable), `--is-spot`, `--os-volume-size` (default 50), `--storage-size`, `--storage-type` (NVMe/HDD), `--startup-script`, `--contract` (PAY_AS_YOU_GO/SPOT/LONG_TERM), `--from` (template), `--wait`, `--wait-timeout` (use 2m) +**Optional flags:** `--location` (default FIN-01), `--ssh-key` (repeatable, takes ID), `--is-spot`, `--os-volume-size` (GiB), `--storage-size` (GiB), `--storage-type` (NVMe/HDD), `--startup-script` (ID), `--contract` (PAY_AS_YOU_GO/SPOT/LONG_TERM), `--from` (template name), `--wait`, `--wait-timeout` (use 2m) ## VM Lifecycle | Command | Key Flags | |---------|-----------| -| `verda vm list -o json` | `--status` (running, offline, provisioning). Fields: `id`, `hostname`, `status`, `instance_type`, `location`, `ip`, `price_per_hour` | +| `verda vm list -o json` | `--status`, `--location`. Fields: `id`, `hostname`, `status`, `instance_type`, `location`, `ip`, `price_per_hour` | | `verda vm describe -o json` | — | | `verda vm start --wait` | `--yes` in agent mode | | `verda vm shutdown --wait` | `--yes` in agent mode. Alias: `stop` | | `verda vm hibernate --wait` | `--yes` in agent mode | -| `verda vm delete --wait` | `--yes` **required** in agent mode. Alias: `rm` | +| `verda vm delete --wait` | `--yes` **required**. `--with-volumes` to also delete attached volumes. Alias: `rm` | + +Batch operations: `--all` with `--status` and/or `--hostname` (glob pattern) to target multiple VMs. +Example: `verda --agent vm shutdown --all --status running --yes --wait -o json` + +Note: `shutdown` alias is `stop`. `delete` alias is `rm`. ## Status & Cost -| Command | Output Fields | -|---------|---------------| -| `verda status -o json` | `instances` (total, running, offline, spot), `volumes` (total, attached, detached, total_size_gb), `financials` (burn_rate_hourly, balance, runway_days), `locations[]` | -| `verda cost balance -o json` | `amount`, `currency` | -| `verda cost estimate -o json` | `total.hourly`, `instance.hourly`, `os_volume.hourly`. Flags: `--type`, `--os-volume`, `--storage`, `--spot` | -| `verda cost running -o json` | `instances[]` (each: `hostname`, `hourly`, `daily`, `monthly`), `total.hourly` | +| Command | Key Flags | Output Fields | +|---------|-----------|---------------| +| `verda status -o json` | — | `instances` (total, running, offline, spot), `volumes` (total, attached, detached, total_size_gb), `financials` (burn_rate_hourly, balance, runway_days), `locations[]` | +| `verda cost balance -o json` | — | `amount`, `currency` | +| `verda cost estimate -o json` | `--type` (required), `--os-volume`, `--storage`, `--storage-type`, `--spot`, `--location` | `total.hourly`, `instance.hourly`, `os_volume.hourly` | +| `verda cost running -o json` | — | `instances[]` (each: `hostname`, `hourly`, `daily`, `monthly`), `total.hourly` | ## SSH (Interactive — Do NOT Run) @@ -96,7 +101,7 @@ Tell user to run in their terminal: | Command | Notes | |---------|-------| -| `verda template list -o json` | Fields: `resource`, `name`, `description` | +| `verda template list -o json` | `--type` to filter (e.g. `--type vm`). Fields: `resource`, `name`, `description` | | `verda template show vm/ -o json` | Fields: `InstanceType`, `Location`, `Image`, `SSHKeys[]`, `HostnamePattern`, `Description`. Note: `vm/` prefix required | | `verda template delete vm/` | Confirm first | | `verda template create` | Interactive — tell user to run | @@ -141,8 +146,8 @@ Hostname patterns: `{random}` → random words, `{location}` → location code | Parameter | Source | Field | |-----------|--------|-------| | instance-type | `instance-types` | `name` | -| location | `availability --type ` | `location_code` | -| image/os | `images --type ` | `slug` | +| location | `vm availability --type ` | `location` | +| image/os | `images --type ` | `image_type` | | ssh-key ID | `ssh-key list` | `id` | | startup-script ID | `startup-script list` | `id` | | volume ID | `volume list` | `id` | diff --git a/internal/skills/manifest.json b/internal/skills/manifest.json index 8b6d6d0..445b4ae 100644 --- a/internal/skills/manifest.json +++ b/internal/skills/manifest.json @@ -9,7 +9,11 @@ "display_name": "Claude Code", "scope": "global", "target": "~/.claude/skills/", - "method": "copy" + "method": "copy", + "file_map": { + "verda-cloud.md": "verda-cloud/SKILL.md", + "verda-reference.md": "verda-reference/SKILL.md" + } }, "cursor": { "display_name": "Cursor", diff --git a/internal/verda-cli/cmd/skills/install.go b/internal/verda-cli/cmd/skills/install.go index f18dcb3..9c96383 100644 --- a/internal/verda-cli/cmd/skills/install.go +++ b/internal/verda-cli/cmd/skills/install.go @@ -309,6 +309,13 @@ func cleanupStaleFiles(dir string, agent *Agent, currentFiles map[string]string, // names and mapped names like verda-cloud.md → SKILL.md). oldDest := agent.DestName(old) if current[oldDest] { + // If file_map was added/changed, the raw source name may still + // exist as a flat file from a previous install without file_map. + // Clean it up (e.g. ~/.claude/skills/verda-cloud.md → now + // installed as ~/.claude/skills/verda-cloud/SKILL.md). + if oldDest != old { + _ = os.Remove(filepath.Join(dir, old)) // best-effort + } continue // still in current manifest } _ = os.Remove(filepath.Join(dir, oldDest)) // best-effort @@ -317,7 +324,15 @@ func cleanupStaleFiles(dir string, agent *Agent, currentFiles map[string]string, func installCopy(dir string, agent *Agent, skillFiles map[string]string) error { for name, content := range skillFiles { - path := filepath.Join(dir, agent.DestName(name)) + dest := agent.DestName(name) + path := filepath.Join(dir, dest) + // Create parent subdirectories for path-based file_map entries + // (e.g. "verda-cloud/SKILL.md" needs the "verda-cloud" dir). + if parent := filepath.Dir(path); parent != dir { + if err := os.MkdirAll(parent, 0o750); err != nil { + return fmt.Errorf("creating directory %s: %w", parent, err) + } + } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { //nolint:gosec // non-sensitive skill files return fmt.Errorf("writing %s: %w", path, err) } diff --git a/internal/verda-cli/cmd/skills/install_test.go b/internal/verda-cli/cmd/skills/install_test.go index 433bbea..6849a34 100644 --- a/internal/verda-cli/cmd/skills/install_test.go +++ b/internal/verda-cli/cmd/skills/install_test.go @@ -112,6 +112,80 @@ func TestInstallCopy_FileMap(t *testing.T) { } } +func TestInstallCopy_SubdirFileMap(t *testing.T) { + t.Parallel() + dir := t.TempDir() + skillFiles := map[string]string{ + "verda-cloud.md": "# Verda Cloud\ncontent", + "verda-reference.md": "# Reference\ncontent", + } + agent := &Agent{ + Name: "claude-code", DisplayName: "Claude Code", + Scope: "global", Method: "copy", Target: dir, + FileMap: map[string]string{ + "verda-cloud.md": "verda-cloud/SKILL.md", + "verda-reference.md": "verda-reference/SKILL.md", + }, + } + if err := installForAgent(agent, skillFiles, nil); err != nil { + t.Fatalf("install error: %v", err) + } + // Both should be installed in subdirectories as SKILL.md + for _, sub := range []string{"verda-cloud", "verda-reference"} { + path := filepath.Join(dir, sub, "SKILL.md") + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected %s/SKILL.md to exist", sub) + } + } + // Flat files should NOT exist + for _, flat := range []string{"verda-cloud.md", "verda-reference.md"} { + if _, err := os.Stat(filepath.Join(dir, flat)); !os.IsNotExist(err) { + t.Fatalf("%s should not exist as flat file", flat) + } + } +} + +func TestInstallCopy_CleansUpFlatFilesOnFileMapChange(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Simulate a previous install that wrote flat files (no file_map). + _ = os.WriteFile(filepath.Join(dir, "verda-cloud.md"), []byte("old"), 0o600) + _ = os.WriteFile(filepath.Join(dir, "verda-reference.md"), []byte("old"), 0o600) + + // Re-install with file_map that puts files into subdirectories. + newFiles := map[string]string{ + "verda-cloud.md": "# Cloud v2", + "verda-reference.md": "# Reference v2", + } + agent := &Agent{ + Name: "claude-code", DisplayName: "Claude Code", + Scope: "global", Method: "copy", Target: dir, + FileMap: map[string]string{ + "verda-cloud.md": "verda-cloud/SKILL.md", + "verda-reference.md": "verda-reference/SKILL.md", + }, + } + previousSkills := []string{"verda-cloud.md", "verda-reference.md"} + + if err := installForAgent(agent, newFiles, previousSkills); err != nil { + t.Fatalf("install error: %v", err) + } + + // New subdir files should exist. + for _, sub := range []string{"verda-cloud", "verda-reference"} { + if _, err := os.Stat(filepath.Join(dir, sub, "SKILL.md")); err != nil { + t.Fatalf("expected %s/SKILL.md to exist", sub) + } + } + // Old flat files should be cleaned up. + for _, flat := range []string{"verda-cloud.md", "verda-reference.md"} { + if _, err := os.Stat(filepath.Join(dir, flat)); !os.IsNotExist(err) { + t.Fatalf("old flat file %s should have been removed", flat) + } + } +} + func TestInstallCopy_CleansUpStaleFiles(t *testing.T) { t.Parallel() dir := t.TempDir() diff --git a/internal/verda-cli/cmd/skills/uninstall.go b/internal/verda-cli/cmd/skills/uninstall.go index d74e8dc..e873b33 100644 --- a/internal/verda-cli/cmd/skills/uninstall.go +++ b/internal/verda-cli/cmd/skills/uninstall.go @@ -219,10 +219,16 @@ func uninstallForAgent(agent *Agent, skillNames []string) error { func uninstallCopy(agent *Agent, skillNames []string) error { dir := agent.TargetDir() for _, name := range skillNames { - path := filepath.Join(dir, agent.DestName(name)) + dest := agent.DestName(name) + path := filepath.Join(dir, dest) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("removing %s: %w", path, err) } + // Remove empty parent directory for subdirectory-based installs + // (e.g. verda-cloud/SKILL.md leaves an empty verda-cloud/ dir). + if parent := filepath.Dir(path); parent != dir { + _ = os.Remove(parent) // best-effort; only succeeds if empty + } } return nil } diff --git a/internal/verda-cli/cmd/skills/uninstall_test.go b/internal/verda-cli/cmd/skills/uninstall_test.go index 1e84fc5..535f2a3 100644 --- a/internal/verda-cli/cmd/skills/uninstall_test.go +++ b/internal/verda-cli/cmd/skills/uninstall_test.go @@ -33,6 +33,40 @@ func TestUninstallCopy(t *testing.T) { } } +func TestUninstallCopy_SubdirCleanup(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Create subdirectory-based skill files. + for _, sub := range []string{"verda-cloud", "verda-reference"} { + subDir := filepath.Join(dir, sub) + _ = os.MkdirAll(subDir, 0o750) + _ = os.WriteFile(filepath.Join(subDir, "SKILL.md"), []byte("test"), 0o600) + } + agent := &Agent{ + Name: "claude-code", Scope: "global", Method: "copy", + Target: dir, + FileMap: map[string]string{ + "verda-cloud.md": "verda-cloud/SKILL.md", + "verda-reference.md": "verda-reference/SKILL.md", + }, + } + if err := uninstallForAgent(agent, []string{"verda-cloud.md", "verda-reference.md"}); err != nil { + t.Fatalf("uninstall error: %v", err) + } + // SKILL.md files should be removed. + for _, sub := range []string{"verda-cloud", "verda-reference"} { + if _, err := os.Stat(filepath.Join(dir, sub, "SKILL.md")); !os.IsNotExist(err) { + t.Fatalf("expected %s/SKILL.md to be deleted", sub) + } + } + // Empty subdirectories should be removed. + for _, sub := range []string{"verda-cloud", "verda-reference"} { + if _, err := os.Stat(filepath.Join(dir, sub)); !os.IsNotExist(err) { + t.Fatalf("expected empty dir %s to be removed", sub) + } + } +} + func TestUninstallAppend(t *testing.T) { t.Parallel() dir := t.TempDir() From 689c89a6116be85c6a630ba1941c173069952b51 Mon Sep 17 00:00:00 2001 From: lei Date: Fri, 10 Apr 2026 16:38:37 +0300 Subject: [PATCH 2/2] fix: wizard summary rendering and deadlock with composite engine The deployment summary was written directly to stderr while the composite Bubble Tea program owned the terminal, causing garbled horizontal output. Convert renderDeploymentSummary to return a string and render it as a wizard View in the TUI layout. Upgrade verdagostack to v1.3.1 which fixes the Loader/stdin deadlock by using per-prompt program lifecycle on real terminals. Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 2 +- go.sum | 4 +- internal/verda-cli/cmd/vm/create.go | 2 +- internal/verda-cli/cmd/vm/wizard.go | 15 ++--- internal/verda-cli/cmd/vm/wizard_summary.go | 74 +++++++++++++++------ internal/verda-cli/cmd/vm/wizard_test.go | 4 +- 6 files changed, 66 insertions(+), 35 deletions(-) diff --git a/go.mod b/go.mod index b6b8b3d..e26843a 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/verda-cloud/verdacloud-sdk-go v1.4.2 - github.com/verda-cloud/verdagostack v1.3.0 + github.com/verda-cloud/verdagostack v1.3.1 go.yaml.in/yaml/v3 v3.0.4 ) diff --git a/go.sum b/go.sum index ffa11ad..c61ef32 100644 --- a/go.sum +++ b/go.sum @@ -104,8 +104,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/verda-cloud/verdacloud-sdk-go v1.4.2 h1:oVb8fHVQOY+YPuuMYMee9gYCkPTwAw01LmkqxM21T/Y= github.com/verda-cloud/verdacloud-sdk-go v1.4.2/go.mod h1:pmlpiCL9fTSikZ3qWLJPpHOG0E8PKkQVUX5s4Z+SktY= -github.com/verda-cloud/verdagostack v1.3.0 h1:NxW5OaE79tbc9pemy/Zasjqw08IuvNr0ivlpe0VP91Q= -github.com/verda-cloud/verdagostack v1.3.0/go.mod h1:eWTGv3kbBUGVCjNKZYLzzK9+UwpNWoPN3B2vebN2otY= +github.com/verda-cloud/verdagostack v1.3.1 h1:OFDW1TMEwdspVmYZWnl5ONhZqllXOT6xQIiyLlw8KS4= +github.com/verda-cloud/verdagostack v1.3.1/go.mod h1:eWTGv3kbBUGVCjNKZYLzzK9+UwpNWoPN3B2vebN2otY= 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= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= diff --git a/internal/verda-cli/cmd/vm/create.go b/internal/verda-cli/cmd/vm/create.go index 12d30f7..34cf24c 100644 --- a/internal/verda-cli/cmd/vm/create.go +++ b/internal/verda-cli/cmd/vm/create.go @@ -268,7 +268,7 @@ func missingCreateFlags(opts *createOptions) []string { } func runWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *createOptions) error { - flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeDeploy, ioStreams.ErrOut) + flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeDeploy) engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) return engine.Run(ctx, flow) } diff --git a/internal/verda-cli/cmd/vm/wizard.go b/internal/verda-cli/cmd/vm/wizard.go index 798c86e..fc9a7fd 100644 --- a/internal/verda-cli/cmd/vm/wizard.go +++ b/internal/verda-cli/cmd/vm/wizard.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "strconv" "strings" @@ -78,7 +77,7 @@ func RunTemplateWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil } func runTemplateWizardWithOpts(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *createOptions) (*TemplateResult, error) { - flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeTemplate, ioStreams.ErrOut) + flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeTemplate) engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) if err := engine.Run(ctx, flow); err != nil { return nil, err @@ -118,7 +117,7 @@ func optsToTemplateResult(opts *createOptions) *TemplateResult { // billing-type → contract → kind → instance-type → location → // image → os-volume-size → storage-size → ssh-keys → // startup-script → hostname → description -func buildCreateFlow(ctx context.Context, getClient clientFunc, opts *createOptions, mode WizardMode, errOut io.Writer) *wizard.Flow { +func buildCreateFlow(ctx context.Context, getClient clientFunc, opts *createOptions, mode WizardMode) *wizard.Flow { cache := &apiCache{} steps := []wizard.Step{ @@ -137,7 +136,7 @@ func buildCreateFlow(ctx context.Context, getClient clientFunc, opts *createOpti steps = append(steps, stepHostname(opts), stepDescription(opts), - stepConfirmDeploy(ctx, errOut, getClient, cache, opts), + stepConfirmDeploy(opts), ) } if mode == WizardModeTemplate { @@ -151,9 +150,9 @@ func buildCreateFlow(ctx context.Context, getClient clientFunc, opts *createOpti {ID: "hints", View: wizard.NewHintBarView(wizard.WithHintStyle(bubbletea.HintStyle()))}, } if mode == WizardModeDeploy { - // Prepend progress bar for deploy mode only layout = append([]wizard.ViewDef{ {ID: "progress", View: wizard.NewProgressView(wizard.WithProgressPercent())}, + {ID: "summary", View: newSummaryView(ctx, getClient, cache, opts)}, }, layout...) } @@ -787,17 +786,13 @@ func stepDescription(opts *createOptions) wizard.Step { // --- Step 13: Deployment Summary & Confirm --- -func stepConfirmDeploy(ctx context.Context, errOut io.Writer, getClient clientFunc, cache *apiCache, opts *createOptions) wizard.Step { +func stepConfirmDeploy(opts *createOptions) wizard.Step { return wizard.Step{ Name: "confirm-deploy", Description: "Deploy now?", Prompt: wizard.ConfirmPrompt, Required: true, Default: func(_ map[string]any) any { - // Ensure pricing data is available (may be missing when - // steps were skipped via --from template pre-fill). - ensurePricingCache(ctx, getClient, cache) - renderDeploymentSummary(errOut, opts, cache) return true }, Setter: func(v any) { diff --git a/internal/verda-cli/cmd/vm/wizard_summary.go b/internal/verda-cli/cmd/vm/wizard_summary.go index 2d6c8dd..7d3baad 100644 --- a/internal/verda-cli/cmd/vm/wizard_summary.go +++ b/internal/verda-cli/cmd/vm/wizard_summary.go @@ -1,16 +1,48 @@ package vm import ( + "context" "fmt" - "io" + "reflect" "strconv" "strings" "charm.land/lipgloss/v2" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "github.com/verda-cloud/verdagostack/pkg/tui/wizard" ) -func renderDeploymentSummary(w io.Writer, opts *createOptions, cache *apiCache) { +// summaryView implements wizard.View and renders the deployment summary +// when the wizard reaches the confirm-deploy step. +type summaryView struct { + ctx context.Context + getClient clientFunc + cache *apiCache + opts *createOptions + last string +} + +func newSummaryView(ctx context.Context, getClient clientFunc, cache *apiCache, opts *createOptions) *summaryView { + return &summaryView{ctx: ctx, getClient: getClient, cache: cache, opts: opts} +} + +func (v *summaryView) Update(msg any) (render string, publish []any) { + if sc, ok := msg.(wizard.StepChangedMsg); ok { + if sc.StepName == "confirm-deploy" { + ensurePricingCache(v.ctx, v.getClient, v.cache) + v.last = renderDeploymentSummary(v.opts, v.cache) + } else { + v.last = "" + } + } + return v.last, nil +} + +func (v *summaryView) Subscribe() []reflect.Type { + return nil // receive all engine broadcasts +} + +func renderDeploymentSummary(opts *createOptions, cache *apiCache) string { bold := lipgloss.NewStyle().Bold(true) dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) accent := lipgloss.NewStyle().Foreground(lipgloss.Color("14")) @@ -73,18 +105,20 @@ func renderDeploymentSummary(w io.Writer, opts *createOptions, cache *apiCache) volDetails = append(volDetails, volDetail{name, vType, size, unitP, hourly}) } + var b strings.Builder + // Print summary. - _, _ = fmt.Fprintf(w, "\n %s\n", bold.Render("Deployment Summary")) + fmt.Fprintf(&b, "\n %s\n", bold.Render("Deployment Summary")) billing := "On-Demand" if opts.IsSpot { billing = "Spot Instance" } - _, _ = fmt.Fprintf(w, " %s\n", dim.Render(billing)) - _, _ = fmt.Fprintf(w, " %s\n\n", dim.Render(strings.Repeat("─", 50))) + fmt.Fprintf(&b, " %s\n", dim.Render(billing)) + fmt.Fprintf(&b, " %s\n\n", dim.Render(strings.Repeat("─", 50))) // Instance. - _, _ = fmt.Fprintf(w, " %s\n", accent.Render("Instance")) + fmt.Fprintf(&b, " %s\n", accent.Render("Instance")) var computePriceStr string if instUnits > 1 { perUnit := computeHourly / float64(instUnits) @@ -92,36 +126,38 @@ func renderDeploymentSummary(w io.Writer, opts *createOptions, cache *apiCache) } else { computePriceStr = fmt.Sprintf("$%.4f/hr", computeHourly) } - _, _ = fmt.Fprintf(w, " %-40s %s\n", instLabel, priceStyle.Render(computePriceStr)) - _, _ = fmt.Fprintf(w, " %s\n\n", dim.Render(opts.LocationCode)) + fmt.Fprintf(&b, " %-40s %s\n", instLabel, priceStyle.Render(computePriceStr)) + fmt.Fprintf(&b, " %s\n\n", dim.Render(opts.LocationCode)) // OS. - _, _ = fmt.Fprintf(w, " %s\n", accent.Render("Operating System")) + fmt.Fprintf(&b, " %s\n", accent.Render("Operating System")) osLine := fmt.Sprintf("%s %dGB NVMe", opts.Image, opts.OSVolumeSize) osPrice := fmt.Sprintf("($%.2f/GB/mo) $%.4f/hr", osVolUnitPrice, osVolPrice) - _, _ = fmt.Fprintf(w, " %-40s %s\n\n", osLine, priceStyle.Render(osPrice)) + fmt.Fprintf(&b, " %-40s %s\n\n", osLine, priceStyle.Render(osPrice)) // Storage volumes. if len(volDetails) > 0 { - _, _ = fmt.Fprintf(w, " %s\n", accent.Render("Storage")) + fmt.Fprintf(&b, " %s\n", accent.Render("Storage")) for _, v := range volDetails { line := fmt.Sprintf("%s %dGB %s", v.name, v.size, v.volType) vPrice := fmt.Sprintf("($%.2f/GB/mo) $%.4f/hr", v.unitPrice, v.hourly) - _, _ = fmt.Fprintf(w, " %-40s %s\n", line, priceStyle.Render(vPrice)) + fmt.Fprintf(&b, " %-40s %s\n", line, priceStyle.Render(vPrice)) } - _, _ = fmt.Fprintln(w) + fmt.Fprintln(&b) } // SSH keys. if len(opts.SSHKeyIDs) > 0 { - _, _ = fmt.Fprintf(w, " %s %d key(s)\n\n", accent.Render("SSH Keys"), len(opts.SSHKeyIDs)) + fmt.Fprintf(&b, " %s %d key(s)\n\n", accent.Render("SSH Keys"), len(opts.SSHKeyIDs)) } // Cost breakdown. - _, _ = fmt.Fprintf(w, " %s\n", dim.Render(strings.Repeat("─", 50))) - _, _ = fmt.Fprintf(w, " %-40s %s\n", "Compute total", fmt.Sprintf("$%.4f/hr", computeHourly)) - _, _ = fmt.Fprintf(w, " %-40s %s\n", "Storage total", fmt.Sprintf("$%.4f/hr", storageHourly)) + fmt.Fprintf(&b, " %s\n", dim.Render(strings.Repeat("─", 50))) + fmt.Fprintf(&b, " %-40s %s\n", "Compute total", fmt.Sprintf("$%.4f/hr", computeHourly)) + fmt.Fprintf(&b, " %-40s %s\n", "Storage total", fmt.Sprintf("$%.4f/hr", storageHourly)) total := computeHourly + storageHourly - _, _ = fmt.Fprintf(w, " %s %s\n", bold.Render(fmt.Sprintf("%-40s", "Total")), bold.Render(fmt.Sprintf("$%.4f/hr", total))) - _, _ = fmt.Fprintf(w, " %s\n\n", dim.Render(strings.Repeat("─", 50))) + fmt.Fprintf(&b, " %s %s\n", bold.Render(fmt.Sprintf("%-40s", "Total")), bold.Render(fmt.Sprintf("$%.4f/hr", total))) + fmt.Fprintf(&b, " %s\n", dim.Render(strings.Repeat("─", 50))) + + return b.String() } diff --git a/internal/verda-cli/cmd/vm/wizard_test.go b/internal/verda-cli/cmd/vm/wizard_test.go index b60ea16..64ff56e 100644 --- a/internal/verda-cli/cmd/vm/wizard_test.go +++ b/internal/verda-cli/cmd/vm/wizard_test.go @@ -29,7 +29,7 @@ func TestBuildCreateFlowHappyPath(t *testing.T) { // hostname, description, confirm-deploy. ctx := context.Background() errClient := func() (*verda.Client, error) { return nil, errors.New("no client in test") } - flow := buildCreateFlow(ctx, errClient, opts, WizardModeDeploy, io.Discard) + flow := buildCreateFlow(ctx, errClient, opts, WizardModeDeploy) engine := wizard.NewEngine(nil, nil, wizard.WithOutput(io.Discard), wizard.WithTestResults( @@ -77,7 +77,7 @@ func TestBuildCreateFlowSpotSkipsContract(t *testing.T) { ctx := context.Background() errClient := func() (*verda.Client, error) { return nil, errors.New("no client in test") } - flow := buildCreateFlow(ctx, errClient, opts, WizardModeDeploy, io.Discard) + flow := buildCreateFlow(ctx, errClient, opts, WizardModeDeploy) engine := wizard.NewEngine(nil, nil, wizard.WithOutput(io.Discard), wizard.WithTestResults(