diff --git a/internal/cli/docs.go b/internal/cli/docs.go new file mode 100644 index 0000000..6cca47e --- /dev/null +++ b/internal/cli/docs.go @@ -0,0 +1,299 @@ +// This file is not generated by Speakeasy — it adds the `vf docs` command +// group: in-band access to the Voiceflow documentation so humans and AI +// coding agents can look things up mid-task without leaving the terminal. +// +// Zero bundled content: `search` calls the public docs search endpoint and +// `get` fetches a page's markdown rendition. No authentication is required +// or sent. + +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/voiceflow/cli/internal/output" +) + +const ( + docsBaseURL = "https://www.voiceflow.com/docs" + docsSearchMCPURL = "https://www.voiceflow.com/docs/mcp" + docsSearchTool = "search_voiceflow_documentation" + docsClientTimeout = 15 * time.Second +) + +// initDocsCmd registers the docs command group. +func initDocsCmd(parent *cobra.Command) { + docsCmd := &cobra.Command{ + Use: "docs", + Short: "Search and read the Voiceflow documentation from the terminal", + Long: `Search and read the Voiceflow documentation without leaving the terminal. + +No authentication is required — these commands work before any token is set up. + +Subcommands: + search - Full-text search across the documentation + get - Print one documentation page as markdown`, + } + parent.AddCommand(docsCmd) + + docsCmd.AddCommand(&cobra.Command{ + Use: "search ", + Short: "Search the Voiceflow documentation", + Long: `Search the Voiceflow documentation and print matching pages with their +titles, links, and content excerpts. + +Pass --output-format json for structured results ({title, link, page, content}). +Fetch any result in full with: vf docs get `, + Example: ` vf docs search "personal access token" + vf docs search "publish an environment" --output-format json`, + Args: cobra.MinimumNArgs(1), + RunE: runDocsSearchCmd, + }) + + docsCmd.AddCommand(&cobra.Command{ + Use: "get ", + Short: "Print a documentation page as markdown", + Long: `Fetch one documentation page and print its markdown to stdout. + +Accepts a page path (from 'vf docs search' output) or a full +voiceflow.com/docs URL.`, + Example: ` vf docs get api-reference/authentication + vf docs get cli/commands/workspace + vf docs get https://www.voiceflow.com/docs/cli/overview`, + Args: cobra.ExactArgs(1), + RunE: runDocsGetCmd, + }) +} + +// docsSearchResult is one parsed search hit. +type docsSearchResult struct { + Title string `json:"title"` + Link string `json:"link"` + Page string `json:"page"` + Content string `json:"content"` +} + +// runDocsSearchCmd executes docs search via the public documentation search +// endpoint (a stateless JSON-RPC tool call). +func runDocsSearchCmd(cmd *cobra.Command, args []string) error { + query := strings.Join(args, " ") + + request, err := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]interface{}{ + "name": docsSearchTool, + "arguments": map[string]string{"query": query}, + }, + }) + if err != nil { + return err + } + + httpRequest, err := http.NewRequestWithContext(cmd.Context(), http.MethodPost, docsSearchMCPURL, bytes.NewReader(request)) + if err != nil { + return err + } + httpRequest.Header.Set("Content-Type", "application/json") + httpRequest.Header.Set("Accept", "application/json, text/event-stream") + + body, err := doDocsRequest(httpRequest) + if err != nil { + return err + } + + blocks, err := parseDocsSearchResponse(body) + if err != nil { + return err + } + if len(blocks) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No documentation matches for %q. Browse everything at %s\n", query, docsBaseURL) + return nil + } + + if output.WantsRawJSON(cmd) { + results := make([]docsSearchResult, 0, len(blocks)) + for _, block := range blocks { + results = append(results, parseDocsSearchBlock(block)) + } + encoded, err := json.MarshalIndent(results, "", " ") + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), string(encoded)) + return nil + } + + out := cmd.OutOrStdout() + for i, block := range blocks { + if i > 0 { + fmt.Fprint(out, "\n---\n\n") + } + fmt.Fprintln(out, strings.TrimSpace(block)) + } + fmt.Fprintln(out, "\nRead a full page with: vf docs get ") + return nil +} + +// runDocsGetCmd fetches one page's markdown rendition and prints it. +func runDocsGetCmd(cmd *cobra.Command, args []string) error { + pageURL, err := docsPageURL(args[0]) + if err != nil { + return err + } + + httpRequest, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, pageURL, nil) + if err != nil { + return err + } + + body, err := doDocsRequest(httpRequest) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), strings.TrimSpace(string(body))) + return nil +} + +// docsPageURL normalizes a page argument (path or full URL) to its markdown +// rendition URL. Full URLs are validated by their PARSED components — scheme, +// host, and path — never by substring matching, and the returned URL is +// rebuilt from those components so nothing unvalidated reaches the wire. +func docsPageURL(page string) (string, error) { + page = strings.TrimSpace(page) + if page == "" { + return "", fmt.Errorf("empty page — pass a path like api-reference/authentication (find pages with: vf docs search )") + } + + if strings.HasPrefix(page, "http://") || strings.HasPrefix(page, "https://") { + parsed, err := url.Parse(page) + if err != nil { + return "", fmt.Errorf("invalid URL %q: %w", page, err) + } + hostname := strings.ToLower(parsed.Hostname()) + if parsed.Scheme != "https" || parsed.User != nil || + (hostname != "www.voiceflow.com" && hostname != "voiceflow.com") { + return "", fmt.Errorf("only https://www.voiceflow.com/docs URLs are supported (got %s)", page) + } + cleanPath := path.Clean("/" + parsed.EscapedPath()) + if cleanPath != "/docs" && !strings.HasPrefix(cleanPath, "/docs/") { + return "", fmt.Errorf("only https://www.voiceflow.com/docs URLs are supported (got %s)", page) + } + if !strings.HasSuffix(cleanPath, ".md") { + cleanPath += ".md" + } + // Query and fragment carry nothing for a docs page; drop them so the + // .md suffix always lands on the path. + return "https://www.voiceflow.com" + cleanPath, nil + } + + page = strings.Trim(page, "/") + page = strings.TrimPrefix(page, "docs/") + cleanPath := path.Clean("/" + page) + if cleanPath == "/" || strings.HasPrefix(cleanPath, "/..") { + return "", fmt.Errorf("invalid page path %q — pass a path like api-reference/authentication", page) + } + if !strings.HasSuffix(cleanPath, ".md") { + cleanPath += ".md" + } + return docsBaseURL + cleanPath, nil +} + +// doDocsRequest performs an HTTP request against the docs site with a bounded +// timeout, returning the response body or an error that names the fix. +func doDocsRequest(request *http.Request) ([]byte, error) { + client := &http.Client{Timeout: docsClientTimeout} + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("could not reach the Voiceflow documentation (%w) — check network connectivity; the docs are also at %s", err, docsBaseURL) + } + defer response.Body.Close() + + body, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) + if err != nil { + return nil, fmt.Errorf("could not read the documentation response: %w", err) + } + if response.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("documentation page not found (%s) — find valid pages with: vf docs search ", request.URL) + } + if response.StatusCode >= 400 { + return nil, fmt.Errorf("the documentation site returned HTTP %d — this is usually transient; the docs are also at %s", response.StatusCode, docsBaseURL) + } + return body, nil +} + +// parseDocsSearchResponse extracts the text blocks from a JSON-RPC tools/call +// response, handling both plain JSON and SSE-framed ("data: {...}") bodies. +func parseDocsSearchResponse(body []byte) ([]string, error) { + payload := body + if !bytes.HasPrefix(bytes.TrimSpace(body), []byte("{")) { + // SSE framing: use the first "data:" line. + payload = nil + for _, line := range bytes.Split(body, []byte("\n")) { + if data, found := bytes.CutPrefix(bytes.TrimSpace(line), []byte("data:")); found { + payload = bytes.TrimSpace(data) + break + } + } + if payload == nil { + return nil, fmt.Errorf("unexpected response from the documentation search endpoint") + } + } + + var response struct { + Result struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(payload, &response); err != nil { + return nil, fmt.Errorf("unexpected response from the documentation search endpoint: %w", err) + } + if response.Error != nil { + return nil, fmt.Errorf("documentation search failed: %s", response.Error.Message) + } + + blocks := make([]string, 0, len(response.Result.Content)) + for _, content := range response.Result.Content { + if content.Type == "text" && strings.TrimSpace(content.Text) != "" { + blocks = append(blocks, content.Text) + } + } + return blocks, nil +} + +// parseDocsSearchBlock splits one "Title:/Link:/Page:/Content:" text block +// into a structured result. +func parseDocsSearchBlock(block string) docsSearchResult { + result := docsSearchResult{} + lines := strings.Split(block, "\n") + for i, line := range lines { + switch { + case strings.HasPrefix(line, "Title: "): + result.Title = strings.TrimPrefix(line, "Title: ") + case strings.HasPrefix(line, "Link: "): + result.Link = strings.TrimPrefix(line, "Link: ") + case strings.HasPrefix(line, "Page: "): + result.Page = strings.TrimPrefix(line, "Page: ") + case strings.HasPrefix(line, "Content: "): + result.Content = strings.TrimSpace(strings.TrimPrefix(strings.Join(lines[i:], "\n"), "Content: ")) + return result + } + } + return result +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 1f6148c..fc1297e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -132,6 +132,7 @@ func NewRootCommand() (*cobra.Command, error) { return nil, fmt.Errorf("init auth: %w", err) } initExploreCmd(rootCmd) + initDocsCmd(rootCmd) // Global output format flag rootCmd.PersistentFlags().StringP("output-format", "o", "pretty", "Specify the output format. Options: pretty, json, yaml, table, toon.") diff --git a/test/docs-command.test.ts b/test/docs-command.test.ts new file mode 100644 index 0000000..98e975b --- /dev/null +++ b/test/docs-command.test.ts @@ -0,0 +1,94 @@ +// Tests for `vf docs` (internal/cli/docs.go). These hit the live public +// documentation site — no credentials involved. +// +// Requires the CLI binary at the repo root: go build -o vf ./cmd/vf + +import { execa } from 'execa'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const VF = path.resolve(__dirname, '..', 'vf'); +const $vf = (args: string[]) => execa({ reject: false, stdin: 'ignore' })(VF, args); + +describe('vf docs search', () => { + it('finds the authentication page and prints follow-up guidance', async () => { + const result = await $vf(['docs', 'search', 'personal access token']); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain('api-reference/authentication'); + expect(result.stdout).toContain('vf docs get'); + }); + + it('emits structured results with --output-format json', async () => { + const result = await $vf(['docs', 'search', 'publish environment', '--output-format', 'json']); + expect(result.exitCode, result.stderr).toBe(0); + const results = JSON.parse(result.stdout); + expect(Array.isArray(results)).toBe(true); + // Assert on shape only if the live corpus returned something: an empty + // result set is a docs-content change, not a CLI regression. + expect(results.length, 'search returned no results for a staple query').toBeGreaterThan(0); + expect(results[0]).toMatchObject({ + title: expect.any(String), + link: expect.stringContaining('voiceflow.com/docs'), + page: expect.any(String), + content: expect.any(String), + }); + }); +}); + +describe('vf docs get', () => { + it('prints a page as markdown, by path or full URL', async () => { + const byPath = await $vf(['docs', 'get', 'api-reference/authentication']); + expect(byPath.exitCode, byPath.stderr).toBe(0); + // Assert the rendition is markdown, not the page's exact prose: the + // heading is docs content and may be reworded at any time. + expect(byPath.stdout).toMatch(/^#{1,2} \S/m); + expect(byPath.stdout).not.toContain(''); + + const byURL = await $vf(['docs', 'get', 'https://www.voiceflow.com/docs/cli/overview']); + expect(byURL.exitCode, byURL.stderr).toBe(0); + expect(byURL.stdout.length).toBeGreaterThan(200); + }); + + it('teaches on a missing page', async () => { + const result = await $vf(['docs', 'get', 'not/a/real/page']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('vf docs search'); + }); + + it('refuses non-voiceflow URLs', async () => { + const result = await $vf(['docs', 'get', 'https://example.com/docs/page']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('voiceflow.com/docs'); + }); + + it('blocks every host-spoofing / SSRF vector (validates parsed components, not substrings)', async () => { + const hostile = [ + 'http://127.0.0.1:8799/voiceflow.com/docs', // marker in path + 'http://127.0.0.1:8799/x?ref=voiceflow.com/docs', // marker in query + 'http://127.0.0.1:8799/secret#voiceflow.com/docs', // marker in fragment (never on the wire) + 'https://evil.com/voiceflow.com/docs/x', // marker in path, hostile host + 'https://www.voiceflow.com.evil.com/docs/x', // look-alike host + 'https://user@www.voiceflow.com/docs/x', // userinfo trick + 'http://www.voiceflow.com/docs/x', // http downgrade + ]; + for (const url of hostile) { + const result = await $vf(['docs', 'get', url]); + expect(result.exitCode, `must block: ${url}`).toBe(1); + expect(result.stderr, `must not fetch: ${url}`).not.toContain('GOTCHA'); + } + }); + + it('fetches the .md rendition of a full URL carrying a #fragment', async () => { + const result = await $vf(['docs', 'get', 'https://www.voiceflow.com/docs/api-reference/authentication#create-a-token']); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toMatch(/^#{1,2} \S/m); // markdown, not the ~400KB HTML page + expect(result.stdout).not.toContain(''); + expect(result.stdout.length).toBeLessThan(100_000); + }); + + it('neutralizes path traversal in a bare page path', async () => { + const result = await $vf(['docs', 'get', '../../../etc/passwd']); + expect(result.exitCode).toBe(1); + expect(result.stdout).not.toContain('root:'); + }); +});