Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 299 additions & 0 deletions internal/cli/docs.go
Original file line number Diff line number Diff line change
@@ -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 <query>",
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 <page>`,
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 <page>",
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 <page>")
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 <query>)")
}

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 <query>", 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
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
Loading