Skip to content

Commit fac64c7

Browse files
authored
Fix/skills install claude code dir structure (#40)
* fix: skills install for Claude Code uses correct directory structure Claude Code requires skills in <name>/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.
1 parent 2eae998 commit fac64c7

13 files changed

Lines changed: 228 additions & 58 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ require (
99
github.com/spf13/pflag v1.0.10
1010
github.com/spf13/viper v1.21.0
1111
github.com/verda-cloud/verdacloud-sdk-go v1.4.2
12-
github.com/verda-cloud/verdagostack v1.3.0
12+
github.com/verda-cloud/verdagostack v1.3.1
1313
go.yaml.in/yaml/v3 v3.0.4
1414
)
1515

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
104104
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
105105
github.com/verda-cloud/verdacloud-sdk-go v1.4.2 h1:oVb8fHVQOY+YPuuMYMee9gYCkPTwAw01LmkqxM21T/Y=
106106
github.com/verda-cloud/verdacloud-sdk-go v1.4.2/go.mod h1:pmlpiCL9fTSikZ3qWLJPpHOG0E8PKkQVUX5s4Z+SktY=
107-
github.com/verda-cloud/verdagostack v1.3.0 h1:NxW5OaE79tbc9pemy/Zasjqw08IuvNr0ivlpe0VP91Q=
108-
github.com/verda-cloud/verdagostack v1.3.0/go.mod h1:eWTGv3kbBUGVCjNKZYLzzK9+UwpNWoPN3B2vebN2otY=
107+
github.com/verda-cloud/verdagostack v1.3.1 h1:OFDW1TMEwdspVmYZWnl5ONhZqllXOT6xQIiyLlw8KS4=
108+
github.com/verda-cloud/verdagostack v1.3.1/go.mod h1:eWTGv3kbBUGVCjNKZYLzzK9+UwpNWoPN3B2vebN2otY=
109109
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
110110
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
111111
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=

internal/skills/files/verda-cloud.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ description: Use when the user mentions Verda Cloud, GPU/CPU VMs, cloud instance
3737

3838
### Explore
3939

40-
- Available instances: `verda --agent instance-types [--gpu|--cpu] -o json` → present name, GPU, VRAM, RAM, price_per_hour sorted by price. **Stop.**
40+
- 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.**
41+
- Full catalog (all types, not just in stock): `verda --agent instance-types [--gpu|--cpu] -o json` → specs and pricing. **Stop.**
4142
- Overview/dashboard: `verda --agent status -o json` → instances, volumes, balance, burn rate. **Stop.**
4243
- Running costs: `verda --agent cost running -o json` → per-instance breakdown. **Stop.**
4344

@@ -50,8 +51,8 @@ Otherwise walk this chain. **ALWAYS** steps must run even if user specified valu
5051
1. **Billing** *(skip if known)* — spot ("cheap", "testing") or on-demand (default)
5152
2. **Compute** *(skip if known)* — GPU (ML/training/CUDA) or CPU (web/API/dev)
5253
3. **Instance type** *(skip if user specified)*`verda --agent instance-types [--gpu|--cpu] -o json`, present top 3 by price
53-
4. **ALWAYS: Availability**`verda --agent availability --type <type> [--spot] -o json`. Location depends on availability, NOT the reverse
54-
5. **ALWAYS: Images**`verda --agent images --type <type> -o json`. **NEVER guess slugs** — they vary by instance type
54+
4. **ALWAYS: Availability**`verda --agent vm availability --type <type> [--spot] -o json`. Location depends on availability, NOT the reverse
55+
5. **ALWAYS: Images**`verda --agent images --type <type> -o json`. Use `image_type` field for `--os` flag. **NEVER guess** — they vary by instance type
5556
6. **ALWAYS: SSH keys**`verda --agent ssh-key list -o json`. If user named a key, find its ID
5657
7. **ALWAYS: Cost**`verda --agent cost balance -o json` + `verda --agent cost estimate --type <type> --os-volume 50 -o json`. Warn if runway < 24h
5758
8. **Confirm** — show summary, wait for "yes"

internal/skills/files/verda-reference.md

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ All commands: `--agent -o json` (except `verda ssh` and `verda auth show`).
2121
| "template", "saved config", "preset", "my templates" | `template list` (alias: `tmpl`) |
2222
| "deploy from template", "use template", "quick deploy" | `vm create --from <name>` |
2323
| "status", "overview", "dashboard", "summary" | `status` (alias: `dash`) |
24-
| "what's available", "stock", "capacity" | `availability` |
25-
| "instance types", "GPU types", "CPU types", "specs", "flavors" | `instance-types` |
24+
| "what's available", "in stock", "can I get", "available right now" | `vm availability` (real-time stock + pricing by location) |
25+
| "instance types", "GPU types", "CPU types", "specs", "flavors", "catalog" | `instance-types` (full catalog, not filtered by stock) |
2626
| "pricing", "how much", "cost per hour" | `instance-types` or `cost estimate` |
2727
| "images", "OS", "Ubuntu", "CUDA" | `images` (NOT `images list`) with `--type` (NOT `--instance-type`) |
2828
| "locations", "regions", "datacenters" | `locations` |
@@ -41,39 +41,44 @@ All commands: `--agent -o json` (except `verda ssh` and `verda auth show`).
4141
|---------|-----------|---------------|
4242
| `verda locations -o json` || `code`, `city`, `country` |
4343
| `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` |
44-
| `verda availability -o json` | `--type`, `--location`, `--spot` | `location_code`, `available` |
45-
| `verda images -o json` | `--type` (NOT `--instance-type`) | `slug` (use in --os), `name`, `category` |
44+
| `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` |
45+
| `verda images -o json` | `--type` (instance type filter, NOT `--instance-type`), `--category` (e.g. ubuntu, pytorch) | `image_type` (use in --os), `name`, `category` |
4646

4747
## VM Create — Required Flags (`--agent` mode)
4848

4949
| Flag | Where to Get Value |
5050
|------|-------------------|
5151
| `--kind` | `gpu` or `cpu` — user intent |
5252
| `--instance-type` | `instance-types -o json``name` |
53-
| `--os` | `images --type <t> -o json``slug` |
53+
| `--os` | `images --type <t> -o json``image_type` field |
5454
| `--hostname` | User-provided or auto-generate |
5555

56-
**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)
56+
**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)
5757

5858
## VM Lifecycle
5959

6060
| Command | Key Flags |
6161
|---------|-----------|
62-
| `verda vm list -o json` | `--status` (running, offline, provisioning). Fields: `id`, `hostname`, `status`, `instance_type`, `location`, `ip`, `price_per_hour` |
62+
| `verda vm list -o json` | `--status`, `--location`. Fields: `id`, `hostname`, `status`, `instance_type`, `location`, `ip`, `price_per_hour` |
6363
| `verda vm describe <id> -o json` ||
6464
| `verda vm start <id> --wait` | `--yes` in agent mode |
6565
| `verda vm shutdown <id> --wait` | `--yes` in agent mode. Alias: `stop` |
6666
| `verda vm hibernate <id> --wait` | `--yes` in agent mode |
67-
| `verda vm delete <id> --wait` | `--yes` **required** in agent mode. Alias: `rm` |
67+
| `verda vm delete <id> --wait` | `--yes` **required**. `--with-volumes` to also delete attached volumes. Alias: `rm` |
68+
69+
Batch operations: `--all` with `--status` and/or `--hostname` (glob pattern) to target multiple VMs.
70+
Example: `verda --agent vm shutdown --all --status running --yes --wait -o json`
71+
72+
Note: `shutdown` alias is `stop`. `delete` alias is `rm`.
6873

6974
## Status & Cost
7075

71-
| Command | Output Fields |
72-
|---------|---------------|
73-
| `verda status -o json` | `instances` (total, running, offline, spot), `volumes` (total, attached, detached, total_size_gb), `financials` (burn_rate_hourly, balance, runway_days), `locations[]` |
74-
| `verda cost balance -o json` | `amount`, `currency` |
75-
| `verda cost estimate -o json` | `total.hourly`, `instance.hourly`, `os_volume.hourly`. Flags: `--type`, `--os-volume`, `--storage`, `--spot` |
76-
| `verda cost running -o json` | `instances[]` (each: `hostname`, `hourly`, `daily`, `monthly`), `total.hourly` |
76+
| Command | Key Flags | Output Fields |
77+
|---------|-----------|---------------|
78+
| `verda status -o json` | | `instances` (total, running, offline, spot), `volumes` (total, attached, detached, total_size_gb), `financials` (burn_rate_hourly, balance, runway_days), `locations[]` |
79+
| `verda cost balance -o json` | | `amount`, `currency` |
80+
| `verda cost estimate -o json` | `--type` (required), `--os-volume`, `--storage`, `--storage-type`, `--spot`, `--location` | `total.hourly`, `instance.hourly`, `os_volume.hourly` |
81+
| `verda cost running -o json` | | `instances[]` (each: `hostname`, `hourly`, `daily`, `monthly`), `total.hourly` |
7782

7883
## SSH (Interactive — Do NOT Run)
7984

@@ -96,7 +101,7 @@ Tell user to run in their terminal:
96101

97102
| Command | Notes |
98103
|---------|-------|
99-
| `verda template list -o json` | Fields: `resource`, `name`, `description` |
104+
| `verda template list -o json` | `--type` to filter (e.g. `--type vm`). Fields: `resource`, `name`, `description` |
100105
| `verda template show vm/<name> -o json` | Fields: `InstanceType`, `Location`, `Image`, `SSHKeys[]`, `HostnamePattern`, `Description`. Note: `vm/` prefix required |
101106
| `verda template delete vm/<name>` | Confirm first |
102107
| `verda template create` | Interactive — tell user to run |
@@ -141,8 +146,8 @@ Hostname patterns: `{random}` → random words, `{location}` → location code
141146
| Parameter | Source | Field |
142147
|-----------|--------|-------|
143148
| instance-type | `instance-types` | `name` |
144-
| location | `availability --type <t>` | `location_code` |
145-
| image/os | `images --type <t>` | `slug` |
149+
| location | `vm availability --type <t>` | `location` |
150+
| image/os | `images --type <t>` | `image_type` |
146151
| ssh-key ID | `ssh-key list` | `id` |
147152
| startup-script ID | `startup-script list` | `id` |
148153
| volume ID | `volume list` | `id` |

internal/skills/manifest.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
"display_name": "Claude Code",
1010
"scope": "global",
1111
"target": "~/.claude/skills/",
12-
"method": "copy"
12+
"method": "copy",
13+
"file_map": {
14+
"verda-cloud.md": "verda-cloud/SKILL.md",
15+
"verda-reference.md": "verda-reference/SKILL.md"
16+
}
1317
},
1418
"cursor": {
1519
"display_name": "Cursor",

internal/verda-cli/cmd/skills/install.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,13 @@ func cleanupStaleFiles(dir string, agent *Agent, currentFiles map[string]string,
309309
// names and mapped names like verda-cloud.md → SKILL.md).
310310
oldDest := agent.DestName(old)
311311
if current[oldDest] {
312+
// If file_map was added/changed, the raw source name may still
313+
// exist as a flat file from a previous install without file_map.
314+
// Clean it up (e.g. ~/.claude/skills/verda-cloud.md → now
315+
// installed as ~/.claude/skills/verda-cloud/SKILL.md).
316+
if oldDest != old {
317+
_ = os.Remove(filepath.Join(dir, old)) // best-effort
318+
}
312319
continue // still in current manifest
313320
}
314321
_ = os.Remove(filepath.Join(dir, oldDest)) // best-effort
@@ -317,7 +324,15 @@ func cleanupStaleFiles(dir string, agent *Agent, currentFiles map[string]string,
317324

318325
func installCopy(dir string, agent *Agent, skillFiles map[string]string) error {
319326
for name, content := range skillFiles {
320-
path := filepath.Join(dir, agent.DestName(name))
327+
dest := agent.DestName(name)
328+
path := filepath.Join(dir, dest)
329+
// Create parent subdirectories for path-based file_map entries
330+
// (e.g. "verda-cloud/SKILL.md" needs the "verda-cloud" dir).
331+
if parent := filepath.Dir(path); parent != dir {
332+
if err := os.MkdirAll(parent, 0o750); err != nil {
333+
return fmt.Errorf("creating directory %s: %w", parent, err)
334+
}
335+
}
321336
if err := os.WriteFile(path, []byte(content), 0o644); err != nil { //nolint:gosec // non-sensitive skill files
322337
return fmt.Errorf("writing %s: %w", path, err)
323338
}

internal/verda-cli/cmd/skills/install_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,80 @@ func TestInstallCopy_FileMap(t *testing.T) {
112112
}
113113
}
114114

115+
func TestInstallCopy_SubdirFileMap(t *testing.T) {
116+
t.Parallel()
117+
dir := t.TempDir()
118+
skillFiles := map[string]string{
119+
"verda-cloud.md": "# Verda Cloud\ncontent",
120+
"verda-reference.md": "# Reference\ncontent",
121+
}
122+
agent := &Agent{
123+
Name: "claude-code", DisplayName: "Claude Code",
124+
Scope: "global", Method: "copy", Target: dir,
125+
FileMap: map[string]string{
126+
"verda-cloud.md": "verda-cloud/SKILL.md",
127+
"verda-reference.md": "verda-reference/SKILL.md",
128+
},
129+
}
130+
if err := installForAgent(agent, skillFiles, nil); err != nil {
131+
t.Fatalf("install error: %v", err)
132+
}
133+
// Both should be installed in subdirectories as SKILL.md
134+
for _, sub := range []string{"verda-cloud", "verda-reference"} {
135+
path := filepath.Join(dir, sub, "SKILL.md")
136+
if _, err := os.Stat(path); err != nil {
137+
t.Fatalf("expected %s/SKILL.md to exist", sub)
138+
}
139+
}
140+
// Flat files should NOT exist
141+
for _, flat := range []string{"verda-cloud.md", "verda-reference.md"} {
142+
if _, err := os.Stat(filepath.Join(dir, flat)); !os.IsNotExist(err) {
143+
t.Fatalf("%s should not exist as flat file", flat)
144+
}
145+
}
146+
}
147+
148+
func TestInstallCopy_CleansUpFlatFilesOnFileMapChange(t *testing.T) {
149+
t.Parallel()
150+
dir := t.TempDir()
151+
152+
// Simulate a previous install that wrote flat files (no file_map).
153+
_ = os.WriteFile(filepath.Join(dir, "verda-cloud.md"), []byte("old"), 0o600)
154+
_ = os.WriteFile(filepath.Join(dir, "verda-reference.md"), []byte("old"), 0o600)
155+
156+
// Re-install with file_map that puts files into subdirectories.
157+
newFiles := map[string]string{
158+
"verda-cloud.md": "# Cloud v2",
159+
"verda-reference.md": "# Reference v2",
160+
}
161+
agent := &Agent{
162+
Name: "claude-code", DisplayName: "Claude Code",
163+
Scope: "global", Method: "copy", Target: dir,
164+
FileMap: map[string]string{
165+
"verda-cloud.md": "verda-cloud/SKILL.md",
166+
"verda-reference.md": "verda-reference/SKILL.md",
167+
},
168+
}
169+
previousSkills := []string{"verda-cloud.md", "verda-reference.md"}
170+
171+
if err := installForAgent(agent, newFiles, previousSkills); err != nil {
172+
t.Fatalf("install error: %v", err)
173+
}
174+
175+
// New subdir files should exist.
176+
for _, sub := range []string{"verda-cloud", "verda-reference"} {
177+
if _, err := os.Stat(filepath.Join(dir, sub, "SKILL.md")); err != nil {
178+
t.Fatalf("expected %s/SKILL.md to exist", sub)
179+
}
180+
}
181+
// Old flat files should be cleaned up.
182+
for _, flat := range []string{"verda-cloud.md", "verda-reference.md"} {
183+
if _, err := os.Stat(filepath.Join(dir, flat)); !os.IsNotExist(err) {
184+
t.Fatalf("old flat file %s should have been removed", flat)
185+
}
186+
}
187+
}
188+
115189
func TestInstallCopy_CleansUpStaleFiles(t *testing.T) {
116190
t.Parallel()
117191
dir := t.TempDir()

internal/verda-cli/cmd/skills/uninstall.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,16 @@ func uninstallForAgent(agent *Agent, skillNames []string) error {
219219
func uninstallCopy(agent *Agent, skillNames []string) error {
220220
dir := agent.TargetDir()
221221
for _, name := range skillNames {
222-
path := filepath.Join(dir, agent.DestName(name))
222+
dest := agent.DestName(name)
223+
path := filepath.Join(dir, dest)
223224
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
224225
return fmt.Errorf("removing %s: %w", path, err)
225226
}
227+
// Remove empty parent directory for subdirectory-based installs
228+
// (e.g. verda-cloud/SKILL.md leaves an empty verda-cloud/ dir).
229+
if parent := filepath.Dir(path); parent != dir {
230+
_ = os.Remove(parent) // best-effort; only succeeds if empty
231+
}
226232
}
227233
return nil
228234
}

internal/verda-cli/cmd/skills/uninstall_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,40 @@ func TestUninstallCopy(t *testing.T) {
3333
}
3434
}
3535

36+
func TestUninstallCopy_SubdirCleanup(t *testing.T) {
37+
t.Parallel()
38+
dir := t.TempDir()
39+
// Create subdirectory-based skill files.
40+
for _, sub := range []string{"verda-cloud", "verda-reference"} {
41+
subDir := filepath.Join(dir, sub)
42+
_ = os.MkdirAll(subDir, 0o750)
43+
_ = os.WriteFile(filepath.Join(subDir, "SKILL.md"), []byte("test"), 0o600)
44+
}
45+
agent := &Agent{
46+
Name: "claude-code", Scope: "global", Method: "copy",
47+
Target: dir,
48+
FileMap: map[string]string{
49+
"verda-cloud.md": "verda-cloud/SKILL.md",
50+
"verda-reference.md": "verda-reference/SKILL.md",
51+
},
52+
}
53+
if err := uninstallForAgent(agent, []string{"verda-cloud.md", "verda-reference.md"}); err != nil {
54+
t.Fatalf("uninstall error: %v", err)
55+
}
56+
// SKILL.md files should be removed.
57+
for _, sub := range []string{"verda-cloud", "verda-reference"} {
58+
if _, err := os.Stat(filepath.Join(dir, sub, "SKILL.md")); !os.IsNotExist(err) {
59+
t.Fatalf("expected %s/SKILL.md to be deleted", sub)
60+
}
61+
}
62+
// Empty subdirectories should be removed.
63+
for _, sub := range []string{"verda-cloud", "verda-reference"} {
64+
if _, err := os.Stat(filepath.Join(dir, sub)); !os.IsNotExist(err) {
65+
t.Fatalf("expected empty dir %s to be removed", sub)
66+
}
67+
}
68+
}
69+
3670
func TestUninstallAppend(t *testing.T) {
3771
t.Parallel()
3872
dir := t.TempDir()

internal/verda-cli/cmd/vm/create.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ func missingCreateFlags(opts *createOptions) []string {
268268
}
269269

270270
func runWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *createOptions) error {
271-
flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeDeploy, ioStreams.ErrOut)
271+
flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeDeploy)
272272
engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation())
273273
return engine.Run(ctx, flow)
274274
}

0 commit comments

Comments
 (0)