diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..b76ef65 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "built-fast", + "owner": { + "name": "BuiltFast", + "email": "support@builtfast.com" + }, + "plugins": [ + { + "name": "vector", + "source": "./", + "description": "Vector Pro integration for Claude Code: manage sites, environments, deployments, backups, WAF, and SSL via the vector CLI." + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..7a2032c --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "vector", + "version": "0.8.0", + "description": "Vector Pro integration for Claude Code. Manage sites, environments, deployments, backups, WAF, and SSL via the vector CLI, plus a /vector:doctor health check.", + "author": { + "name": "BuiltFast", + "email": "support@builtfast.com" + }, + "homepage": "https://github.com/built-fast/vector-cli", + "repository": "https://github.com/built-fast/vector-cli", + "license": "MIT" +} diff --git a/.surface b/.surface index 1f9c8d5..8ac7099 100644 --- a/.surface +++ b/.surface @@ -124,6 +124,7 @@ CMD vector deploy list CMD vector deploy rollback CMD vector deploy show CMD vector deploy trigger +CMD vector doctor CMD vector env CMD vector env create CMD vector env db diff --git a/README.md b/README.md index 6d332a2..16a6fdc 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,18 @@ vector webhook delete vector php-versions ``` +### Doctor + +Diagnose CLI setup, authentication, and live API connectivity: + +```bash +vector doctor # table of checks (cli / auth / api) +vector doctor --json # machine-readable; backs /vector:doctor +``` + +`doctor` always exits 0 — read each check's status (`pass`/`warn`/`skip`/`fail`) +rather than the exit code. + ### MCP Integration Configure [Claude Desktop](https://claude.ai/download) to use Vector CLI as an MCP server: @@ -314,6 +326,25 @@ Configure [Claude Desktop](https://claude.ai/download) to use Vector CLI as an M vector mcp setup ``` +### Claude Code Plugin + +Install the Vector plugin to give Claude Code the bundled skill and a +`/vector:doctor` health-check command. Inside Claude Code: + +```text +/plugin marketplace add built-fast/vector-cli +/plugin install vector@built-fast +``` + +The plugin reuses the same `SKILL.md` reference that `vector skill install` +provides — install it via the plugin or the standalone command, not both. For +other agents, point them directly at the embedded skill: + +```bash +vector skill # print SKILL.md to stdout +vector skill install # install to ~/.agents/skills + ~/.claude/skills +``` + ## Output Format - **Interactive (TTY)**: Human-readable table format diff --git a/commands/doctor.md b/commands/doctor.md new file mode 100644 index 0000000..307ac4c --- /dev/null +++ b/commands/doctor.md @@ -0,0 +1,31 @@ +--- +description: Check Vector CLI health — binary, authentication, and live API connectivity. +allowed-tools: Bash(vector doctor:*) +--- + +# /vector:doctor + +Run the Vector CLI health check and report the results. + +```bash +vector doctor --json +``` + +The JSON output has an `ok` boolean and a `checks` array. Each check has a +`name`, a `status`, a `detail`, and an optional `hint`. Interpret the status: + +- **pass** — working correctly +- **warn** — non-critical issue +- **skip** — check not run (e.g. unauthenticated) +- **fail** — broken, needs attention + +The command always exits 0; rely on the `status` fields, not the exit code. + +Common fixes (follow the `hint` field first): + +- `auth` fail → run `vector auth login`, or set `VECTOR_API_KEY` +- `api` fail with "rejected" → token is invalid or expired; run `vector auth login` +- `api` fail with "network error" → check network/VPN connectivity + +Report results concisely: list any failures and warnings with their hints. If +everything passes, say so in one line. diff --git a/internal/cli/root.go b/internal/cli/root.go index 222500c..d802b5d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -114,6 +114,7 @@ func NewRootCmd() *cobra.Command { cmd.PersistentFlags().String("jq", "", `Filter JSON output with a jq expression (built-in, no external jq required)`) cmd.AddCommand(commands.NewAuthCmd()) + cmd.AddCommand(commands.NewDoctorCmd()) cmd.AddCommand(commands.NewSiteCmd()) cmd.AddCommand(commands.NewEnvCmd()) cmd.AddCommand(commands.NewDeployCmd()) diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go new file mode 100644 index 0000000..ee550ca --- /dev/null +++ b/internal/commands/doctor.go @@ -0,0 +1,207 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/built-fast/vector-cli/internal/api" + "github.com/built-fast/vector-cli/internal/appctx" + "github.com/built-fast/vector-cli/internal/output" + "github.com/built-fast/vector-cli/internal/version" +) + +// Doctor check statuses, mirrored in the /vector:doctor plugin command. +const ( + doctorPass = "pass" // working correctly + doctorWarn = "warn" // non-critical issue + doctorSkip = "skip" // check not run (e.g. unauthenticated) + doctorFail = "fail" // broken, needs attention +) + +// doctorCheck is a single diagnostic result. +type doctorCheck struct { + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail"` + Hint string `json:"hint,omitempty"` +} + +// NewDoctorCmd creates the doctor command. +func NewDoctorCmd() *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Diagnose CLI setup, authentication, and API connectivity", + Long: "Run health checks on the Vector CLI: binary version, configured " + + "authentication, and live API connectivity. Backs the Claude Code " + + "/vector:doctor command and is useful for troubleshooting. Always exits 0 " + + "on a successful run; health is reported in the status of each check.", + Example: ` # Run all health checks + vector doctor + + # Machine-readable output (used by /vector:doctor) + vector doctor --json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDoctor(cmd) + }, + } +} + +// runDoctor gathers the health checks and renders them. It does not use +// requireApp: diagnosing the unauthenticated state is part of its job. +func runDoctor(cmd *cobra.Command) error { + app := appctx.FromContext(cmd.Context()) + if app == nil { + return fmt.Errorf("app not initialized") + } + + auth := doctorAuthCheck(app) + checks := []doctorCheck{ + doctorCLICheck(), + auth, + doctorAPICheck(cmd, app, auth.Status == doctorPass), + } + + ok := true + for _, c := range checks { + if c.Status == doctorFail { + ok = false + } + } + + if app.Output.Format() == output.JSON { + return app.Output.JSON(map[string]any{ + "ok": ok, + "api_url": app.Config.APIURL, + "checks": checks, + }) + } + + rows := make([][]string, 0, len(checks)) + for _, c := range checks { + rows = append(rows, []string{c.Name, doctorStatusLabel(c.Status), c.Detail}) + } + app.Output.Table([]string{"CHECK", "STATUS", "DETAIL"}, rows) + + for _, c := range checks { + if c.Hint != "" { + app.Output.Message(fmt.Sprintf("→ %s: %s", c.Name, c.Hint)) + } + } + + return nil +} + +// doctorCLICheck reports the running binary version; it never fails. +func doctorCLICheck() doctorCheck { + return doctorCheck{ + Name: "cli", + Status: doctorPass, + Detail: version.FullVersion(), + } +} + +// doctorAuthCheck verifies a token is configured, without contacting the API. +func doctorAuthCheck(app *appctx.App) doctorCheck { + if app.Client.Token == "" { + return doctorCheck{ + Name: "auth", + Status: doctorFail, + Detail: "no API token configured", + Hint: "run 'vector auth login', pass --token, or set VECTOR_API_KEY", + } + } + + source := app.TokenSource + if source == "" { + source = "unknown source" + } + return doctorCheck{ + Name: "auth", + Status: doctorPass, + Detail: "token from " + source, + } +} + +// doctorAPICheck validates the token against the live API. It is skipped when +// no token is configured (the auth check already reported that). +func doctorAPICheck(cmd *cobra.Command, app *appctx.App, haveToken bool) doctorCheck { + if !haveToken { + return doctorCheck{ + Name: "api", + Status: doctorSkip, + Detail: "skipped (not authenticated)", + } + } + + resp, err := app.Client.Get(cmd.Context(), "/api/v1/auth/whoami", nil) + if err != nil { + var apiErr *api.APIError + if errors.As(err, &apiErr) { + if apiErr.HTTPStatus == 401 || apiErr.HTTPStatus == 403 { + return doctorCheck{ + Name: "api", + Status: doctorFail, + Detail: "token rejected (invalid or expired)", + Hint: "run 'vector auth login' to re-authenticate", + } + } + return doctorCheck{ + Name: "api", + Status: doctorFail, + Detail: fmt.Sprintf("API error: %s", apiErr.Message), + Hint: "check the Vector status page and try again", + } + } + return doctorCheck{ + Name: "api", + Status: doctorFail, + Detail: fmt.Sprintf("network error: %s", err), + Hint: "check your network connection or VPN", + } + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return doctorCheck{ + Name: "api", + Status: doctorFail, + Detail: fmt.Sprintf("reading response: %s", err), + } + } + + var whoami whoamiResponse + if err := json.Unmarshal(body, &whoami); err != nil { + return doctorCheck{ + Name: "api", + Status: doctorWarn, + Detail: "connected, but the response was not recognized", + } + } + + return doctorCheck{ + Name: "api", + Status: doctorPass, + Detail: fmt.Sprintf("authenticated as %s (%s)", whoami.Data.User.Email, whoami.Data.Account.Name), + } +} + +// doctorStatusLabel renders a status for table output. +func doctorStatusLabel(status string) string { + switch status { + case doctorPass: + return "OK" + case doctorWarn: + return "WARN" + case doctorSkip: + return "SKIP" + case doctorFail: + return "FAIL" + default: + return status + } +} diff --git a/internal/commands/doctor_test.go b/internal/commands/doctor_test.go new file mode 100644 index 0000000..ea54aa6 --- /dev/null +++ b/internal/commands/doctor_test.go @@ -0,0 +1,148 @@ +package commands + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/built-fast/vector-cli/internal/api" + "github.com/built-fast/vector-cli/internal/appctx" + "github.com/built-fast/vector-cli/internal/config" + "github.com/built-fast/vector-cli/internal/output" +) + +// buildDoctorCmd wires a root + doctor command with an App context. baseURL +// points the client at a test server; tokenSource mirrors how the real root +// resolves the token (flag/env/keyring) so the auth check can report it. +func buildDoctorCmd(baseURL, token, tokenSource string, format output.Format) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + root := &cobra.Command{ + Use: "vector", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + cfg := config.DefaultConfig() + cfg.APIURL = baseURL + client := api.NewClient(baseURL, token, "test-agent") + app := appctx.NewApp(cfg, client, tokenSource) + app.Output = output.NewWriter(stdout, format) + cmd.SetContext(appctx.WithApp(cmd.Context(), app)) + return nil + }, + SilenceUsage: true, + SilenceErrors: true, + } + + root.AddCommand(NewDoctorCmd()) + root.SetOut(stdout) + root.SetErr(stderr) + + return root, stdout, stderr +} + +func TestDoctorCmd_AllHealthy(t *testing.T) { + srv := newTestServer("good-token") + defer srv.Close() + + cmd, stdout, _ := buildDoctorCmd(srv.URL, "good-token", "keyring", output.Table) + cmd.SetArgs([]string{"doctor"}) + + require.NoError(t, cmd.Execute()) + + out := stdout.String() + assert.Contains(t, out, "OK") + assert.Contains(t, out, "token from keyring") + assert.Contains(t, out, "authenticated as john@example.com (Acme Inc)") + assert.NotContains(t, out, "FAIL") +} + +func TestDoctorCmd_AllHealthyJSON(t *testing.T) { + srv := newTestServer("good-token") + defer srv.Close() + + cmd, stdout, _ := buildDoctorCmd(srv.URL, "good-token", "env", output.JSON) + cmd.SetArgs([]string{"doctor"}) + + require.NoError(t, cmd.Execute()) + + var result struct { + OK bool `json:"ok"` + APIURL string `json:"api_url"` + Checks []doctorCheck `json:"checks"` + } + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + + assert.True(t, result.OK) + assert.Equal(t, srv.URL, result.APIURL) + require.Len(t, result.Checks, 3) + + byName := map[string]doctorCheck{} + for _, c := range result.Checks { + byName[c.Name] = c + } + assert.Equal(t, doctorPass, byName["cli"].Status) + assert.Equal(t, doctorPass, byName["auth"].Status) + assert.Equal(t, doctorPass, byName["api"].Status) +} + +func TestDoctorCmd_NoToken(t *testing.T) { + cmd, stdout, _ := buildDoctorCmd("http://localhost", "", "", output.JSON) + cmd.SetArgs([]string{"doctor"}) + + // Doctor reports health via status, never errors on a successful run. + require.NoError(t, cmd.Execute()) + + var result struct { + OK bool `json:"ok"` + Checks []doctorCheck `json:"checks"` + } + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + + assert.False(t, result.OK) + + byName := map[string]doctorCheck{} + for _, c := range result.Checks { + byName[c.Name] = c + } + assert.Equal(t, doctorFail, byName["auth"].Status) + assert.NotEmpty(t, byName["auth"].Hint) + assert.Equal(t, doctorSkip, byName["api"].Status) +} + +func TestDoctorCmd_InvalidToken(t *testing.T) { + srv := newTestServer("good-token") + defer srv.Close() + + cmd, stdout, _ := buildDoctorCmd(srv.URL, "wrong-token", "flag", output.JSON) + cmd.SetArgs([]string{"doctor"}) + + require.NoError(t, cmd.Execute()) + + var result struct { + OK bool `json:"ok"` + Checks []doctorCheck `json:"checks"` + } + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result)) + + assert.False(t, result.OK) + + byName := map[string]doctorCheck{} + for _, c := range result.Checks { + byName[c.Name] = c + } + // Token is present, so auth passes; the API rejects it. + assert.Equal(t, doctorPass, byName["auth"].Status) + assert.Equal(t, doctorFail, byName["api"].Status) + assert.Contains(t, byName["api"].Detail, "rejected") +} + +func TestDoctorCmd_HelpText(t *testing.T) { + cmd, stdout, _ := buildDoctorCmd("http://localhost", "", "", output.Table) + cmd.SetArgs([]string{"doctor", "--help"}) + + require.NoError(t, cmd.Execute()) + assert.Contains(t, stdout.String(), "health checks") +} diff --git a/man/man1/vector.1 b/man/man1/vector.1 index 16b144e..d7932e5 100644 --- a/man/man1/vector.1 +++ b/man/man1/vector.1 @@ -55,6 +55,16 @@ Log out and clear stored credentials. .TP .B auth status Check the current authentication status. +.SS doctor \- Diagnose CLI setup +.TP +.B doctor +Run health checks on the CLI binary, configured authentication, and live API +connectivity. Backs the Claude Code +.B /vector:doctor +command. Always exits 0; each check reports a status of pass, warn, skip, or +fail. Use +.B \-\-json +for machine-readable output. .SS site \- Manage sites .TP .B site list \fR[\fB\-\-page\fR \fIN\fR] [\fB\-\-per\-page\fR \fIN\fR] diff --git a/skills/vector/SKILL.md b/skills/vector/SKILL.md index 92f9fbc..78249cf 100644 --- a/skills/vector/SKILL.md +++ b/skills/vector/SKILL.md @@ -126,6 +126,20 @@ Displays: user name/email, account name, token name, abilities, expiration, token source (flag/env/keyring), and config directory. Exits with code 2 if not authenticated. +### vector doctor + +Diagnose CLI setup, authentication, and live API connectivity. + +``` +vector doctor --json +``` + +Runs three checks (`cli`, `auth`, `api`), each reporting a `status` of `pass`, +`warn`, `skip`, or `fail` with a `detail` and optional `hint`. The JSON output +also carries a top-level `ok` boolean. Unlike other commands, `doctor` always +exits 0 — read the per-check `status`, not the exit code. This command backs the +Claude Code `/vector:doctor` plugin command. + --- ## Configuration