Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
7 changes: 4 additions & 3 deletions internal/skills/files/verda-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**

Expand All @@ -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 <type> [--spot] -o json`. Location depends on availability, NOT the reverse
5. **ALWAYS: Images** — `verda --agent images --type <type> -o json`. **NEVER guess slugs** — they vary by instance type
4. **ALWAYS: Availability** — `verda --agent vm availability --type <type> [--spot] -o json`. Location depends on availability, NOT the reverse
5. **ALWAYS: Images** — `verda --agent images --type <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 <type> --os-volume 50 -o json`. Warn if runway < 24h
8. **Confirm** — show summary, wait for "yes"
Expand Down
39 changes: 22 additions & 17 deletions internal/skills/files/verda-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` |
| "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` |
Expand All @@ -41,39 +41,44 @@ 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)

| Flag | Where to Get Value |
|------|-------------------|
| `--kind` | `gpu` or `cpu` — user intent |
| `--instance-type` | `instance-types -o json` → `name` |
| `--os` | `images --type <t> -o json` → `slug` |
| `--os` | `images --type <t> -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 <id> -o json` | — |
| `verda vm start <id> --wait` | `--yes` in agent mode |
| `verda vm shutdown <id> --wait` | `--yes` in agent mode. Alias: `stop` |
| `verda vm hibernate <id> --wait` | `--yes` in agent mode |
| `verda vm delete <id> --wait` | `--yes` **required** in agent mode. Alias: `rm` |
| `verda vm delete <id> --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)

Expand All @@ -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/<name> -o json` | Fields: `InstanceType`, `Location`, `Image`, `SSHKeys[]`, `HostnamePattern`, `Description`. Note: `vm/` prefix required |
| `verda template delete vm/<name>` | Confirm first |
| `verda template create` | Interactive — tell user to run |
Expand Down Expand Up @@ -141,8 +146,8 @@ Hostname patterns: `{random}` → random words, `{location}` → location code
| Parameter | Source | Field |
|-----------|--------|-------|
| instance-type | `instance-types` | `name` |
| location | `availability --type <t>` | `location_code` |
| image/os | `images --type <t>` | `slug` |
| location | `vm availability --type <t>` | `location` |
| image/os | `images --type <t>` | `image_type` |
| ssh-key ID | `ssh-key list` | `id` |
| startup-script ID | `startup-script list` | `id` |
| volume ID | `volume list` | `id` |
Expand Down
6 changes: 5 additions & 1 deletion internal/skills/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion internal/verda-cli/cmd/skills/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
74 changes: 74 additions & 0 deletions internal/verda-cli/cmd/skills/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 7 additions & 1 deletion internal/verda-cli/cmd/skills/uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
34 changes: 34 additions & 0 deletions internal/verda-cli/cmd/skills/uninstall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion internal/verda-cli/cmd/vm/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
15 changes: 5 additions & 10 deletions internal/verda-cli/cmd/vm/wizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"io"
"strconv"
"strings"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{
Expand All @@ -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 {
Expand All @@ -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...)
}

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading