From 4eb635fe9e6d0d62405f52783a30d44522d86833 Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:32:37 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20add=20'vf=20docs'=20=E2=80=94=20sea?= =?UTF-8?q?rch=20and=20read=20the=20documentation=20in-band?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vf docs search - full-text search via the public docs search endpoint; --output-format json for structured {title, link, page, content} results vf docs get - print any docs page as markdown, by path or full voiceflow.com/docs URL Zero bundled content (no staleness, no binary growth), zero auth — it works before any token is configured, so error hints and the quickstart can point at runnable 'vf docs' commands even in environments where the agent has no browser or web access. Lives in a non-generated file (internal/cli/docs.go); the only touch on generated code is the one-line registration in root.go, the same persisted-edit shape that carries the hooks registration. --- internal/cli/docs.go | 279 ++++++++++++++++++++++++++++++++++++++ internal/cli/root.go | 1 + test/docs-command.test.ts | 57 ++++++++ 3 files changed, 337 insertions(+) create mode 100644 internal/cli/docs.go create mode 100644 test/docs-command.test.ts diff --git a/internal/cli/docs.go b/internal/cli/docs.go new file mode 100644 index 0000000..b26d474 --- /dev/null +++ b/internal/cli/docs.go @@ -0,0 +1,279 @@ +// 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" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/voiceflow/cli/internal/flagutil" +) + +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 format, _ := flagutil.GetStringFlag(cmd, "output-format"); format == "json" { + 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. +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://") { + if !strings.Contains(page, "voiceflow.com/docs") { + return "", fmt.Errorf("only voiceflow.com/docs URLs are supported (got %s)", page) + } + if !strings.HasSuffix(page, ".md") { + page += ".md" + } + return page, nil + } + + page = strings.Trim(page, "/") + page = strings.TrimPrefix(page, "docs/") + if !strings.HasSuffix(page, ".md") { + page += ".md" + } + return docsBaseURL + "/" + page, 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..8059c01 --- /dev/null +++ b/test/docs-command.test.ts @@ -0,0 +1,57 @@ +// 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); + 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); + expect(byPath.stdout).toContain('# Personal access tokens'); + + 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'); + }); +}); From 0836a3cffc0e84d5299bff2d8064c66005772110 Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:36:21 -0400 Subject: [PATCH 2/3] fix: validate docs URLs by parsed components, close SSRF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-URL branch of 'vf docs get' guarded with strings.Contains(page, "voiceflow.com/docs"), so a hostile URL carrying that marker in its path, query, or fragment passed the check while the request went to any host — arbitrary-host fetch / SSRF, and the marker in a #fragment is never even sent on the wire. An agent fed a poisoned page argument would ingest attacker markdown as trusted docs. Now parse with net/url and validate the resolved components: https only, no userinfo, host in {www.voiceflow.com, voiceflow.com}, cleaned path under /docs; rebuild the URL from those components so nothing unvalidated reaches the wire. Fixes the sibling bug too — appending .md after a #fragment or ?query fetched HTML instead of markdown; .md now lands on the cleaned path. Bare paths run through path.Clean, neutralizing traversal. Also honor resolved output-format (config/env/agent-mode) via output.WantsRawJSON instead of the raw flag. --- internal/cli/docs.go | 42 +++++++++++++++++++++++++++++---------- test/docs-command.test.ts | 30 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/internal/cli/docs.go b/internal/cli/docs.go index b26d474..6cca47e 100644 --- a/internal/cli/docs.go +++ b/internal/cli/docs.go @@ -14,11 +14,13 @@ import ( "fmt" "io" "net/http" + "net/url" + "path" "strings" "time" "github.com/spf13/cobra" - "github.com/voiceflow/cli/internal/flagutil" + "github.com/voiceflow/cli/internal/output" ) const ( @@ -119,7 +121,7 @@ func runDocsSearchCmd(cmd *cobra.Command, args []string) error { return nil } - if format, _ := flagutil.GetStringFlag(cmd, "output-format"); format == "json" { + if output.WantsRawJSON(cmd) { results := make([]docsSearchResult, 0, len(blocks)) for _, block := range blocks { results = append(results, parseDocsSearchBlock(block)) @@ -164,7 +166,9 @@ func runDocsGetCmd(cmd *cobra.Command, args []string) error { } // docsPageURL normalizes a page argument (path or full URL) to its markdown -// rendition URL. +// 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 == "" { @@ -172,21 +176,37 @@ func docsPageURL(page string) (string, error) { } if strings.HasPrefix(page, "http://") || strings.HasPrefix(page, "https://") { - if !strings.Contains(page, "voiceflow.com/docs") { - return "", fmt.Errorf("only voiceflow.com/docs URLs are supported (got %s)", page) + 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) } - if !strings.HasSuffix(page, ".md") { - page += ".md" + 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) } - return page, nil + 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/") - if !strings.HasSuffix(page, ".md") { - page += ".md" + 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 + "/" + page, nil + return docsBaseURL + cleanPath, nil } // doDocsRequest performs an HTTP request against the docs site with a bounded diff --git a/test/docs-command.test.ts b/test/docs-command.test.ts index 8059c01..9d99e5b 100644 --- a/test/docs-command.test.ts +++ b/test/docs-command.test.ts @@ -54,4 +54,34 @@ describe('vf docs get', () => { 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).toContain('# Personal access tokens'); // markdown, not the ~400KB HTML page + expect(result.stdout).not.toContain(''); + }); + + 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:'); + }); }); From 7fbf4be0f850d0db4d82925c28ff9179c6d3ab83 Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:29:11 -0400 Subject: [PATCH 3/3] test: assert docs shape, not live docs prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: two assertions coupled the suite to content that the docs team can change at any time — an exact H1 string, and results[0] existing without checking the array was non-empty. Assert the rendition is markdown rather than HTML, cap its size, and fail with a clear message when a staple query returns nothing. --- test/docs-command.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/docs-command.test.ts b/test/docs-command.test.ts index 9d99e5b..98e975b 100644 --- a/test/docs-command.test.ts +++ b/test/docs-command.test.ts @@ -23,6 +23,9 @@ describe('vf docs search', () => { 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'), @@ -36,7 +39,10 @@ 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); - expect(byPath.stdout).toContain('# Personal access tokens'); + // 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); @@ -75,8 +81,9 @@ describe('vf docs get', () => { 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).toContain('# Personal access tokens'); // markdown, not the ~400KB HTML page + 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 () => {