diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ac4e25..026978d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,30 +141,11 @@ jobs: - name: Run govulncheck run: govulncheck ./... - changelog-guard: - name: Changelog Guard - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - steps: - - name: Check for CHANGELOG.md changes - env: - GH_TOKEN: ${{ github.token }} - run: | - CHANGED_FILES=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files --jq '.[].filename') - if echo "$CHANGED_FILES" | grep -q '^CHANGELOG.md$'; then - echo "::error::CHANGELOG.md should not be modified manually. It is auto-generated by git-cliff at release time." - echo "" - echo "Please revert your changes to CHANGELOG.md." - echo "The changelog is generated automatically from conventional commit messages (feat:, fix:, chore:, etc.)." - exit 1 - fi - echo "CHANGELOG.md not modified - all good." - ci-success: name: All CI Checks Passed runs-on: ubuntu-latest if: always() - needs: [lint, test, build, format, mod-tidy, vuln-scan, changelog-guard] + needs: [lint, test, build, format, mod-tidy, vuln-scan] steps: - name: Check results run: | @@ -174,8 +155,4 @@ jobs: exit 1 fi done - if [ "${{ needs.changelog-guard.result }}" != "success" ] && [ "${{ needs.changelog-guard.result }}" != "skipped" ]; then - echo "Changelog guard failed" - exit 1 - fi echo "All CI checks passed successfully!" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e91d60a..c0f9d1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,18 +52,14 @@ jobs: # Release notes for this version only (passed to goreleaser) git-cliff --tag ${{ github.event.inputs.version }} --latest --strip header -o RELEASE_NOTES.md - - name: Commit, tag, and push release branch + - name: Tag and push release run: | VERSION="${{ github.event.inputs.version }}" - BRANCH="release/${VERSION}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git remote set-url origin https://x-access-token:${{ secrets.RELEASE_TOKEN }}@github.com/${{ github.repository }} - git checkout -b "${BRANCH}" - git add -A - git commit -m "chore(release): ${VERSION}" git tag "${VERSION}" - git push origin "${BRANCH}" --tags + git push origin --tags - name: Install cosign uses: sigstore/cosign-installer@v3 @@ -114,7 +110,7 @@ jobs: - name: Sign checksum files with cosign # Keyless signing via GitHub OIDC. The certificate identity will be: - # https://github.com/verda-cloud/verda-cli/.github/workflows/release.yml@refs/heads/release/ + # https://github.com/verda-cloud/verda-cli/.github/workflows/release.yml@refs/heads/main # # To verify manually: # cosign verify-blob \ @@ -153,13 +149,18 @@ jobs: "dist/verda_${VER}_SHA256SUMS.sig" \ "dist/verda_${VER}_SHA256SUMS.pem" - - name: Create PR to merge release into main + - name: Commit changelog and create PR env: GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | VERSION="${{ github.event.inputs.version }}" + BRANCH="chore/changelog-${VERSION}" + git checkout -b "${BRANCH}" + git add CHANGELOG.md + git commit -m "chore(release): update changelog for ${VERSION}" + git push origin "${BRANCH}" gh pr create \ --base main \ - --head "release/${VERSION}" \ - --title "chore(release): ${VERSION}" \ - --body "Automated release PR for ${VERSION}. Merges changelog and version updates into main." + --head "${BRANCH}" \ + --title "chore(release): update changelog for ${VERSION}" \ + --body "Updates CHANGELOG.md with release notes for ${VERSION}." diff --git a/.gitignore b/.gitignore index 8ba94ad..941faf5 100644 --- a/.gitignore +++ b/.gitignore @@ -379,3 +379,6 @@ scripts/live-test.sh # AI working notes and implementation plans (not shipped) .ai/notes/ docs/plans/ + +# AI agent project-level configs (installed by users, not shipped) +.cursor/ diff --git a/AGENTS.md b/AGENTS.md index 8a7e7c7..d327585 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,62 +1,48 @@ -# AI Agent Guidelines for Verda CLI +# AI Agent Contract -This document provides instructions for AI coding agents (Claude Code, Codex, etc.) working on this codebase. +Read `CLAUDE.md` first. This file defines how you execute, not what the project is. -## Before You Start +## Mandatory Read-First, Plan-First Workflow -1. Read `CLAUDE.md` for project overview, build commands, and architecture -2. Read the `CLAUDE.md` in the specific subcommand directory you're working on (e.g., `cmd/vm/CLAUDE.md`) -3. Read `.ai/skills/new-command.md` when creating or modifying CLI commands -4. Run `go build ./...` and `go test ./...` to verify your changes compile and pass +Do NOT write code until you have completed all steps below. No exceptions. -## Skills +**Step 1 — Read** (always, every task): +1. `CLAUDE.md` (root) — architecture, conventions, pricing rules +2. `CLAUDE.md` in the target command directory (e.g. `cmd/vm/CLAUDE.md`) — domain gotchas +3. `README.md` in the target command directory — usage, flags, examples +4. `.ai/skills/new-command.md` if adding or modifying a command -Skills are structured guides in `.ai/skills/` that document patterns and checklists: +**Step 2 — Verify** (always): +5. Run `make test` to confirm the repo is green before you start -| Skill | When to Use | -|-------|-------------| -| [new-command.md](.ai/skills/new-command.md) | Adding a new subcommand or modifying an existing one | -| [update-command-knowledge.md](.ai/skills/update-command-knowledge.md) | Auto-updating per-command README.md and CLAUDE.md docs | +**Step 3 — Plan** (required for non-trivial changes): +6. State what you will change and why before writing code +7. For risky areas (see table below): write a plan, get approval, then code +8. If superpowers skills are available: use `brainstorming` before creative work, `writing-plans` before multi-step tasks, `test-driven-development` before implementation -## Key Rules +Skipping these steps leads to pattern violations, broken dual-mode, and pricing bugs. -### Always +## Execution Rules -- Support `--debug` flag on every command that calls the API (use `cmdutil.DebugJSON`) -- Wrap API calls with spinner + timeout context -- Support both interactive (prompts) and non-interactive (flags) modes -- Add confirmation for destructive actions -- Register new commands in their parent command file and `cmd/cmd.go` if new domain -- Run `go build ./...` and `go test ./...` before finishing +- **Follow existing patterns** — find the nearest similar command, match its structure +- **Preserve dual mode** — every command must work interactive AND non-interactive. Never build one without the other +- **Never modify `verdagostack`** directly — describe needed changes for the maintainer +- **Commit only when asked** — don't auto-commit -### Never +## Risky Areas — Slow Down -- Skip the `--debug` output pattern -- Make API calls without a timeout context -- Write to `ioStreams.Out` for non-data output (use `ioStreams.ErrOut` for prompts/warnings/debug) -- Forget to handle user cancellation (Esc/Ctrl+C returns nil error) -- Use lipgloss v1 imports (`github.com/charmbracelet/lipgloss`) -- use `charm.land/lipgloss/v2` -- Use bubbletea v1 imports -- use `charm.land/bubbletea/v2` +| Area | Risk | What To Do | +|------|------|------------| +| `cmd/util/pricing.go` | Wrong math = wrong bills | Verify formula, test with real numbers | +| `options/credentials.go` | Break auth = break everything | Test all profiles, expired tokens | +| Agent mode (`--agent`) | JSON contract change = break downstream | Check structured error format | +| Wizard steps | Step ordering, cache invalidation | Map dependencies before coding | +| `verdagostack` types | Shared across repos | Don't modify, describe changes needed | -### Dependencies +## Done Checklist -When modifying `verdagostack` (local replace): -- Bubble Tea v2 changed `tea.KeyMsg` to `tea.KeyPressMsg` -- `KeyPressMsg.String()` returns `"space"` not `" "` for space key -- `KeyPressMsg.String()` returns `"enter"`, `"esc"`, `"backspace"`, `"tab"` for special keys -- Wizard API uses `ViewDef` (not `RegionDef`), `NewProgressView` (not `NewProgressRegion`) - -## Project Layout - -``` -cmd/verda/main.go # Entrypoint -internal/verda-cli/ - cmd/cmd.go # Root command, command groups - cmd/util/ # Factory, IOStreams, helpers, hostname - cmd/vm/ # VM: create, list, action, wizard, status_view - cmd/auth/ # Auth: login, show, use, wizard - cmd/sshkey/ # SSH keys: list, add, delete - cmd/startupscript/ # Startup scripts: list, add, delete - cmd/volume/ # Volumes: list, create, action, trash - options/ # Global options, credentials -``` +- [ ] `make build` passes +- [ ] `make test` passes +- [ ] `--help` renders correctly for changed commands +- [ ] Interactive and non-interactive modes both work +- [ ] No leftover debug code, TODOs, or commented-out blocks diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..895947f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,146 @@ +# Changelog + + +## [Unreleased] +- [`d6fe448`] Feat/vm batch operation support +- [`79cf7b7`] Feat/verda status dashboard +- [`d2e2338`] Require --all for filter flags (--status, --hostname) in batch operations +- [`72ccba7`] Update license +- [`be03cc3`] Feat/verda template +- [`dfa9753`] Refactor: architecture hardening + template edit command +- [`d3fd513`] Add skills fetcher for downloading manifest and skill files from GitHub +- [`f20ef84`] Add skills state management for tracking installed skills +- [`23b671b`] Add skills parent command and wire into root with credential skip +- [`a8051ea`] Add skills install subcommand with full fetch, select, and install flow +- [`db96b93`] Add skills uninstall subcommand with interactive and non-interactive modes +- [`f3b5345`] Refactor(skills): move agent definitions from hardcoded registry to manifest +- [`c7c27dc`] Feat(skills): add --force flag to install subcommand +- [`5049480`] Add agent mode skip wait and response immediately +- [`e5a3576`] Refactor license +- [`72b1457`] Feat(skills): add embedded skills package with manifest and skill files +- [`ce8662c`] Refactor(skills): replace HTTP fetching with embedded skills package +- [`a041a93`] Docs(skills): update descriptions to reflect embedded skills +- [`17272a8`] Feat(skills): enrich help text and auto-update skills on CLI update +- [`812e252`] Docs(skills): add template command reference to embedded skills +- [`5d3537c`] Fix(skills): handle renamed skill files and track installed filenames +- [`6b5f1bc`] Feat(skills): add file_map for per-agent file renaming, fix Codex install +- [`491255c`] Fix(skills): update Gemini CLI to directory-based SKILL.md install +- [`8db8123`] Chore: add .cursor/ to gitignore, add presenting results to skill +- [`a4d021d`] Docs: rewrite CLAUDE.md and AGENTS.md for Go CLI specificity +- [`315d574`] Docs(agents): enforce strict read-first, plan-first workflow +- [`e1efa04`] Docs(agents): reference superpowers skills in plan step + +## [v1.4.2] - 2026-04-08 +- [`0095dee`] Fix: correct homebrew and scoop package name in README +- [`6852d07`] Add auth resolve order explain +- [`2fbf4a0`] Fix/refactor version sub command to --version/-v pararms +- [`e4a5df9`] Image list show image type remove id +- [`2f2d9b7`] Ssh add pipe support +- [`c5f9cdd`] Add more test for auth command + +## [v1.4.1] - 2026-04-07 +- [`d0a3b8e`] Fix: update binary migration to ~/.verda/bin +- [`bd28c3e`] Fix/vm create related bugs +- [`5e19808`] Fix: use official gitleaks-action to fix 404 on version fetch + +## [v1.4.0] - 2026-04-07 +- [`a5c57bc`] Feat/agent mode and mcp +- [`15ee7ff`] Fix/update command issue + +## [v1.3.2] - 2026-04-05 +- [`cfee0cb`] Fix: resolve verify checksum matching and changelog generation issues + +## [v1.3.1] - 2026-04-05 +- [`2b0f0c0`] Fix/verify version prefix + +## [v1.3.0] - 2026-04-05 +- [`a533db7`] Fix/update permission +- [`e2960a7`] Feat/add deatils version command + +## [v1.2.0] - 2026-04-03 +- [`c0b869a`] Feat: add auto-maintained per-subcommand knowledge docs +- [`4c2d084`] Feat/cli improvements + +## [v1.1.1] - 2026-04-02 +- [`7808962`] Docs: add VHS demo gif and skip CI for doc-only changes +- [`f6217c8`] Docs: reformat README tables and restore demo gif embed +- [`d10e094`] Feat: upgrade verdagostack to v1.1.2, add theme-aware hints and interactive theme selector + +## [v1.1.0] - 2026-04-02 +- [`d8dbeb4`] Feat: add self-update command, update README with install/update docs +- [`3d3b2c3`] Fix(ci): add gitleaks config to allowlist VHS demo tape files +- [`4dfadfa`] Fix: remove vhs/ from repo, add to gitignore + +## [v1.0.0] - 2026-04-02 +- [`3ee032a`] Feat(vm): add interactive wizard flow for vm create +- [`99b1ccd`] Feat(auth): add interactive wizard flow for auth configure +- [`87b337c`] Fix(vm): lazily resolve API client in wizard flow +- [`6a01f97`] Fix(auth): improve error message when credentials are missing +- [`91a9855`] Feat(auth): replace auth configure with auth login, add base_url +- [`7bc108d`] Refactor: rename --server flag to --base-url +- [`f36583c`] Refactor(auth): add verda_ prefix to credential file keys +- [`0e928c1`] Fix(vm): check credentials before starting wizard +- [`777256d`] Fix: hide auth and log flags from help output +- [`9cb8382`] Fix: remove global flags section from subcommand help +- [`3f6f78b`] Fix(auth): show base_url in auth show output +- [`7931cff`] Feat(vm): pre-filter instance types by availability, show locations +- [`516e301`] Refactor: adapt to verdagostack wizard engine v2 +- [`9090584`] Feat(vm): add spinners to API-loading steps and final submit +- [`5ed5b3a`] Feat: use percentage progress bar in wizard flows +- [`549324e`] Feat(vm): replace JSON output with live instance status view +- [`6d6895b`] Feat(vm): add animated spinner and elapsed timer to status polling +- [`7ee0a01`] Feat(vm): Claude-style animated status line while polling +- [`e774c89`] Feat(vm): add inline SSH key creation sub-flow in wizard +- [`2e657b2`] Feat(vm): add inline startup script creation sub-flow in wizard +- [`5f39096`] Refactor(vm): inline "Add new" option in SSH keys and startup script lists +- [`0ed66e7`] Feat(vm): add "load from file" option for startup script creation +- [`f4b4a16`] Fix(vm): clarify startup script "None" label to "None (skip)" +- [`7c59c57`] Feat(vm): replace simple storage step with full volume sub-flow +- [`5d3d484`] Fix(vm): add back option and navigation hints to storage sub-flows +- [`e78458c`] Fix(vm): handle API errors gracefully in SSH key and startup script creation +- [`32b44fb`] Fix(vm): never crash wizard on sub-flow errors +- [`a1ae326`] Fix(vm): fix SSH keys/storage/scripts lost after Loader, add --debug flag +- [`2511532`] Feat(vm): add vm list command with interactive detail view +- [`da4eb7a`] Fix(vm): simplify vm list to single interactive select +- [`3144970`] Feat(vm): add vm delete command with interactive selection +- [`e6b5db0`] Feat(vm): show storage volumes in instance detail card +- [`a2332ac`] Fix(vm): deduplicate OS volume in storage list, add volume IDs +- [`21a8097`] Fix(vm): render storage as table with header row in detail card +- [`e7d96a0`] Fix(vm): show storage volumes as key-value list matching instance style +- [`f2929e5`] Feat: make --debug a global flag available to all subcommands +- [`eda167a`] Feat(vm): replace vm delete with vm action supporting all operations +- [`da49a2a`] Fix(vm): remove action aliases to keep CLI surface simple +- [`360b246`] Feat(vm): filter actions by instance status, add confirmation messages +- [`513b09d`] Feat(vm): poll instance status after action until expected state +- [`8ca148c`] Fix(vm): poll until expected status after action, not just any terminal +- [`0ff708d`] Fix(vm): add 5-minute timeout to status polling +- [`53705d7`] Feat(vm): add volume selection sub-flow to delete action +- [`0d6425f`] Fix(vm): add red bold warnings to shutdown and force shutdown actions +- [`7eb2e19`] Feat(vm): add deployment summary with cost breakdown before submit +- [`4dde90c`] Fix(vm): fix pricing in deployment summary and instance type list +- [`ab2c493`] Fix(vm): show price per hour in instance type selection list +- [`d1821e1`] Fix(vm): multiply unit price by GPU/CPU count for total hourly price +- [`539c9be`] Fix(vm): always show price for OS and storage volumes in summary +- [`c0d560c`] Feat(vm): show unit price alongside total for volumes in summary +- [`7fde23d`] Feat(vm): show volume prices in type selection and storage step +- [`9d55c6a`] Feat(vm): show unit price breakdown for multi-GPU/CPU instances +- [`26d3b11`] Fix(vm): API price_per_hour is total, not per-GPU — remove double multiply +- [`719936e`] Feat: add ssh-key, startup-script, and volume resource commands +- [`6471851`] Fix: remove key and script aliases for ssh-key and startup-script +- [`f2e4977`] Feat(volume): replace delete with action command supporting all operations +- [`add7f40`] Feat(volume): add volume create command +- [`27b5c64`] Feat(volume): add pricing summary with confirm before creating volume +- [`1f447d3`] Fix(volume): collect user input before spinner in rename/resize/clone +- [`cbd7c06`] Feat(volume): add volume trash command to list deleted volumes +- [`c07e06d`] Fix(volume): improve trash display with type, contract, and pricing +- [`929f248`] Feat(volume): upgrade SDK to v1.4.1, use proper trash API +- [`1d2f7d0`] Feat: upgrade to bubbletea v2, add settings/theme, CI/CD, cross-platform release +- [`8a54571`] Feat: add install script, update README, preload editor template +- [`d992cfd`] Fix: resolve gosec, errcheck, staticcheck, and trivy CI failures +- [`bd4d5fd`] Fix(ci): pin trivy-action to v0.32.0 to avoid Node.js 20 deprecation warning +- [`0517ce9`] Fix(ci): suppress gosec G304 in test file +- [`c7c1955`] Fix(ci): use trivy-action v0.31.0 (v0.32.0 does not exist) +- [`c14b115`] Fix(ci): revert trivy-action to @master (tagged versions not available) +- [`e8ea655`] Fix: resolve all golangci-lint issues and formatting +- [`9eccacf`] Fix: add -short flag to pre-commit unit tests to avoid slow API timeout tests +- [`d4c0b43`] Fix: resolve remaining lint and gosec issues diff --git a/CLAUDE.md b/CLAUDE.md index 8340570..2ab9844 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,62 +1,106 @@ # Verda CLI -Command-line interface for Verda Cloud, built with Go + Cobra + Bubble Tea TUI. +Go CLI for Verda Cloud. Cobra commands + Bubble Tea TUI + lipgloss styling. -## Build & Test +## Build & Validate ```bash -go build -o ./bin/verda ./cmd/verda/ # Build binary -go test ./... # Run all tests -go mod tidy # Sync dependencies +make build # Build binary to ./bin/verda +make test # Run all tests (go test + golangci-lint) +make lint # Lint only +make pre-commit # Full pre-commit suite ``` +Never use raw `go test ./...` — always `make test` which includes linting. + ## Architecture -- `cmd/verda/` -- Entrypoint -- `internal/verda-cli/cmd/` -- Command implementations organized by domain (vm, volume, auth, sshkey, startupscript) -- `internal/verda-cli/cmd/util/` -- Shared: Factory, IOStreams, helpers, templates, hostname utils -- `internal/verda-cli/options/` -- Global CLI options, credentials resolution +``` +cmd/verda/ # Entrypoint +internal/verda-cli/ + cmd/cmd.go # Root command, command groups + cmd/util/ # Factory, IOStreams, helpers, pricing, hostname + cmd// # One dir per domain (vm, volume, auth, skills, ...) + options/ # Global CLI options, credentials +internal/skills/ # Embedded AI skill files (go:embed) +``` -### Key Patterns +### Core Patterns -- **Factory pattern** (`cmd/util/factory.go`): Dependency injection for Prompter, Status, VerdaClient, Debug -- **Wizard engine** (`verdagostack/pkg/tui/wizard`): Multi-step interactive flows (vm create, auth login) -- **Lazy client** (`clientFunc` type): API client resolved on first use, not at wizard start -- **API cache** (`apiCache`): Shared across wizard steps to avoid redundant API calls +- **Factory** (`cmd/util/factory.go`): DI for Prompter, Status, VerdaClient, Debug, AgentMode, OutputFormat +- **Wizard engine** (`verdagostack/pkg/tui/wizard`): Multi-step interactive flows +- **Lazy client** (`clientFunc`): API client resolved on first use, not at init +- **API cache** (`apiCache`): Shared across wizard steps to avoid redundant calls -### Dependencies (local dev) +### Local Dependencies -- `verdagostack` is replaced locally via `go.mod`: `replace github.com/verda-cloud/verdagostack => ../verdagostack` -- Uses Bubble Tea v2 (`charm.land/bubbletea/v2`) and lipgloss v2 (`charm.land/lipgloss/v2`) +- `verdagostack` replaced locally: `replace github.com/verda-cloud/verdagostack => ../verdagostack` +- Bubble Tea v2 (`charm.land/bubbletea/v2`), lipgloss v2 (`charm.land/lipgloss/v2`) +- Never use v1 imports — they won't compile ## Conventions -- Every API-calling command must support `--debug` (global flag) -- see `.ai/skills/new-command.md` -- Every API call must use a timeout context and show a spinner -- Commands support both interactive (prompts) and non-interactive (flags) usage -- Output to `ioStreams.Out`, prompts/warnings/debug to `ioStreams.ErrOut` -- Destructive actions require confirmation with warning styling -- Follow the checklist in `.ai/skills/new-command.md` when adding new commands - -## Pricing Model +### Every API-calling command MUST: -- Instance `price_per_hour` from API is **per-unit** (per-GPU or per-vCPU). Total = `price_per_hour * units`. -- Use `cmdutil.InstanceTotalHourlyCost(inst)` or `cmdutil.InstanceBillableUnits(inst)` from `cmd/util/pricing.go`. -- Volume pricing is `price_per_month_per_gb`. Hourly = `ceil(monthly_per_gb * size / 730 * 10000) / 10000` +1. **Timeout context**: `ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout)` +2. **Spinner**: Show spinner during API calls, stop before handling result +3. **Debug output**: `cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "label:", data)` +4. **Dual mode**: Work with flags (non-interactive) AND prompts (interactive) — no partial wizard +5. **Output separation**: Data → `ioStreams.Out`, prompts/warnings/debug → `ioStreams.ErrOut` -## Per-Command Knowledge +### Destructive actions MUST: -Each subcommand directory has its own docs: -- `README.md` — Human-readable: usage, examples, flags, architecture -- `CLAUDE.md` — AI context: gotchas, domain logic, relationships +- Show warning styling (red bold) before confirmation +- Require `prompter.Confirm()` — return nil on cancel or Esc +- In agent mode (`f.AgentMode()`): require `--yes` flag, never auto-confirm -These are auto-maintained by a pre-commit hook. See `.ai/skills/update-command-knowledge.md`. +### Pricing — get this wrong and users get billed wrong: -When modifying a command, the hook will auto-update its docs on commit. -For manual update: `claude -p "/update-command-knowledge --all" --model sonnet --dangerously-skip-permissions` +- Instance `price_per_hour` from API is **per-unit** (per-GPU or per-vCPU) +- Total = `price_per_hour * units` — use `cmdutil.InstanceTotalHourlyCost(inst)` +- Volume: `price_per_month_per_gb` — hourly = `ceil(monthly * size / 730 * 10000) / 10000` +- Never display raw API price as "total" without multiplying -## Credentials +### Credentials -- AWS-style INI file at `~/.verda/credentials` with `verda_` prefixed keys -- Keys: `verda_base_url`, `verda_client_id`, `verda_client_secret`, `verda_token` +- AWS-style INI at `~/.verda/credentials` with `verda_` prefixed keys - Profile support via `[profile_name]` sections +- `f.VerdaClient()` handles resolution — returns clear error if not authenticated + +## Before Editing Any Command + +1. Read the **nearest** `CLAUDE.md` in the command directory (e.g. `cmd/vm/CLAUDE.md`) +2. Read the **nearest** `README.md` for usage examples and flag details +3. Read `.ai/skills/new-command.md` for the full checklist when adding/modifying commands +4. If touching pricing, auth, or agent-mode: plan first, don't code immediately + +Per-command docs are auto-maintained by a pre-commit hook. +Manual update: `claude -p "/update-command-knowledge --all" --model sonnet --dangerously-skip-permissions` + +## Thinking Depth + +| Change Type | Approach | +|-------------|----------| +| Rename, typo, flag default | Just do it | +| New list/describe command | Follow `.ai/skills/new-command.md` checklist | +| New create/wizard flow | Plan first — wizard steps, cache strategy, step dependencies | +| Refactor shared util | Check all callers, run full test suite | +| Pricing logic | Deep think — verify formula against API docs, test with real numbers | +| Auth flow changes | Deep think — test all profiles, expired tokens, missing creds | +| Agent-mode (`--agent`) changes | Deep think — JSON output contract, structured errors, no prompts | + +## Validation + +Before considering any change complete: + +```bash +make build # Must compile +make test # Must pass (tests + lint) +``` + +If you modified a command, also verify: +- `./bin/verda --help` renders correctly +- Interactive mode works (prompts appear) +- Non-interactive mode works (flags only, no prompts) +- `--agent -o json` mode works (structured output, no TUI) +- `--debug` shows request/response payloads diff --git a/LICENSE b/LICENSE index d8b46db..222b91f 100644 --- a/LICENSE +++ b/LICENSE @@ -2,6 +2,181 @@ Version 2.0, January 2004 http://www.apache.org/licenses/ + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Verda Cloud Oy + Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at @@ -13,5 +188,3 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - - Copyright 2026 Verda Cloud Oy diff --git a/cliff.toml b/cliff.toml index da58cd1..224a3ef 100644 --- a/cliff.toml +++ b/cliff.toml @@ -3,11 +3,7 @@ [changelog] header = """ -# Changelog - -All notable changes to this project will be documented in this file. - -This changelog is automatically generated by [git-cliff](https://git-cliff.org/). +# Changelog\n """ body = """ {%- if version %} @@ -15,41 +11,25 @@ body = """ {%- else %} ## [Unreleased] {%- endif %} -{% for group, commits in commits | group_by(attribute="group") %} -### {{ group | striptags | trim | upper_first }} {% for commit in commits -%} -- ({{ commit.id | truncate(length=7, end="") }}) {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | upper_first }} - {{ commit.author.name }} <{{ commit.author.email }}> +- [`{{ commit.id | truncate(length=7, end="") }}`] {{ commit.message | upper_first }} {% endfor %} -{%- endfor %} -""" -footer = """ - """ trim = true [git] -conventional_commits = true +conventional_commits = false filter_unconventional = false -require_conventional = false split_commits = false commit_preprocessors = [ + # Strip everything after the first line { pattern = "\n.*", replace = "" }, + # Remove trailing PR numbers like (#123) { pattern = "\\s*\\(#\\d+\\)", replace = "" }, ] -protect_breaking_commits = false filter_commits = false topo_order = false sort_commits = "oldest" commit_parsers = [ - { message = "^feat", group = "Added" }, - { message = "^fix", group = "Fixed" }, - { message = "^refactor", group = "Changed" }, - { message = "^perf", group = "Performance" }, - { message = "^doc", group = "Documentation" }, - { message = "^test", group = "Testing" }, - { message = "^ci", group = "CI" }, { message = "^chore\\(release\\)", skip = true }, - { message = "^chore", group = "Other" }, - { message = "^revert", group = "Reverted" }, - { message = ".*", group = "Other" }, ] diff --git a/docs/ai-skills.md b/docs/ai-skills.md new file mode 100644 index 0000000..33a7512 --- /dev/null +++ b/docs/ai-skills.md @@ -0,0 +1,145 @@ +# AI Skills for Verda Cloud + +Verda CLI includes AI skills — markdown files that teach AI coding agents how to manage your cloud infrastructure through natural language. Install the skills and your agent gains structured knowledge of Verda Cloud workflows without you explaining them each time. + +## Install + +```bash +verda skills install +``` + +This auto-detects your AI agent (Claude Code, Cursor, etc.) and installs skills to the right location. + +To reinstall or update after a CLI upgrade: + +```bash +verda skills install --force +``` + +## What's Included + +| Skill | Purpose | +|-------|---------| +| **verda-cloud** | Decision engine — teaches agents HOW to reason about tasks: classify requests, follow the deploy dependency chain, handle errors, stay safe | +| **verda-reference** | Command reference — teaches agents WHAT to run: all commands, flags, parameter sources, output fields | + +## Example Prompts + +### Overview — what's going on + +``` +Show me my Verda Cloud status +Give me an overview of my Verda Cloud resources +What's my Verda Cloud dashboard look like? +``` + +### Explore — check what's available + +``` +What GPU instances are available in Verda Cloud right now? +Show me the cheapest Verda Cloud GPU with at least 80GB VRAM +What CPU options does Verda Cloud have? +How much am I spending on my Verda Cloud VMs? +What's my Verda Cloud account balance? +``` + +### Deploy — create a VM + +``` +Deploy a Verda Cloud GPU VM for training with at least 80GB VRAM +I need a cheap spot GPU on Verda Cloud for testing +Spin up a Verda Cloud CPU instance for a small web server +Deploy a Verda Cloud A100 in FIN-01 with my SSH key +Create a Verda Cloud VM from my gpu-training template +``` + +### Deploy with more context + +``` +I'm fine-tuning a 13B model — what Verda Cloud GPU do I need and can you set it up? +I need a Verda Cloud VM with 200GB storage for a large dataset, NVMe preferred +Deploy a spot H100 on Verda Cloud for Jupyter notebooks +``` + +### Templates — save and reuse configurations + +``` +Show me my Verda Cloud templates +Deploy a Verda Cloud VM from my gpu-training template +What Verda Cloud templates do I have? +``` + +To create or edit templates interactively, run these in your terminal: + +```bash +verda template create # Save a new template via wizard +verda template edit my-tmpl # Edit an existing template +``` + +### Manage — control existing VMs + +``` +List my running Verda Cloud VMs +Shut down my Verda Cloud training VM +Start my Verda Cloud gpu-runner instance back up +Hibernate my Verda Cloud dev box +Delete the Verda Cloud instance I'm not using anymore +``` + +### Cost management + +``` +How much am I spending per hour on Verda Cloud right now? +What would a Verda Cloud H100 cost me per hour? +Show me my Verda Cloud balance and how long it will last +Which of my Verda Cloud VMs is the most expensive? +``` + +### SSH keys and startup scripts + +``` +Show me my Verda Cloud SSH keys +List my Verda Cloud startup scripts +Which Verda Cloud SSH key is named "meng"? +``` + +### Volumes and storage + +``` +List my Verda Cloud volumes +Show me detached Verda Cloud volumes I could reuse +``` + +### Status checks + +``` +What's the status of my Verda Cloud training VM? +Is my Verda Cloud instance running? +Show me details of my Verda Cloud gpu-runner +``` + +Note: The agent can check VM status and configuration, but cannot access system logs or cloud-side diagnostics. For deeper troubleshooting, check the [Verda Cloud dashboard](https://verda.com) or contact support. + +## How It Works + +When you ask your AI agent something related to Verda Cloud, the agent automatically loads the skills and follows the workflows defined in them. The skills teach the agent to: + +1. **Use the right commands** — maps natural language ("my keys", "GPU types") to the correct CLI syntax (`ssh-key list`, `instance-types --gpu`) +2. **Follow the dependency chain** — when deploying, checks billing → compute → instance type → availability → images → SSH keys → cost → confirm → create +3. **Stay safe** — always checks cost before creating, confirms before deleting, never guesses image slugs +4. **Handle errors** — parses structured `--agent` mode errors and recovers automatically +5. **Be efficient** — runs independent commands in parallel, caches results, skips unnecessary steps + +## Supported Agents + +| Agent | Skill location after install | +|-------|------------------------------| +| Claude Code | `~/.claude/skills/` | +| Cursor | `.cursor/rules/` (project-level) | + +## Tips + +- **Be specific when you can** — "Deploy 1A100.22V in FIN-03" is faster than "I need a GPU" because the agent skips discovery steps +- **Mention your template** — "Deploy from my gpu-training template" skips the entire configuration flow +- **Ask about costs first** — "How much would an H100 cost?" before "Deploy an H100" saves surprises +- **Use natural language** — say "my keys" not `ssh-key list`, the agent translates for you diff --git a/internal/skills/embed.go b/internal/skills/embed.go new file mode 100644 index 0000000..2bdbc44 --- /dev/null +++ b/internal/skills/embed.go @@ -0,0 +1,24 @@ +package skills + +import ( + "embed" + "io/fs" +) + +//go:embed manifest.json +var manifestData []byte + +//go:embed files/* +var skillFiles embed.FS + +// ManifestData returns the raw embedded manifest JSON. +func ManifestData() []byte { return manifestData } + +// ReadSkillFile reads a single skill file from the embedded filesystem. +func ReadSkillFile(name string) (string, error) { + data, err := fs.ReadFile(skillFiles, "files/"+name) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/internal/skills/embed_test.go b/internal/skills/embed_test.go new file mode 100644 index 0000000..3f30ddc --- /dev/null +++ b/internal/skills/embed_test.go @@ -0,0 +1,30 @@ +package skills + +import "testing" + +func TestManifestData(t *testing.T) { + t.Parallel() + data := ManifestData() + if len(data) == 0 { + t.Fatal("expected non-empty manifest data") + } +} + +func TestReadSkillFile(t *testing.T) { + t.Parallel() + content, err := ReadSkillFile("verda-cloud.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if content == "" { + t.Fatal("expected non-empty content") + } +} + +func TestReadSkillFile_NotFound(t *testing.T) { + t.Parallel() + _, err := ReadSkillFile("nonexistent.md") + if err == nil { + t.Fatal("expected error for missing file") + } +} diff --git a/internal/skills/files/verda-cloud.md b/internal/skills/files/verda-cloud.md new file mode 100644 index 0000000..bc1c317 --- /dev/null +++ b/internal/skills/files/verda-cloud.md @@ -0,0 +1,93 @@ +--- +name: verda-cloud +description: Use when the user mentions Verda Cloud, GPU/CPU VMs, cloud instances, deploying servers, ML training infrastructure, cloud costs/billing, SSH into remote machines, or verda CLI commands. +--- + +# Verda Cloud + +## MANDATORY — Read Before Every Command + +**Every `verda` command MUST include these flags:** +- `--agent` — non-interactive mode, returns structured JSON errors +- `-o json` — structured output (NEVER scrape human-readable tables) + +**Example:** `verda --agent instance-types --gpu -o json` + +**NEVER do these:** +- NEVER run `verda` without `--agent -o json` (except `verda ssh` which is interactive) +- NEVER guess commands — consult the verda-reference skill or run `verda --help` +- NEVER create resources without checking cost first +- NEVER delete/shutdown without explicit user confirmation +- NEVER hardcode instance types, locations, or image slugs — always discover them +- NEVER handle, ask for, or display credentials — auth is user-only via `verda auth login` + +## Prerequisites + +1. `which verda` — if missing: `brew install verda-cloud/tap/verda-cli` +2. `verda --agent auth show` — if exit code non-zero: tell user to run `verda auth login` (does not support -o json, do NOT display output) + +## Classify the Request + +| Type | Signal | Action | +|------|--------|--------| +| **Explore** | "what's available", "show me", "how much" | Discovery only. Do NOT create anything | +| **Deploy** | "create", "deploy", "spin up", "launch" | Deploy workflow below | +| **Manage** | "start", "stop", "delete", "SSH" | Find VM first, then act | +| **Status** | "overview", "status", "what's wrong" | `verda --agent status -o json` for overview; `vm describe` for specific VM | + +### Explore + +- Available instances: `verda --agent instance-types [--gpu|--cpu] -o json` → present name, GPU, VRAM, RAM, price_per_hour sorted by price. **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.** + +## Deploy Workflow + +**Template shortcut:** `verda --agent template list -o json` — if user has templates, deploy with `verda --agent vm create --from --hostname --wait --wait-timeout 2m -o json` (skips steps 1-6). + +Otherwise walk this chain. **ALWAYS** steps must run even if user specified values. + +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 +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" +9. **Create:** + ```bash + verda --agent vm create \ + --kind --instance-type --location \ + --os --hostname --ssh-key \ + [--is-spot] [--os-volume-size 50] --wait --wait-timeout 2m -o json + ``` +10. **Verify** — `verda --agent vm describe -o json`. Tell user: `verda ssh ` (do NOT run it) + +## Error Recovery + +| Error Code | Action | +|------------|--------| +| `AUTH_ERROR` | Tell user: `verda auth login` | +| `INSUFFICIENT_BALANCE` | Show balance, suggest spot or smaller instance | +| `NOT_FOUND` | Re-fetch resource list, verify ID | +| `MISSING_REQUIRED_FLAGS` | Read `details.missing`, provide values, retry | +| `CONFIRMATION_REQUIRED` | Confirm with user, retry with `--yes` | +| `VALIDATION_ERROR` | Read `details.field` + `details.reason`, fix and retry | + +## Presenting Results + +Pick the format that fits the data: +- **Multiple items to compare** (instance types, pricing) → markdown table, keep columns minimal (4-6 max) +- **Single item** (one VM, one template) → short summary paragraph or key-value list +- **Dashboard / overview** → summary paragraph with key numbers highlighted +- **Never** dump raw JSON to the user + +## Asking Good Questions + +When request is vague ("I need a GPU"): +1. **Workload**: training, inference, fine-tuning? → determines GPU size +2. **Model size**: parameter count → VRAM (7B≈16GB, 13B≈24GB, 70B≈80GB+) +3. **Budget**: hourly budget constraint? + +Ask ONE question at a time. diff --git a/internal/skills/files/verda-reference.md b/internal/skills/files/verda-reference.md new file mode 100644 index 0000000..44236eb --- /dev/null +++ b/internal/skills/files/verda-reference.md @@ -0,0 +1,146 @@ +--- +name: verda-reference +description: Verda CLI command reference — all commands, flags, output fields, and user intent mapping. Use alongside verda-cloud skill. +--- + +# Verda CLI Reference + +All commands: `--agent -o json` (except `verda ssh` and `verda auth show`). + +## User Intent → Command + +| User says | Command | +|-----------|---------| +| "deploy", "create VM", "create instance", "spin up", "launch" | `vm create` | +| "my VMs", "my instances", "list instances", "running machines" | `vm list` | +| "VM info", "instance info", "describe", "show VM" | `vm describe ` | +| "start", "boot", "power on" | `vm start ` | +| "stop", "shut down", "power off" | `vm shutdown ` (alias: `stop`) | +| "hibernate", "suspend", "sleep" | `vm hibernate ` | +| "delete VM", "delete instance", "remove", "destroy", "terminate" | `vm delete ` (alias: `rm`) | +| "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` | +| "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` | +| "ssh key", "sshkey", "my keys", "public key" | `ssh-key` | +| "startup script", "init script", "boot script" | `startup-script` | +| "volume", "disk", "storage", "block storage" | `volume` | +| "balance", "credits", "funds" | `cost balance` | +| "running costs", "burn rate", "spending" | `cost running` | +| "estimate", "how much will it cost" | `cost estimate` | +| "connect", "SSH in", "remote access" | Tell user to run `verda ssh ` themselves (interactive) | +| "login", "authenticate", "credentials" | `auth login` (user runs manually) | + +## Discovery + +| Command | Key Flags | Output Fields | +|---------|-----------|---------------| +| `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` | + +## 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 -o json` → `slug` | +| `--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) + +## 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 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` | + +## 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` | + +## SSH (Interactive — Do NOT Run) + +Tell user to run in their terminal: +- `verda ssh ` — SSH session +- `verda ssh -- -L 8080:localhost:8080` — port forwarding + +## SSH Keys & Startup Scripts + +| Command | Key Flags | +|---------|-----------| +| `verda ssh-key list -o json` | — | +| `verda ssh-key add -o json` | `--name`, `--public-key` | +| `verda ssh-key delete -o json` | confirm first | +| `verda startup-script list -o json` | — | +| `verda startup-script add -o json` | `--name`, `--file` or `--script` | +| `verda startup-script delete -o json` | confirm first | + +## Templates (alias: `tmpl`) + +| Command | Notes | +|---------|-------| +| `verda template list -o json` | Lists saved templates | +| `verda template show vm/ -o json` | Note: `vm/` prefix required | +| `verda template delete vm/` | Confirm first | +| `verda template create` | Interactive — tell user to run | +| `verda template edit ` | Interactive — tell user to run | + +Deploy: `verda --agent vm create --from --hostname --wait --wait-timeout 2m -o json` +Hostname patterns: `{random}` → random words, `{location}` → location code + +## Volumes + +| Command | Key Flags | +|---------|-----------| +| `verda volume list -o json` | `--status` (attached, detached, ordered) | +| `verda volume describe -o json` | — | +| `verda volume create -o json` | `--name`, `--size`, `--type` (NVMe/HDD), `--location` | +| `verda volume action ` | Actions: detach, rename, resize, clone, delete | +| `verda volume trash -o json` | Recoverable within 96 hours | + +## Spot VMs + +- Add `--is-spot` and `--os-volume-on-spot-discontinue keep_detached` to create command +- Spot VMs can be interrupted — warn user + +## Volume Guidance + +- OS volume: always created, default 50 GiB +- Storage: optional. NVMe = fast, HDD = cheap +- Reuse: `volume list --status detached -o json` (must match VM location) + +## Efficiency + +- **Parallel**: instance-types, ssh-key list, cost balance — run together +- **Cache**: instance-types and locations don't change mid-session +- **Skip**: user specifies exact type → skip steps 1-3, still ALWAYS run 4-7 + +## Parameter Sources + +| Parameter | Source | Field | +|-----------|--------|-------| +| instance-type | `instance-types` | `name` | +| location | `availability --type ` | `location_code` | +| image/os | `images --type ` | `slug` | +| ssh-key ID | `ssh-key list` | `id` | +| startup-script ID | `startup-script list` | `id` | +| volume ID | `volume list` | `id` | +| VM ID / hostname | `vm list` | `id`, `hostname` | +| template name | `template list` | `name` | diff --git a/internal/skills/manifest.json b/internal/skills/manifest.json new file mode 100644 index 0000000..8b6d6d0 --- /dev/null +++ b/internal/skills/manifest.json @@ -0,0 +1,51 @@ +{ + "version": "1.0.0", + "skills": [ + "verda-cloud.md", + "verda-reference.md" + ], + "agents": { + "claude-code": { + "display_name": "Claude Code", + "scope": "global", + "target": "~/.claude/skills/", + "method": "copy" + }, + "cursor": { + "display_name": "Cursor", + "scope": "project", + "target": ".cursor/rules/", + "method": "copy" + }, + "windsurf": { + "display_name": "Windsurf", + "scope": "project", + "target": ".windsurf/rules/", + "method": "copy" + }, + "codex": { + "display_name": "Codex", + "scope": "global", + "target": "~/.agents/skills/verda-cloud/", + "method": "copy", + "file_map": { + "verda-cloud.md": "SKILL.md" + } + }, + "gemini": { + "display_name": "Gemini CLI", + "scope": "global", + "target": "~/.gemini/skills/verda-cloud/", + "method": "copy", + "file_map": { + "verda-cloud.md": "SKILL.md" + } + }, + "copilot": { + "display_name": "Copilot", + "scope": "project", + "target": ".github/copilot-instructions.md", + "method": "append" + } + } +} diff --git a/internal/verda-cli/cmd/cmd.go b/internal/verda-cli/cmd/cmd.go index 3d9e15d..fdf2c17 100644 --- a/internal/verda-cli/cmd/cmd.go +++ b/internal/verda-cli/cmd/cmd.go @@ -20,6 +20,7 @@ import ( "github/verda-cloud/verda-cli/internal/verda-cli/cmd/locations" mcpcmd "github/verda-cloud/verda-cli/internal/verda-cli/cmd/mcp" "github/verda-cloud/verda-cli/internal/verda-cli/cmd/settings" + "github/verda-cloud/verda-cli/internal/verda-cli/cmd/skills" "github/verda-cloud/verda-cli/internal/verda-cli/cmd/ssh" "github/verda-cloud/verda-cli/internal/verda-cli/cmd/sshkey" "github/verda-cloud/verda-cli/internal/verda-cli/cmd/startupscript" @@ -130,6 +131,7 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op Message: "AI Agent Commands:", Commands: []*cobra.Command{ mcpcmd.NewCmdMCP(f, ioStreams), + skills.NewCmdSkills(f, ioStreams), }, }, { @@ -163,6 +165,8 @@ func skipCredentialResolution(cmd *cobra.Command) bool { return true case cmd.Name() == "use" && pName == "auth": return true + case pName == "skills": + return true } return false } diff --git a/internal/verda-cli/cmd/skills/fetch.go b/internal/verda-cli/cmd/skills/fetch.go new file mode 100644 index 0000000..7479885 --- /dev/null +++ b/internal/verda-cli/cmd/skills/fetch.go @@ -0,0 +1,162 @@ +package skills + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + embeddedskills "github/verda-cloud/verda-cli/internal/skills" +) + +const defaultAgentName = "claude-code" + +// Manifest describes the structure of the skill repository manifest. +type Manifest struct { + Version string `json:"version"` + Skills []string `json:"skills"` + Agents map[string]*Agent `json:"agents"` +} + +// Agent describes an AI coding agent target for skill installation. +// Agent definitions come from the embedded manifest and optional user overrides. +type Agent struct { + Name string `json:"-"` // set from the map key + DisplayName string `json:"display_name"` + Scope string `json:"scope"` // "global" or "project" + Target string `json:"target"` // path with ~ expansion, or filename for append + Method string `json:"method"` // "copy" or "append" + FileMap map[string]string `json:"file_map,omitempty"` // optional: rename files during install (src -> dst) +} + +// TargetDir returns the resolved directory path for this agent. +// For "copy" agents, it's the directory to copy files into. +// For "append" agents, it's the directory containing the target file. +func (a *Agent) TargetDir() string { + target := expandHome(a.Target) + if a.Method == methodAppend { + return filepath.Dir(target) + } + return target +} + +// TargetFile returns the filename for append-method agents. +func (a *Agent) TargetFile() string { + return filepath.Base(a.Target) +} + +// DisplayLabel returns a human-readable label for prompts. +func (a *Agent) DisplayLabel() string { + return a.DisplayName + " (" + a.Target + ")" +} + +// DestName returns the destination filename for a skill file, applying +// the agent's FileMap rename if one exists. +func (a *Agent) DestName(src string) string { + if a.FileMap != nil { + if dst, ok := a.FileMap[src]; ok { + return dst + } + } + return src +} + +// expandHome replaces a leading ~ with the user's home directory. +func expandHome(path string) string { + if !strings.HasPrefix(path, "~/") { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + return filepath.Join(home, path[2:]) +} + +// AgentNames returns sorted agent names from the manifest. +func (m *Manifest) AgentNames() []string { + // Return in a stable order: claude-code first, then alphabetical. + names := make([]string, 0, len(m.Agents)) + if _, ok := m.Agents[defaultAgentName]; ok { + names = append(names, defaultAgentName) + } + sorted := make([]string, 0, len(m.Agents)) + for name := range m.Agents { + if name != defaultAgentName { + sorted = append(sorted, name) + } + } + // Simple insertion sort for small list. + for i := 1; i < len(sorted); i++ { + for j := i; j > 0 && sorted[j] < sorted[j-1]; j-- { + sorted[j], sorted[j-1] = sorted[j-1], sorted[j] + } + } + names = append(names, sorted...) + return names +} + +// AgentDisplayLabels returns human-readable labels in the same order as AgentNames. +func (m *Manifest) AgentDisplayLabels() []string { + names := m.AgentNames() + labels := make([]string, len(names)) + for i, name := range names { + labels[i] = m.Agents[name].DisplayLabel() + } + return labels +} + +// LoadManifest parses the embedded manifest and merges user-defined agents. +func LoadManifest() (*Manifest, error) { + var m Manifest + if err := json.Unmarshal(embeddedskills.ManifestData(), &m); err != nil { + return nil, fmt.Errorf("parsing manifest: %w", err) + } + // Populate agent Name field from map keys. + for name, agent := range m.Agents { + agent.Name = name + } + mergeUserAgents(&m) + return &m, nil +} + +// LoadSkillFiles reads all skill files listed in the manifest from the embedded filesystem. +func LoadSkillFiles(m *Manifest) (map[string]string, error) { + files := make(map[string]string, len(m.Skills)) + for _, name := range m.Skills { + content, err := embeddedskills.ReadSkillFile(name) + if err != nil { + return nil, fmt.Errorf("reading skill %s: %w", name, err) + } + files[name] = content + } + return files, nil +} + +// userAgentsFile is the structure of ~/.verda/agents.json. +type userAgentsFile struct { + Agents map[string]*Agent `json:"agents"` +} + +// mergeUserAgents reads ~/.verda/agents.json and merges user-defined agents into +// the manifest. User entries override built-in agents with the same key. +// Silent no-op if the file doesn't exist or is malformed. +func mergeUserAgents(m *Manifest) { + home, err := os.UserHomeDir() + if err != nil { + return + } + data, err := os.ReadFile(filepath.Clean(filepath.Join(home, ".verda", "agents.json"))) + if err != nil { + return + } + var uf userAgentsFile + if err := json.Unmarshal(data, &uf); err != nil { + return + } + for name, agent := range uf.Agents { + agent.Name = name + m.Agents[name] = agent + } +} diff --git a/internal/verda-cli/cmd/skills/fetch_test.go b/internal/verda-cli/cmd/skills/fetch_test.go new file mode 100644 index 0000000..cea5333 --- /dev/null +++ b/internal/verda-cli/cmd/skills/fetch_test.go @@ -0,0 +1,138 @@ +package skills + +import ( + "testing" +) + +func TestLoadManifest(t *testing.T) { + t.Parallel() + m, err := LoadManifest() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.Version == "" { + t.Fatal("expected non-empty version") + } + if len(m.Skills) == 0 { + t.Fatal("expected at least one skill") + } + if len(m.Agents) == 0 { + t.Fatal("expected at least one agent") + } + cc, ok := m.Agents["claude-code"] + if !ok { + t.Fatal("expected claude-code agent") + } + if cc.Name != "claude-code" { + t.Fatalf("expected agent Name 'claude-code', got %q", cc.Name) + } +} + +func TestLoadSkillFiles(t *testing.T) { + t.Parallel() + m, err := LoadManifest() + if err != nil { + t.Fatalf("loading manifest: %v", err) + } + files, err := LoadSkillFiles(m) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != len(m.Skills) { + t.Fatalf("expected %d files, got %d", len(m.Skills), len(files)) + } + for name, content := range files { + if content == "" { + t.Fatalf("expected non-empty content for skill %q", name) + } + } +} + +func TestMergeUserAgents(t *testing.T) { + t.Parallel() + // mergeUserAgents should be a no-op when ~/.verda/agents.json doesn't exist. + // Verify the manifest still has its built-in agents after the call. + m, err := LoadManifest() + if err != nil { + t.Fatalf("loading manifest: %v", err) + } + if _, ok := m.Agents["claude-code"]; !ok { + t.Fatal("expected claude-code agent to survive mergeUserAgents") + } + if len(m.Agents) == 0 { + t.Fatal("expected agents to survive mergeUserAgents") + } +} + +func TestManifestAgentNames(t *testing.T) { + t.Parallel() + m := &Manifest{ + Agents: map[string]*Agent{ + "cursor": {Name: "cursor"}, + "claude-code": {Name: "claude-code"}, + "codex": {Name: "codex"}, + }, + } + names := m.AgentNames() + if len(names) != 3 { + t.Fatalf("expected 3 names, got %d", len(names)) + } + if names[0] != "claude-code" { + t.Fatalf("expected claude-code first, got %q", names[0]) + } +} + +func TestAgentTargetDir_Copy(t *testing.T) { + t.Parallel() + a := &Agent{Name: "cursor", Target: ".cursor/rules/", Method: "copy"} + if dir := a.TargetDir(); dir != ".cursor/rules/" { + t.Fatalf("expected '.cursor/rules/', got %q", dir) + } +} + +func TestAgentTargetDir_Append(t *testing.T) { + t.Parallel() + a := &Agent{Name: "codex", Target: "AGENTS.md", Method: methodAppend} + if dir := a.TargetDir(); dir != "." { + t.Fatalf("expected '.', got %q", dir) + } + if f := a.TargetFile(); f != "AGENTS.md" { + t.Fatalf("expected 'AGENTS.md', got %q", f) + } +} + +func TestAgentDisplayLabel(t *testing.T) { + t.Parallel() + a := &Agent{DisplayName: "Claude Code", Target: "~/.claude/skills/"} + label := a.DisplayLabel() + if label != "Claude Code (~/.claude/skills/)" { + t.Fatalf("unexpected label: %q", label) + } +} + +func TestAgentDestName(t *testing.T) { + t.Parallel() + a := &Agent{Name: "codex", FileMap: map[string]string{"verda-cloud.md": "SKILL.md"}} + if got := a.DestName("verda-cloud.md"); got != "SKILL.md" { + t.Fatalf("expected SKILL.md, got %q", got) + } + if got := a.DestName("verda-reference.md"); got != "verda-reference.md" { + t.Fatalf("expected verda-reference.md (no mapping), got %q", got) + } + // No file_map + b := &Agent{Name: "cursor"} + if got := b.DestName("verda-cloud.md"); got != "verda-cloud.md" { + t.Fatalf("expected verda-cloud.md (nil map), got %q", got) + } +} + +func TestExpandHome(t *testing.T) { + t.Parallel() + if p := expandHome(".cursor/rules/"); p != ".cursor/rules/" { + t.Fatalf("expected unchanged path, got %q", p) + } + expanded := expandHome("~/.claude/skills/") + if expanded == "~/.claude/skills/" { + t.Fatal("expected ~ to be expanded") + } +} diff --git a/internal/verda-cli/cmd/skills/install.go b/internal/verda-cli/cmd/skills/install.go new file mode 100644 index 0000000..f18dcb3 --- /dev/null +++ b/internal/verda-cli/cmd/skills/install.go @@ -0,0 +1,363 @@ +package skills + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// errCanceled is returned when the user declines confirmation. +var errCanceled = errors.New("canceled") + +const ( + markerStart = "" + markerEnd = "" + methodAppend = "append" +) + +type installOptions struct { + agents []string + force bool + statePath string + agentOverrides map[string]*Agent +} + +func NewCmdInstall(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + opts := &installOptions{} + + cmd := &cobra.Command{ + Use: "install [agents...]", + Short: "Install AI agent skills for Verda Cloud", + Long: cmdutil.LongDesc(` + Install skill files that teach AI coding agents how to use the + Verda CLI. Skills are bundled with the CLI binary and versioned + with each release. + + Two skill files are installed per agent: + - verda-cloud.md: Decision engine (deploy workflow, cost checks, error recovery) + - verda-reference.md: Command reference (flags, output fields, parameter sources) + + Install methods vary by agent: + - copy: Files placed in agent's rules directory (Claude Code, Cursor, Windsurf) + - append: Content injected between markers in a target file (Codex, Gemini, Copilot) + + Without arguments, shows an interactive picker to select agents. + With arguments, installs for the specified agents directly. + `), + Example: cmdutil.Examples(` + # Interactive — select agents from a list + verda skills install + + # Install for Claude Code (writes to ~/.claude/skills/) + verda skills install claude-code + + # Install for multiple agents at once + verda skills install claude-code cursor windsurf + + # Reinstall / update without confirmation + verda skills install claude-code --force + + # Non-interactive for CI/scripts (defaults to claude-code) + verda --agent skills install claude-code + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.agents = args + return runInstall(cmd.Context(), f, ioStreams, opts) + }, + } + + cmd.Flags().BoolVar(&opts.force, "force", false, "Skip confirmation and reinstall even if already installed") + + return cmd +} + +func runInstall(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *installOptions) error { + manifest, err := loadManifestWithSpinner(ctx, f) + if err != nil { + return err + } + + selectedAgents, err := resolveAgents(ctx, f, opts, manifest) + if err != nil { + return err + } + if len(selectedAgents) == 0 { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "No agents selected.") + return nil + } + + if err := confirmInstall(ctx, f, ioStreams, opts, manifest, selectedAgents); err != nil { + if errors.Is(err, errCanceled) { + return nil + } + return err + } + + skillFiles, err := LoadSkillFiles(manifest) + if err != nil { + return err + } + + // Load previous state to detect stale files from renamed skills. + prevState := loadPreviousState(opts) + + if err := installAndPrint(ioStreams, selectedAgents, manifest, skillFiles, prevState.Skills); err != nil { + return err + } + + if err := saveInstallState(ioStreams, opts, manifest, selectedAgents); err != nil { + return err + } + + _, _ = fmt.Fprintf(ioStreams.Out, "\nInstalled verda-ai-skills v%s\n", manifest.Version) + printHints(ioStreams, opts, manifest, selectedAgents) + + return nil +} + +func loadManifestWithSpinner(ctx context.Context, f cmdutil.Factory) (*Manifest, error) { + var sp interface{ Stop(string) } + if status := f.Status(); status != nil { + sp, _ = status.Spinner(ctx, "Loading skills manifest...") + } + manifest, err := LoadManifest() + if sp != nil { + sp.Stop("") + } + if err != nil { + return nil, fmt.Errorf("could not load skills: %w", err) + } + return manifest, nil +} + +func confirmInstall(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *installOptions, manifest *Manifest, selectedAgents []*Agent) error { + if f.AgentMode() || opts.force { + return nil + } + agentNames := make([]string, len(selectedAgents)) + for i, a := range selectedAgents { + agentNames[i] = a.DisplayName + } + prompt := fmt.Sprintf("Install verda-ai-skills v%s for %s?", manifest.Version, strings.Join(agentNames, ", ")) + confirmed, err := f.Prompter().Confirm(ctx, prompt) + if err != nil { + return err + } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return errCanceled + } + return nil +} + +func loadPreviousState(opts *installOptions) *State { + statePath := opts.statePath + if statePath == "" { + var err error + statePath, err = StatePath() + if err != nil { + return &State{} + } + } + state, _ := LoadState(statePath) + return state +} + +func installAndPrint(ioStreams cmdutil.IOStreams, selectedAgents []*Agent, manifest *Manifest, skillFiles map[string]string, previousSkills []string) error { + for _, agent := range selectedAgents { + if installErr := installForAgent(agent, skillFiles, previousSkills); installErr != nil { + return fmt.Errorf("installing for %s: %w", agent.DisplayName, installErr) + } + dir := agent.TargetDir() + if agent.Method == methodAppend { + _, _ = fmt.Fprintf(ioStreams.Out, " %s: %s\n", agent.DisplayName, filepath.Join(dir, agent.TargetFile())) + } else { + for _, name := range manifest.Skills { + _, _ = fmt.Fprintf(ioStreams.Out, " %s: %s\n", agent.DisplayName, filepath.Join(dir, agent.DestName(name))) + } + } + } + return nil +} + +func saveInstallState(ioStreams cmdutil.IOStreams, opts *installOptions, manifest *Manifest, selectedAgents []*Agent) error { + statePath := opts.statePath + if statePath == "" { + var err error + statePath, err = StatePath() + if err != nil { + return err + } + } + state, _ := LoadState(statePath) + state.Version = manifest.Version + state.InstalledAt = time.Now() + state.Skills = manifest.Skills + for _, a := range selectedAgents { + if !state.HasAgent(a.Name) { + state.Agents = append(state.Agents, a.Name) + } + } + if saveErr := SaveState(statePath, state); saveErr != nil { + _, _ = fmt.Fprintf(ioStreams.ErrOut, "Warning: could not save state: %v\n", saveErr) + } + return nil +} + +func printHints(ioStreams cmdutil.IOStreams, opts *installOptions, manifest *Manifest, selectedAgents []*Agent) { + statePath := opts.statePath + if statePath == "" { + statePath, _ = StatePath() + } + state, _ := LoadState(statePath) + + installed := make(map[string]bool, len(selectedAgents)) + for _, a := range selectedAgents { + installed[a.Name] = true + } + var hints []string + for _, name := range manifest.AgentNames() { + if !installed[name] && !state.HasAgent(name) { + hints = append(hints, name) + } + } + if len(hints) > 0 && len(hints) <= 4 { + _, _ = fmt.Fprintf(ioStreams.ErrOut, "\nAlso using other AI agents? Run:\n") + _, _ = fmt.Fprintf(ioStreams.ErrOut, " verda skills install %s\n", strings.Join(hints, " ")) + } +} + +func resolveAgents(ctx context.Context, f cmdutil.Factory, opts *installOptions, manifest *Manifest) ([]*Agent, error) { + if len(opts.agents) > 0 { + resolved := make([]*Agent, 0, len(opts.agents)) + for _, name := range opts.agents { + a := resolveAgent(opts, manifest, name) + if a == nil { + return nil, fmt.Errorf("unknown agent %q. Known agents: %s", + name, strings.Join(manifest.AgentNames(), ", ")) + } + resolved = append(resolved, a) + } + return resolved, nil + } + + if f.AgentMode() { + a := resolveAgent(opts, manifest, defaultAgentName) + if a == nil { + return nil, errors.New("claude-code not found in manifest") + } + return []*Agent{a}, nil + } + + labels := manifest.AgentDisplayLabels() + selected, err := f.Prompter().MultiSelect(ctx, "Select AI agents to install skills for", labels) + if err != nil { + return nil, err + } + + names := manifest.AgentNames() + resolved := make([]*Agent, 0, len(selected)) + for _, idx := range selected { + a := resolveAgent(opts, manifest, names[idx]) + if a != nil { + resolved = append(resolved, a) + } + } + return resolved, nil +} + +func resolveAgent(opts *installOptions, manifest *Manifest, name string) *Agent { + if opts.agentOverrides != nil { + if a, ok := opts.agentOverrides[name]; ok { + return a + } + } + return manifest.Agents[name] +} + +func installForAgent(agent *Agent, skillFiles map[string]string, previousSkills []string) error { + dir := agent.TargetDir() + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("creating directory %s: %w", dir, err) + } + if agent.Method == methodAppend { + return installAppend(agent, dir, skillFiles) + } + // Remove stale files from a previous install (e.g. renamed skills). + cleanupStaleFiles(dir, agent, skillFiles, previousSkills) + return installCopy(dir, agent, skillFiles) +} + +// cleanupStaleFiles removes previously installed skill files that are no longer +// in the current manifest. This handles file renames across CLI versions. +func cleanupStaleFiles(dir string, agent *Agent, currentFiles map[string]string, previousSkills []string) { + // Build set of current destination filenames. + current := make(map[string]bool, len(currentFiles)) + for name := range currentFiles { + current[agent.DestName(name)] = true + } + for _, old := range previousSkills { + // Apply file_map to resolve destination name (handles both plain + // names and mapped names like verda-cloud.md → SKILL.md). + oldDest := agent.DestName(old) + if current[oldDest] { + continue // still in current manifest + } + _ = os.Remove(filepath.Join(dir, oldDest)) // best-effort + } +} + +func installCopy(dir string, agent *Agent, skillFiles map[string]string) error { + for name, content := range skillFiles { + path := filepath.Join(dir, agent.DestName(name)) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { //nolint:gosec // non-sensitive skill files + return fmt.Errorf("writing %s: %w", path, err) + } + } + return nil +} + +func installAppend(agent *Agent, dir string, skillFiles map[string]string) error { + path := filepath.Join(dir, agent.TargetFile()) + + var block strings.Builder + block.WriteString(markerStart + "\n") + for _, content := range skillFiles { + block.WriteString(content) + if !strings.HasSuffix(content, "\n") { + block.WriteString("\n") + } + block.WriteString("\n") + } + block.WriteString(markerEnd) + + existing, err := os.ReadFile(filepath.Clean(path)) + if err != nil && !os.IsNotExist(err) { + return err + } + + content := string(existing) + startIdx := strings.Index(content, markerStart) + endIdx := strings.Index(content, markerEnd) + if startIdx >= 0 && endIdx >= 0 { + content = content[:startIdx] + block.String() + content[endIdx+len(markerEnd):] + } else { + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + if content != "" { + content += "\n" + } + content += block.String() + "\n" + } + + return os.WriteFile(path, []byte(content), 0o644) //nolint:gosec // non-sensitive skill files +} diff --git a/internal/verda-cli/cmd/skills/install_test.go b/internal/verda-cli/cmd/skills/install_test.go new file mode 100644 index 0000000..433bbea --- /dev/null +++ b/internal/verda-cli/cmd/skills/install_test.go @@ -0,0 +1,211 @@ +package skills + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + + cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +func TestInstallCopy(t *testing.T) { + t.Parallel() + dir := t.TempDir() + skillFiles := map[string]string{ + "verda-cloud.md": "# Verda Cloud\ntest", + "verda-reference.md": "# Commands\ntest", + } + agent := &Agent{ + Name: "test-agent", DisplayName: "Test Agent", + Scope: "global", Method: "copy", Target: dir, + } + if err := installForAgent(agent, skillFiles, nil); err != nil { + t.Fatalf("install error: %v", err) + } + for name, content := range skillFiles { + data, err := os.ReadFile(filepath.Clean(filepath.Join(dir, name))) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if string(data) != content { + t.Fatalf("content mismatch for %s", name) + } + } +} + +func TestInstallAppend(t *testing.T) { + t.Parallel() + dir := t.TempDir() + skillFiles := map[string]string{"verda-cloud.md": "# Verda Cloud\ntest"} + agent := &Agent{ + Name: "codex", DisplayName: "Codex", + Scope: "project", Method: methodAppend, + Target: filepath.Join(dir, "AGENTS.md"), + } + if err := installForAgent(agent, skillFiles, nil); err != nil { + t.Fatalf("install error: %v", err) + } + data, err := os.ReadFile(filepath.Clean(filepath.Join(dir, "AGENTS.md"))) + if err != nil { + t.Fatalf("read error: %v", err) + } + if !bytes.Contains(data, []byte(markerStart)) { + t.Fatal("expected start marker") + } + if !bytes.Contains(data, []byte(markerEnd)) { + t.Fatal("expected end marker") + } + if !bytes.Contains(data, []byte("# Verda Cloud")) { + t.Fatal("expected skill content") + } +} + +func TestInstallAppend_Idempotent(t *testing.T) { + t.Parallel() + dir := t.TempDir() + agent := &Agent{ + Name: "codex", Scope: "project", Method: methodAppend, + Target: filepath.Join(dir, "AGENTS.md"), + } + _ = installForAgent(agent, map[string]string{"verda-cloud.md": "# V1"}, nil) + _ = installForAgent(agent, map[string]string{"verda-cloud.md": "# V2"}, nil) + + data, _ := os.ReadFile(filepath.Clean(filepath.Join(dir, "AGENTS.md"))) + if bytes.Count(data, []byte(markerStart)) != 1 { + t.Fatalf("expected exactly 1 start marker, got content:\n%s", data) + } + if !bytes.Contains(data, []byte("# V2")) { + t.Fatal("expected updated content") + } +} + +func TestInstallCopy_FileMap(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: "codex", DisplayName: "Codex", + Scope: "global", Method: "copy", Target: dir, + FileMap: map[string]string{"verda-cloud.md": "SKILL.md"}, + } + if err := installForAgent(agent, skillFiles, nil); err != nil { + t.Fatalf("install error: %v", err) + } + // verda-cloud.md should be installed as SKILL.md + if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { + t.Fatal("expected SKILL.md to exist") + } + // verda-reference.md keeps its name (not in file_map) + if _, err := os.Stat(filepath.Join(dir, "verda-reference.md")); err != nil { + t.Fatal("expected verda-reference.md to exist") + } + // verda-cloud.md should NOT exist (it was renamed) + if _, err := os.Stat(filepath.Join(dir, "verda-cloud.md")); !os.IsNotExist(err) { + t.Fatal("verda-cloud.md should not exist — should be SKILL.md") + } +} + +func TestInstallCopy_CleansUpStaleFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Simulate a previous install with an old filename. + oldFile := filepath.Join(dir, "verda-commands.md") + _ = os.WriteFile(oldFile, []byte("old content"), 0o600) + + // Install with new filenames — old file should be cleaned up. + newFiles := map[string]string{ + "verda-cloud.md": "# Cloud", + "verda-reference.md": "# Reference", + } + agent := &Agent{ + Name: "test", DisplayName: "Test", + Scope: "global", Method: "copy", Target: dir, + } + previousSkills := []string{"verda-cloud.md", "verda-commands.md"} + + if err := installForAgent(agent, newFiles, previousSkills); err != nil { + t.Fatalf("install error: %v", err) + } + + // New files should exist. + if _, err := os.Stat(filepath.Join(dir, "verda-reference.md")); err != nil { + t.Fatal("verda-reference.md should exist") + } + // Old renamed file should be removed. + if _, err := os.Stat(oldFile); !os.IsNotExist(err) { + t.Fatal("verda-commands.md should have been removed") + } + // File that exists in both old and new should still exist. + if _, err := os.Stat(filepath.Join(dir, "verda-cloud.md")); err != nil { + t.Fatal("verda-cloud.md should still exist") + } +} + +func TestRunInstall_NonInteractive(t *testing.T) { + t.Parallel() + + targetDir := t.TempDir() + statePath := filepath.Join(t.TempDir(), "skills.json") + + mock := tuitest.New().AddConfirm(true) + f := cmdutil.NewTestFactory(mock) + var out bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &out, ErrOut: &out} + + opts := &installOptions{ + agents: []string{"claude-code"}, + statePath: statePath, + agentOverrides: map[string]*Agent{ + "claude-code": { + Name: "claude-code", DisplayName: "Claude Code", + Scope: "global", Method: "copy", Target: targetDir, + }, + }, + } + + if err := runInstall(context.Background(), f, ioStreams, opts); err != nil { + t.Fatalf("install error: %v", err) + } + if _, err := os.Stat(filepath.Join(targetDir, "verda-cloud.md")); err != nil { + t.Fatal("verda-cloud.md not installed") + } + if _, err := os.Stat(filepath.Join(targetDir, "verda-reference.md")); err != nil { + t.Fatal("verda-reference.md not installed") + } + state, _ := LoadState(statePath) + if state.Version == "" { + t.Fatal("expected non-empty version in state") + } + if !state.HasAgent("claude-code") { + t.Fatal("expected claude-code in state") + } +} + +func TestRunInstall_UserCancels(t *testing.T) { + t.Parallel() + + mock := tuitest.New().AddConfirm(false) + f := cmdutil.NewTestFactory(mock) + var out bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &out, ErrOut: &out} + + opts := &installOptions{ + agents: []string{"claude-code"}, + statePath: filepath.Join(t.TempDir(), "skills.json"), + } + + if err := runInstall(context.Background(), f, ioStreams, opts); err != nil { + t.Fatalf("expected nil on cancel, got: %v", err) + } + if !bytes.Contains(out.Bytes(), []byte("Canceled")) { + t.Fatal("expected canceled message") + } +} diff --git a/internal/verda-cli/cmd/skills/skills.go b/internal/verda-cli/cmd/skills/skills.go new file mode 100644 index 0000000..5d5c446 --- /dev/null +++ b/internal/verda-cli/cmd/skills/skills.go @@ -0,0 +1,56 @@ +package skills + +import ( + "github.com/spf13/cobra" + + cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +func NewCmdSkills(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "skills", + Short: "Manage AI agent skills for Verda Cloud", + Long: cmdutil.LongDesc(` + Install, update, and manage AI agent skill files that teach coding + agents how to use the Verda CLI for cloud infrastructure management. + + Skills teach AI agents to: + - Discover instance types, availability, and pricing + - Deploy GPU/CPU VMs with proper dependency chains + - Manage lifecycle (start, stop, delete) with safety checks + - Handle costs, volumes, SSH keys, and startup scripts + - Use --agent and -o json flags correctly + + Skills are bundled with the CLI binary — no network fetch needed. + They are versioned with the CLI, so updating the CLI updates skills. + + Supported agents: Claude Code, Cursor, Windsurf, Codex, Gemini CLI, Copilot. + Custom agents can be added via ~/.verda/agents.json. + `), + Example: cmdutil.Examples(` + # Install skills for your AI coding agent + verda skills install claude-code + + # Install for multiple agents at once + verda skills install claude-code cursor windsurf + + # Check installed version and available updates + verda skills status + + # Add a custom agent via ~/.verda/agents.json: + # { "agents": { "aider": { "display_name": "Aider", + # "scope": "project", "target": ".aider/instructions.md", + # "method": "append" } } } + verda skills install aider + `), + Run: cmdutil.DefaultSubCommandRun(ioStreams.Out), + } + + cmd.AddCommand( + NewCmdInstall(f, ioStreams), + NewCmdStatus(f, ioStreams), + NewCmdUninstall(f, ioStreams), + ) + + return cmd +} diff --git a/internal/verda-cli/cmd/skills/state.go b/internal/verda-cli/cmd/skills/state.go new file mode 100644 index 0000000..454f994 --- /dev/null +++ b/internal/verda-cli/cmd/skills/state.go @@ -0,0 +1,76 @@ +package skills + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "slices" + "time" + + clioptions "github/verda-cloud/verda-cli/internal/verda-cli/options" +) + +// State tracks which skills version is installed and for which agents. +type State struct { + Version string `json:"version"` + InstalledAt time.Time `json:"installed_at"` + Agents []string `json:"agents"` + Skills []string `json:"skills,omitempty"` // skill filenames installed (for cleanup on rename) +} + +// HasAgent reports whether the given agent name is in the installed list. +func (s *State) HasAgent(name string) bool { + return slices.Contains(s.Agents, name) +} + +// RemoveAgent removes the named agent from the installed list. +func (s *State) RemoveAgent(name string) { + filtered := s.Agents[:0] + for _, a := range s.Agents { + if a != name { + filtered = append(filtered, a) + } + } + s.Agents = filtered +} + +// StatePath returns the default path for the skills state file (~/.verda/skills.json). +func StatePath() (string, error) { + dir, err := clioptions.VerdaDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "skills.json"), nil +} + +// LoadState reads the skills state from the given path. +// If the file does not exist, it returns an empty State (not an error). +func LoadState(path string) (*State, error) { + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return &State{}, nil + } + return nil, err + } + var s State + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + return &s, nil +} + +// SaveState writes the skills state to the given path, creating parent +// directories as needed. +func SaveState(path string, s *State) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} diff --git a/internal/verda-cli/cmd/skills/state_test.go b/internal/verda-cli/cmd/skills/state_test.go new file mode 100644 index 0000000..ea7faaa --- /dev/null +++ b/internal/verda-cli/cmd/skills/state_test.go @@ -0,0 +1,88 @@ +package skills + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadState_MissingFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + state, err := LoadState(filepath.Join(dir, "skills.json")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if state.Version != "" { + t.Fatalf("expected empty version, got %q", state.Version) + } + if len(state.Agents) != 0 { + t.Fatalf("expected no agents, got %d", len(state.Agents)) + } +} + +func TestSaveAndLoadState(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "skills.json") + now := time.Now().Truncate(time.Second) + want := &State{ + Version: "1.0.0", + InstalledAt: now, + Agents: []string{"claude-code", "cursor"}, + } + if err := SaveState(path, want); err != nil { + t.Fatalf("save error: %v", err) + } + got, err := LoadState(path) + if err != nil { + t.Fatalf("load error: %v", err) + } + if got.Version != want.Version { + t.Fatalf("version: expected %q, got %q", want.Version, got.Version) + } + if len(got.Agents) != 2 { + t.Fatalf("agents: expected 2, got %d", len(got.Agents)) + } + if got.Agents[0] != "claude-code" || got.Agents[1] != "cursor" { + t.Fatalf("agents: expected [claude-code cursor], got %v", got.Agents) + } +} + +func TestStatePath(t *testing.T) { + // Cannot use t.Parallel() with t.Setenv + // Unset VERDA_HOME to ensure we get the default path + t.Setenv("VERDA_HOME", "") + path, err := StatePath() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + home, _ := os.UserHomeDir() + expected := filepath.Join(home, ".verda", "skills.json") + if path != expected { + t.Fatalf("expected %q, got %q", expected, path) + } +} + +func TestStateHasAgent(t *testing.T) { + t.Parallel() + s := &State{Agents: []string{"claude-code", "cursor"}} + if !s.HasAgent("claude-code") { + t.Fatal("expected HasAgent to return true for claude-code") + } + if s.HasAgent("windsurf") { + t.Fatal("expected HasAgent to return false for windsurf") + } +} + +func TestStateRemoveAgent(t *testing.T) { + t.Parallel() + s := &State{Agents: []string{"claude-code", "cursor", "windsurf"}} + s.RemoveAgent("cursor") + if len(s.Agents) != 2 { + t.Fatalf("expected 2 agents after remove, got %d", len(s.Agents)) + } + if s.HasAgent("cursor") { + t.Fatal("expected cursor to be removed") + } +} diff --git a/internal/verda-cli/cmd/skills/status.go b/internal/verda-cli/cmd/skills/status.go new file mode 100644 index 0000000..d1e64c8 --- /dev/null +++ b/internal/verda-cli/cmd/skills/status.go @@ -0,0 +1,119 @@ +package skills + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +type statusOptions struct { + statePath string +} + +type statusOutput struct { + Installed bool `json:"installed"` + Version string `json:"version,omitempty"` + Latest string `json:"latest,omitempty"` + Agents []string `json:"agents,omitempty"` + UpdateAvailable bool `json:"update_available,omitempty"` +} + +func NewCmdStatus(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + opts := &statusOptions{} + cmd := &cobra.Command{ + Use: "status", + Short: "Show installed skills status", + Long: cmdutil.LongDesc(` + Display the currently installed skills version, which agents + have skills installed, and whether a newer version is bundled + with this CLI build. + + The "latest" version is the one embedded in the current CLI binary. + If your installed version differs, run "verda skills install" to update. + `), + Example: cmdutil.Examples(` + # Human-readable output + verda skills status + + # Structured output for scripts + verda skills status -o json + + # Check if update is available (JSON fields: version, latest, update_available) + verda --agent skills status -o json + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runStatus(cmd.Context(), f, ioStreams, opts) + }, + } + return cmd +} + +func runStatus(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *statusOptions) error { + statePath := opts.statePath + if statePath == "" { + var err error + statePath, err = StatePath() + if err != nil { + return err + } + } + + state, err := LoadState(statePath) + if err != nil { + return fmt.Errorf("reading state: %w", err) + } + + out := statusOutput{ + Installed: state.Version != "", + Version: state.Version, + Agents: state.Agents, + } + + // Load manifest to resolve agent display names and check version. + var manifest *Manifest + if out.Installed { + if m, loadErr := LoadManifest(); loadErr == nil { + manifest = m + out.Latest = m.Version + out.UpdateAvailable = m.Version != state.Version + } + } + + // Structured output (json/yaml). + if wrote, writeErr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), out); wrote { + return writeErr + } + + // Table output. + if !out.Installed { + _, _ = fmt.Fprintln(ioStreams.Out, "Verda AI skills: not installed") + _, _ = fmt.Fprintln(ioStreams.Out, "\nRun 'verda skills install' to get started.") + return nil + } + + _, _ = fmt.Fprintf(ioStreams.Out, " Verda AI Skills\n\n") + _, _ = fmt.Fprintf(ioStreams.Out, " Version: %s\n", out.Version) + if out.Latest != "" { + _, _ = fmt.Fprintf(ioStreams.Out, " Latest: %s\n", out.Latest) + } + if out.UpdateAvailable { + _, _ = fmt.Fprintf(ioStreams.Out, " Update: available (run 'verda skills install')\n") + } + _, _ = fmt.Fprintf(ioStreams.Out, " Installed: %s\n", state.InstalledAt.Format("2006-01-02 15:04")) + _, _ = fmt.Fprintf(ioStreams.Out, "\n Agents:\n") + for _, name := range out.Agents { + displayName := name + if manifest != nil { + if a, ok := manifest.Agents[name]; ok { + displayName = a.DisplayName + } + } + _, _ = fmt.Fprintf(ioStreams.Out, " %s\n", displayName) + } + + return nil +} diff --git a/internal/verda-cli/cmd/skills/status_test.go b/internal/verda-cli/cmd/skills/status_test.go new file mode 100644 index 0000000..d0b0ccd --- /dev/null +++ b/internal/verda-cli/cmd/skills/status_test.go @@ -0,0 +1,88 @@ +package skills + +import ( + "bytes" + "context" + "path/filepath" + "testing" + "time" + + cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" + + tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" +) + +func TestRunStatus_Installed(t *testing.T) { + t.Parallel() + + statePath := filepath.Join(t.TempDir(), "skills.json") + _ = SaveState(statePath, &State{ + Version: "1.0.0", + InstalledAt: time.Date(2026, 4, 8, 10, 0, 0, 0, time.UTC), + Agents: []string{"claude-code", "cursor"}, + }) + + mock := tuitest.New() + f := cmdutil.NewTestFactory(mock) + var out bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &out, ErrOut: &out} + + opts := &statusOptions{ + statePath: statePath, + } + + if err := runStatus(context.Background(), f, ioStreams, opts); err != nil { + t.Fatalf("status error: %v", err) + } + + output := out.String() + if !bytes.Contains(out.Bytes(), []byte("1.0.0")) { + t.Fatalf("expected installed version in output, got:\n%s", output) + } + if !bytes.Contains(out.Bytes(), []byte("Claude Code")) { + t.Fatalf("expected Claude Code in output, got:\n%s", output) + } +} + +func TestRunStatus_NotInstalled(t *testing.T) { + t.Parallel() + statePath := filepath.Join(t.TempDir(), "skills.json") + + mock := tuitest.New() + f := cmdutil.NewTestFactory(mock) + var out bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &out, ErrOut: &out} + + opts := &statusOptions{statePath: statePath} + + if err := runStatus(context.Background(), f, ioStreams, opts); err != nil { + t.Fatalf("status error: %v", err) + } + if !bytes.Contains(out.Bytes(), []byte("not installed")) { + t.Fatalf("expected 'not installed' message, got:\n%s", out.String()) + } +} + +func TestRunStatus_JSON(t *testing.T) { + t.Parallel() + statePath := filepath.Join(t.TempDir(), "skills.json") + _ = SaveState(statePath, &State{ + Version: "1.0.0", + Agents: []string{"claude-code"}, + }) + + mock := tuitest.New() + f := cmdutil.NewTestFactory(mock) + f.OutputFormatOverride = "json" + var out bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &out, ErrOut: &out} + + opts := &statusOptions{statePath: statePath} + + if err := runStatus(context.Background(), f, ioStreams, opts); err != nil { + t.Fatalf("status error: %v", err) + } + if !bytes.Contains(out.Bytes(), []byte(`"version"`)) { + t.Fatalf("expected JSON output, got:\n%s", out.String()) + } +} diff --git a/internal/verda-cli/cmd/skills/uninstall.go b/internal/verda-cli/cmd/skills/uninstall.go new file mode 100644 index 0000000..d74e8dc --- /dev/null +++ b/internal/verda-cli/cmd/skills/uninstall.go @@ -0,0 +1,261 @@ +package skills + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +var defaultSkillNames = []string{"verda-cloud.md", "verda-reference.md"} + +type uninstallOptions struct { + agents []string + statePath string + skillNames []string + agentOverrides map[string]*Agent +} + +func NewCmdUninstall(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + opts := &uninstallOptions{} + cmd := &cobra.Command{ + Use: "uninstall [agents...]", + Short: "Remove installed AI agent skills", + Long: cmdutil.LongDesc(` + Remove verda-ai-skills from the specified AI agents. + + For copy-method agents (Claude Code, Cursor, Windsurf), skill files + are deleted from the target directory. For append-method agents + (Codex, Gemini, Copilot), the marked content block is removed from + the target file, preserving surrounding content. + + Without arguments, shows an interactive picker of currently + installed agents to select from. + `), + Example: cmdutil.Examples(` + # Interactive — select from installed agents + verda skills uninstall + + # Remove from specific agent + verda skills uninstall claude-code + + # Remove from multiple agents + verda skills uninstall claude-code cursor + + # Non-interactive — remove from all installed agents + verda --agent skills uninstall + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.agents = args + return runUninstall(cmd.Context(), f, ioStreams, opts) + }, + } + return cmd +} + +func runUninstall(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *uninstallOptions) error { + statePath := opts.statePath + if statePath == "" { + var err error + statePath, err = StatePath() + if err != nil { + return err + } + } + + state, err := LoadState(statePath) + if err != nil { + return fmt.Errorf("reading state: %w", err) + } + + if state.Version == "" && len(opts.agents) == 0 { + _, _ = fmt.Fprintln(ioStreams.Out, "No skills installed.") + return nil + } + + // Load manifest to resolve agent definitions. + manifest, _ := fetchManifestForUninstall() + + selectedAgents, err := resolveUninstallAgents(ctx, f, ioStreams, opts, state, manifest) + if err != nil { + return err + } + if len(selectedAgents) == 0 { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "No agents selected.") + return nil + } + + // Confirm. + if !f.AgentMode() { + agentNames := make([]string, len(selectedAgents)) + for i, a := range selectedAgents { + agentNames[i] = a.DisplayName + } + prompt := fmt.Sprintf("Remove verda-ai-skills from %s?", strings.Join(agentNames, ", ")) + confirmed, confirmErr := f.Prompter().Confirm(ctx, prompt) + if confirmErr != nil { + return confirmErr + } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + } + + // Use skill names from state (tracks what was actually installed), + // fall back to manifest, then hardcoded defaults. + skillNames := opts.skillNames + if len(skillNames) == 0 && len(state.Skills) > 0 { + skillNames = state.Skills + } + if len(skillNames) == 0 { + skillNames = defaultSkillNames + } + + for _, agent := range selectedAgents { + if removeErr := uninstallForAgent(agent, skillNames); removeErr != nil { + _, _ = fmt.Fprintf(ioStreams.ErrOut, " Warning: %s: %v\n", agent.DisplayName, removeErr) + continue + } + _, _ = fmt.Fprintf(ioStreams.Out, " Removed from %s\n", agent.DisplayName) + state.RemoveAgent(agent.Name) + } + + if len(state.Agents) == 0 { + state.Version = "" + } + + if saveErr := SaveState(statePath, state); saveErr != nil { + _, _ = fmt.Fprintf(ioStreams.ErrOut, "Warning: could not save state: %v\n", saveErr) + } + + _, _ = fmt.Fprintln(ioStreams.Out, "\nDone.") + return nil +} + +func fetchManifestForUninstall() (*Manifest, error) { + return LoadManifest() +} + +func resolveUninstallAgents(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *uninstallOptions, state *State, manifest *Manifest) ([]*Agent, error) { + if len(opts.agents) > 0 { + resolved := make([]*Agent, 0, len(opts.agents)) + for _, name := range opts.agents { + a := resolveUninstallAgent(opts, manifest, name) + if a == nil { + return nil, fmt.Errorf("unknown agent %q", name) + } + resolved = append(resolved, a) + } + return resolved, nil + } + + if f.AgentMode() { + resolved := make([]*Agent, 0, len(state.Agents)) + for _, name := range state.Agents { + a := resolveUninstallAgent(opts, manifest, name) + if a != nil { + resolved = append(resolved, a) + } + } + return resolved, nil + } + + if len(state.Agents) == 0 { + _, _ = fmt.Fprintln(ioStreams.Out, "No agents with skills installed.") + return nil, nil + } + + labels := make([]string, len(state.Agents)) + for i, name := range state.Agents { + a := resolveUninstallAgent(opts, manifest, name) + if a != nil { + labels[i] = a.DisplayName + } else { + labels[i] = name + } + } + + selected, selectErr := f.Prompter().MultiSelect(ctx, "Select agents to remove skills from", labels) + if selectErr != nil { + return nil, selectErr + } + + resolved := make([]*Agent, 0, len(selected)) + for _, idx := range selected { + a := resolveUninstallAgent(opts, manifest, state.Agents[idx]) + if a != nil { + resolved = append(resolved, a) + } + } + return resolved, nil +} + +// resolveUninstallAgent looks up an agent by name: overrides first, then manifest. +func resolveUninstallAgent(opts *uninstallOptions, manifest *Manifest, name string) *Agent { + if opts.agentOverrides != nil { + if a, ok := opts.agentOverrides[name]; ok { + return a + } + } + if manifest != nil { + return manifest.Agents[name] + } + return nil +} + +func uninstallForAgent(agent *Agent, skillNames []string) error { + if agent.Method == methodAppend { + return uninstallAppend(agent) + } + return uninstallCopy(agent, skillNames) +} + +func uninstallCopy(agent *Agent, skillNames []string) error { + dir := agent.TargetDir() + for _, name := range skillNames { + path := filepath.Join(dir, agent.DestName(name)) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing %s: %w", path, err) + } + } + return nil +} + +func uninstallAppend(agent *Agent) error { + path := filepath.Join(agent.TargetDir(), agent.TargetFile()) + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + content := string(data) + startIdx := strings.Index(content, markerStart) + endIdx := strings.Index(content, markerEnd) + if startIdx < 0 || endIdx < 0 { + return nil + } + + before := strings.TrimRight(content[:startIdx], "\n") + after := strings.TrimLeft(content[endIdx+len(markerEnd):], "\n") + + var result string + switch { + case before != "" && after != "": + result = before + "\n\n" + after + "\n" + case before != "": + result = before + "\n" + case after != "": + result = after + "\n" + } + + return os.WriteFile(path, []byte(result), 0o644) //nolint:gosec // non-sensitive markdown file +} diff --git a/internal/verda-cli/cmd/skills/uninstall_test.go b/internal/verda-cli/cmd/skills/uninstall_test.go new file mode 100644 index 0000000..1e84fc5 --- /dev/null +++ b/internal/verda-cli/cmd/skills/uninstall_test.go @@ -0,0 +1,99 @@ +package skills + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" + + cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" + + tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" +) + +func TestUninstallCopy(t *testing.T) { + t.Parallel() + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "verda-cloud.md"), []byte("test"), 0o600) + _ = os.WriteFile(filepath.Join(dir, "verda-reference.md"), []byte("test"), 0o600) + agent := &Agent{ + Name: "test-agent", Scope: "global", Method: "copy", + Target: dir, + } + if err := uninstallForAgent(agent, []string{"verda-cloud.md", "verda-reference.md"}); err != nil { + t.Fatalf("uninstall error: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "verda-cloud.md")); !os.IsNotExist(err) { + t.Fatal("expected verda-cloud.md to be deleted") + } + if _, err := os.Stat(filepath.Join(dir, "verda-reference.md")); !os.IsNotExist(err) { + t.Fatal("expected verda-reference.md to be deleted") + } +} + +func TestUninstallAppend(t *testing.T) { + t.Parallel() + dir := t.TempDir() + content := "# My Agents\n\nSome stuff\n\n" + markerStart + "\nskill content\n" + markerEnd + "\n\nMore stuff\n" + _ = os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte(content), 0o600) + agent := &Agent{ + Name: "codex", Scope: "project", Method: methodAppend, + Target: filepath.Join(dir, "AGENTS.md"), + } + if err := uninstallForAgent(agent, nil); err != nil { + t.Fatalf("uninstall error: %v", err) + } + data, _ := os.ReadFile(filepath.Clean(filepath.Join(dir, "AGENTS.md"))) + if bytes.Contains(data, []byte(markerStart)) { + t.Fatal("expected markers to be removed") + } + if !bytes.Contains(data, []byte("# My Agents")) { + t.Fatal("expected surrounding content to be preserved") + } + if !bytes.Contains(data, []byte("More stuff")) { + t.Fatal("expected trailing content to be preserved") + } +} + +func TestRunUninstall_NonInteractive(t *testing.T) { + t.Parallel() + targetDir := t.TempDir() + _ = os.WriteFile(filepath.Join(targetDir, "verda-cloud.md"), []byte("test"), 0o600) + _ = os.WriteFile(filepath.Join(targetDir, "verda-reference.md"), []byte("test"), 0o600) + statePath := filepath.Join(t.TempDir(), "skills.json") + _ = SaveState(statePath, &State{ + Version: "1.0.0", + InstalledAt: time.Now(), + Agents: []string{"claude-code"}, + }) + + mock := tuitest.New().AddConfirm(true) + f := cmdutil.NewTestFactory(mock) + var out bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &out, ErrOut: &out} + + opts := &uninstallOptions{ + agents: []string{"claude-code"}, + statePath: statePath, + skillNames: []string{"verda-cloud.md", "verda-reference.md"}, + agentOverrides: map[string]*Agent{ + "claude-code": { + Name: "claude-code", DisplayName: "Claude Code", + Scope: "global", Method: "copy", Target: targetDir, + }, + }, + } + + if err := runUninstall(context.Background(), f, ioStreams, opts); err != nil { + t.Fatalf("uninstall error: %v", err) + } + if _, err := os.Stat(filepath.Join(targetDir, "verda-cloud.md")); !os.IsNotExist(err) { + t.Fatal("expected files to be deleted") + } + state, _ := LoadState(statePath) + if state.HasAgent("claude-code") { + t.Fatal("expected claude-code removed from state") + } +} diff --git a/internal/verda-cli/cmd/update/update.go b/internal/verda-cli/cmd/update/update.go index 39f3499..87f21af 100644 --- a/internal/verda-cli/cmd/update/update.go +++ b/internal/verda-cli/cmd/update/update.go @@ -12,6 +12,7 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -20,6 +21,7 @@ import ( "github.com/spf13/cobra" "github.com/verda-cloud/verdagostack/pkg/version" + skillscmd "github/verda-cloud/verda-cli/internal/verda-cli/cmd/skills" cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github/verda-cloud/verda-cli/internal/verda-cli/options" ) @@ -164,6 +166,9 @@ func runUpdate(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStrea _, _ = fmt.Fprintf(ioStreams.Out, "Updated to %s\n", target) + // Update installed skills if any agents have them. + updateInstalledSkills(ctx, dst, ioStreams) + // Migrate: if the currently running binary is outside ~/.verda/bin/, // handle the old location based on how it was installed. oldExe, _ := resolveExecutable() @@ -422,3 +427,31 @@ func ghGet(ctx context.Context, url string, v any) error { } return json.NewDecoder(resp.Body).Decode(v) } + +// updateInstalledSkills re-installs skills for agents that already have them. +// It runs the NEW binary (at dst) so the latest embedded skills are used. +// Best-effort: failures are reported as warnings, not errors. +func updateInstalledSkills(ctx context.Context, newBinary string, ioStreams cmdutil.IOStreams) { + statePath, err := skillscmd.StatePath() + if err != nil { + return + } + state, err := skillscmd.LoadState(statePath) + if err != nil || state.Version == "" || len(state.Agents) == 0 { + return + } + + args := make([]string, 0, 4+len(state.Agents)) + args = append(args, "--agent", "skills", "install", "--force") + args = append(args, state.Agents...) + + cmd := exec.CommandContext(ctx, newBinary, args...) //nolint:gosec // newBinary is the just-installed verda binary + cmd.Stdout = ioStreams.Out + cmd.Stderr = ioStreams.ErrOut + + if err := cmd.Run(); err != nil { + _, _ = fmt.Fprintf(ioStreams.ErrOut, "Warning: could not update skills: %v\n", err) + _, _ = fmt.Fprintf(ioStreams.ErrOut, " Run 'verda skills install %s --force' manually.\n", + strings.Join(state.Agents, " ")) + } +} diff --git a/internal/verda-cli/cmd/vm/create.go b/internal/verda-cli/cmd/vm/create.go index 91a8bd0..a2bf4a2 100644 --- a/internal/verda-cli/cmd/vm/create.go +++ b/internal/verda-cli/cmd/vm/create.go @@ -175,7 +175,7 @@ func NewCmdCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command _ = flags.MarkHidden("ssh-key-id") _ = flags.MarkHidden("startup-script-id") _ = flags.MarkHidden("spot") - opts.Wait.AddFlags(flags, true) // --wait defaults to true for vm create + opts.Wait.AddFlags(flags, !f.AgentMode()) // agents should poll with vm describe instead of blocking return cmd }