From 9bfc1c85500196172bfb003acd0e3787c413d3bc Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:01:03 -0400 Subject: [PATCH 1/5] feat: pin a project and environment to a directory with vf link (COR-14197) Every project command needs --project-id and --environment-alias, and nothing can set a default, so a coding agent starts every task with discovery (workspace list, then project list): extra turns, and a path into workspaces it has no business reading. vf link checks that the project and environment exist, then writes .voiceflow/project.json. Every command run in that directory, or below it, gets the linked project, environment and workspace unless the flag is passed. The root PersistentPreRunE sets the flags before cobra's required-flag checks run, so all generated commands pick the link up without per-command changes. An explicit flag always wins, and a link never fills in what project/environment/workspace delete would destroy. - vf unlink removes the link that applies here, including a damaged one - vf whoami shows the link in effect - vf link prints a snippet for the agent's instructions file - a Creator URL is refused with an explanation: its id is a version id, and the public API cannot map a version to its project --- README.md | 26 +++ docs/vf.md | 2 + docs/vf_link.md | 58 +++++++ docs/vf_unlink.md | 41 +++++ internal/cli/link.go | 348 +++++++++++++++++++++++++++++++++++++ internal/cli/root.go | 5 +- internal/cli/whoami.go | 1 + internal/link/link.go | 255 +++++++++++++++++++++++++++ internal/link/link_test.go | 279 +++++++++++++++++++++++++++++ internal/output/format.go | 14 ++ test/link.test.ts | 253 +++++++++++++++++++++++++++ 11 files changed, 1281 insertions(+), 1 deletion(-) create mode 100644 docs/vf_link.md create mode 100644 docs/vf_unlink.md create mode 100644 internal/cli/link.go create mode 100644 internal/link/link.go create mode 100644 internal/link/link_test.go create mode 100644 internal/output/format.go create mode 100644 test/link.test.ts diff --git a/README.md b/README.md index c11cce1e..68782680 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Realtime: Realtime gateway API service * [CLI Example Usage](#cli-example-usage) * [Authentication](#authentication) * [Browser sign-in (OAuth2)](#browser-sign-in-oauth2) + * [Link a project to a directory](#link-a-project-to-a-directory) * [Available Commands](#available-commands) * [Request Body Input](#request-body-input) * [Server Selection](#server-selection) @@ -283,6 +284,31 @@ set, the CLI registers itself as a public client through the authorization server's dynamic client registration endpoint and caches the resulting `client_id` for later logins. +## Link a project to a directory + +Every project command takes `--project-id` and `--environment-alias`. Link a +directory once and leave them out: + +```bash +vf link 6a67842584dac97c7626ebaa # environment "main" +vf link 6a67842584dac97c7626ebaa --environment-alias dev +``` + +`vf link` checks that the project and environment exist, then writes +`.voiceflow/project.json`: ids, the project name and the environment alias, +nothing secret. Commands run in that directory, or in any directory below it, +use the link, and an explicit flag always wins. `vf whoami` shows the link in +effect, and `vf unlink` removes it. + +- **Deletes always name their target.** A link never fills in the project, + environment or workspace that `project delete`, `environment delete` or + `workspace delete` would destroy. +- **The project id is in Creator**, under the agent's Settings → General + (Metadata). A Creator page URL will not do: the id in it is a version id. +- **For coding agents**, `vf link` also prints a short snippet for the agent's + instructions file (`CLAUDE.md`, `AGENTS.md`), so the agent knows the project + is linked before it runs its first command. + ## Available Commands diff --git a/docs/vf.md b/docs/vf.md index 6789184d..ab625c04 100644 --- a/docs/vf.md +++ b/docs/vf.md @@ -43,12 +43,14 @@ vf [flags] * [vf explore](vf_explore.md) - Interactively browse and run commands * [vf function](vf_function.md) - Operations for function * [vf knowledge-base](vf_knowledge-base.md) - Operations for knowledge-base +* [vf link](vf_link.md) - Pin a project and environment to this directory * [vf mcp-server](vf_mcp-server.md) - Operations for mcp-server * [vf mcp-tool](vf_mcp-tool.md) - Operations for mcp-tool * [vf playbook](vf_playbook.md) - Operations for playbook * [vf project](vf_project.md) - Operations for project * [vf tool](vf_tool.md) - Operations for tool * [vf transcript](vf_transcript.md) - Operations for transcript +* [vf unlink](vf_unlink.md) - Remove the project link that applies to this directory * [vf variable](vf_variable.md) - Operations for variable * [vf version](vf_version.md) - Print the CLI version * [vf whoami](vf_whoami.md) - Display current authentication configuration diff --git a/docs/vf_link.md b/docs/vf_link.md new file mode 100644 index 00000000..a3460c48 --- /dev/null +++ b/docs/vf_link.md @@ -0,0 +1,58 @@ +## vf link + +Pin a project and environment to this directory + +### Synopsis + +Pin a Voiceflow project and environment to the current directory. + +Every vf command run here, or in any directory below, then uses them by +default, so --project-id and --environment-alias can be left out. An explicit +flag always wins. Deleting a project, environment or workspace always needs +its flag: a link never fills in what a delete destroys. + +The link is .voiceflow/project.json. It holds ids, the project name and the +environment alias — nothing secret. Remove it with 'vf unlink'. + +The project id is in Creator under the agent's Settings → General (Metadata). +A Creator page URL will not do: the id in it is a version id. + +``` +vf link [flags] +``` + +### Examples + +``` + vf link 6a67842584dac97c7626ebaa + vf link 6a67842584dac97c7626ebaa --environment-alias dev +``` + +### Options + +``` + -e, --environment-alias string Environment to link (default "main") + -h, --help help for link +``` + +### Options inherited from parent commands + +``` + --agent-mode Enable structured errors and default TOON output for AI coding agents. Automatically enabled when a known agent environment is detected (CLAUDE_CODE, CURSOR_AGENT, etc.). Use --agent-mode=false to disable. + --color string Control colored output: auto (color when output is a TTY), always, or never. Respects NO_COLOR and FORCE_COLOR env vars. (default "auto") + -d, --debug Log request and response diagnostics to stderr + --dry-run Preview the request that would be sent without executing it (output to stderr) + -H, --header stringArray Set a custom HTTP request header (format: "Key: Value"). Can be specified multiple times. + --include-headers Include HTTP response headers in the output + -q, --jq string Filter and transform output using a jq expression (e.g., '.name', '.items[] | .id') + --no-interactive Disable all interactive features (auto-prompting, explorer auto-launch, TUI forms) + -o, --output-format string Specify the output format. Options: pretty, json, yaml, table, toon. (default "pretty") + --server-url string Override the default server URL + --timeout string HTTP request timeout (e.g., 30s, 5m, 100ms) + --token string Voiceflow bearer token + --usage Print the CLI Usage schema in KDL format +``` + +### SEE ALSO + +* [vf](vf.md) - Realtime: Realtime gateway API service diff --git a/docs/vf_unlink.md b/docs/vf_unlink.md new file mode 100644 index 00000000..40cae9d5 --- /dev/null +++ b/docs/vf_unlink.md @@ -0,0 +1,41 @@ +## vf unlink + +Remove the project link that applies to this directory + +### Synopsis + +Remove the .voiceflow/project.json that applies to the current directory — +the one here, or the nearest one above. Commands then need --project-id and +--environment-alias again. + +``` +vf unlink [flags] +``` + +### Options + +``` + -h, --help help for unlink +``` + +### Options inherited from parent commands + +``` + --agent-mode Enable structured errors and default TOON output for AI coding agents. Automatically enabled when a known agent environment is detected (CLAUDE_CODE, CURSOR_AGENT, etc.). Use --agent-mode=false to disable. + --color string Control colored output: auto (color when output is a TTY), always, or never. Respects NO_COLOR and FORCE_COLOR env vars. (default "auto") + -d, --debug Log request and response diagnostics to stderr + --dry-run Preview the request that would be sent without executing it (output to stderr) + -H, --header stringArray Set a custom HTTP request header (format: "Key: Value"). Can be specified multiple times. + --include-headers Include HTTP response headers in the output + -q, --jq string Filter and transform output using a jq expression (e.g., '.name', '.items[] | .id') + --no-interactive Disable all interactive features (auto-prompting, explorer auto-launch, TUI forms) + -o, --output-format string Specify the output format. Options: pretty, json, yaml, table, toon. (default "pretty") + --server-url string Override the default server URL + --timeout string HTTP request timeout (e.g., 30s, 5m, 100ms) + --token string Voiceflow bearer token + --usage Print the CLI Usage schema in KDL format +``` + +### SEE ALSO + +* [vf](vf.md) - Realtime: Realtime gateway API service diff --git a/internal/cli/link.go b/internal/cli/link.go new file mode 100644 index 00000000..d8293314 --- /dev/null +++ b/internal/cli/link.go @@ -0,0 +1,348 @@ +// This file is not generated by Speakeasy. It adds `vf link` and `vf unlink`, +// which pin a project and environment to a directory, and the hook that fills +// --project-id, --environment-alias and --workspace-id from that link for +// every other command. See internal/link. + +package cli + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/voiceflow/cli/internal/client" + "github.com/voiceflow/cli/internal/flagutil" + "github.com/voiceflow/cli/internal/link" + "github.com/voiceflow/cli/internal/output" + "github.com/voiceflow/cli/internal/sdk" + "github.com/voiceflow/cli/internal/sdk/models/operations" + "github.com/voiceflow/cli/internal/sdk/models/sdkerrors" +) + +// initLinkCmd registers `vf link` and `vf unlink`. +func initLinkCmd(parent *cobra.Command) { + linkCmd := &cobra.Command{ + Use: "link ", + Short: "Pin a project and environment to this directory", + Long: `Pin a Voiceflow project and environment to the current directory. + +Every vf command run here, or in any directory below, then uses them by +default, so --project-id and --environment-alias can be left out. An explicit +flag always wins. Deleting a project, environment or workspace always needs +its flag: a link never fills in what a delete destroys. + +The link is .voiceflow/project.json. It holds ids, the project name and the +environment alias — nothing secret. Remove it with 'vf unlink'. + +The project id is in Creator under the agent's Settings → General (Metadata). +A Creator page URL will not do: the id in it is a version id.`, + Example: ` vf link 6a67842584dac97c7626ebaa + vf link 6a67842584dac97c7626ebaa --environment-alias dev`, + Args: cobra.ExactArgs(1), + // vf link decides the defaults; it must not receive the old ones. + Annotations: map[string]string{link.SkipDefaultsAnnotation: "true"}, + RunE: runLinkCmd, + } + linkCmd.Flags().StringP("environment-alias", "e", link.DefaultEnvironmentAlias, "Environment to link") + parent.AddCommand(linkCmd) + + parent.AddCommand(&cobra.Command{ + Use: "unlink", + Short: "Remove the project link that applies to this directory", + Long: `Remove the .voiceflow/project.json that applies to the current directory — +the one here, or the nearest one above. Commands then need --project-id and +--environment-alias again.`, + Args: cobra.NoArgs, + RunE: runUnlinkCmd, + }) +} + +// linkResult wraps the summary for output.Result, which renders the first +// field of the value it is given. +type linkResult struct { + Link linkSummary `json:"link"` +} + +type linkSummary struct { + ProjectID string `json:"projectID"` + ProjectName string `json:"projectName"` + WorkspaceID string `json:"workspaceID"` + EnvironmentAlias string `json:"environmentAlias"` + File string `json:"file"` + ReplacedProjectID string `json:"replacedProjectID,omitempty"` + AgentInstructions string `json:"agentInstructions"` +} + +func runLinkCmd(cmd *cobra.Command, args []string) error { + projectID, err := link.ParseProjectID(args[0]) + if err != nil { + return linkError(cmd, "invalid_project_id", err.Error(), + "Copy the Project ID from the agent's Settings → General (Metadata) in Creator", + "Or list the projects in a workspace: vf project list --workspace-id ") + } + alias, _ := cmd.Flags().GetString("environment-alias") + alias = strings.TrimSpace(alias) + if alias == "" { + alias = link.DefaultEnvironmentAlias + } + + s, err := client.NewClient(cmd) + if err != nil { + return err + } + sdkOpts, err := output.PrepareCallOpts(cmd) + if err != nil { + return err + } + dryRun := client.IsDryRun(cmd) + if dryRun { + sdkOpts = append(sdkOpts, operations.WithSkipDeserialization()) + } + + projectRes, err := s.Project.Get(cmd.Context(), operations.StableProjectControllerGetRequest{ProjectID: projectID}, sdkOpts...) + if err != nil { + return output.Error(cmd, err) + } + _, err = s.Environment.Get(cmd.Context(), operations.StableEnvironmentControllerGetRequest{ + EnvironmentAlias: alias, + ProjectID: projectID, + }, sdkOpts...) + if err != nil { + if statusCode(err) == http.StatusNotFound { + return environmentNotFound(cmd, s.Environment, projectID, alias, sdkOpts) + } + return output.Error(cmd, err) + } + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("find the current directory: %w", err) + } + linkPath := filepath.Join(cwd, link.DirName, link.FileName) + if dryRun { + fmt.Fprintf(cmd.ErrOrStderr(), "[DRY-RUN] Would write %s\n", linkPath) + return nil + } + if projectRes == nil || projectRes.StableProjectResponse == nil { + return errors.New("the project lookup returned no project") + } + project := projectRes.StableProjectResponse.Project + + // Only a link in this very directory is replaced; one further up is + // merely shadowed, and stays in force for its other subdirectories. + var replaced string + if previous, err := link.Read(linkPath); err == nil && previous.ProjectID != project.ID { + replaced = previous.ProjectID + } + + saved := link.Link{ + ProjectID: project.ID, + ProjectName: project.Name, + WorkspaceID: project.WorkspaceID, + EnvironmentAlias: alias, + LinkedAt: time.Now().UTC().Truncate(time.Second), + } + path, err := link.Save(cwd, saved) + if err != nil { + return err + } + + summary := linkSummary{ + ProjectID: saved.ProjectID, + ProjectName: saved.ProjectName, + WorkspaceID: saved.WorkspaceID, + EnvironmentAlias: saved.EnvironmentAlias, + File: path, + ReplacedProjectID: replaced, + AgentInstructions: agentInstructions(saved), + } + if !wantsPlainText(cmd) { + return output.Result(cmd, linkResult{Link: summary}) + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Linked this directory to %q (%s), environment %q.\n", saved.ProjectName, saved.ProjectID, saved.EnvironmentAlias) + if replaced != "" { + fmt.Fprintf(out, "This replaces the link to project %s.\n", replaced) + } + fmt.Fprintln(out, "vf commands run here, or in any directory below, now use it by default.") + fmt.Fprintf(out, "Link file: %s (undo: vf unlink)\n\n", path) + fmt.Fprintln(out, "For AI coding agents, add this to your agent instructions file (CLAUDE.md, AGENTS.md):") + fmt.Fprintln(out) + for _, line := range strings.Split(summary.AgentInstructions, "\n") { + fmt.Fprintf(out, " %s\n", line) + } + return nil +} + +// agentInstructions is the snippet `vf link` prints for an agent's +// instructions file: what is linked, and the two rules agents otherwise learn +// by getting them wrong. +func agentInstructions(l link.Link) string { + return fmt.Sprintf(`This directory is linked to the Voiceflow project %q (%s), environment %q. vf commands run here use them by default, so leave out --project-id and --environment-alias. +- Changes take effect only after 'vf environment compile'. Test the draft with --version-param draft. +- Publishing ('vf environment publish') ships to real users. Ask before running it.`, + l.ProjectName, l.ProjectID, l.EnvironmentAlias) +} + +// environmentNotFound turns a 404 on the environment into an error that +// lists the aliases the project does have. +func environmentNotFound(cmd *cobra.Command, envs *sdk.Environment, projectID, alias string, sdkOpts []operations.Option) error { + hint := "List them with: vf environment list --project-id " + projectID + if res, err := envs.List(cmd.Context(), operations.StableEnvironmentControllerListRequest{ProjectID: projectID}, sdkOpts...); err == nil && + res != nil && res.StableEnvironmentListResponse != nil { + var aliases []string + for _, env := range res.StableEnvironmentListResponse.Environments { + aliases = append(aliases, env.Alias) + } + if len(aliases) > 0 { + hint = "This project's environments: " + strings.Join(aliases, ", ") + } + } + return linkError(cmd, "environment_not_found", + fmt.Sprintf("project %s has no environment %q", projectID, alias), + hint, "Pick one with: vf link "+projectID+" --environment-alias ") +} + +func runUnlinkCmd(cmd *cobra.Command, args []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("find the current directory: %w", err) + } + path, err := link.Locate(cwd) + if err != nil { + return err + } + if path == "" { + if !wantsPlainText(cmd) { + return output.Result(cmd, unlinkResult{Unlink: unlinkSummary{}}) + } + fmt.Fprintln(cmd.OutOrStdout(), "Nothing to unlink: no .voiceflow/project.json here or in any directory above.") + return nil + } + + // Read what is being removed for the message; a damaged link is still removed. + previous, _ := link.Read(path) + if err := link.Remove(path); err != nil { + return err + } + + summary := unlinkSummary{Removed: true, File: path} + if previous != nil { + summary.ProjectID, summary.ProjectName = previous.ProjectID, previous.ProjectName + } + if !wantsPlainText(cmd) { + return output.Result(cmd, unlinkResult{Unlink: summary}) + } + if previous != nil { + fmt.Fprintf(cmd.OutOrStdout(), "Unlinked %q (%s). Removed %s\n", previous.ProjectName, previous.ProjectID, path) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Removed %s\n", path) + } + return nil +} + +type unlinkResult struct { + Unlink unlinkSummary `json:"unlink"` +} + +type unlinkSummary struct { + Removed bool `json:"removed"` + ProjectID string `json:"projectID,omitempty"` + ProjectName string `json:"projectName,omitempty"` + File string `json:"file,omitempty"` +} + +// applyLinkDefaults fills --project-id, --environment-alias and +// --workspace-id from the link that applies to the working directory, for +// every command that takes them and was not given them. +func applyLinkDefaults(cmd *cobra.Command) error { + cwd, err := os.Getwd() + if err != nil { + // No working directory means no link; commands behave as they always have. + return nil + } + found, err := link.ApplyDefaults(cmd, cwd) + if err != nil { + return linkError(cmd, "invalid_link", err.Error(), + "Fix the file, or remove it with: vf unlink", + "Or pass --project-id and --environment-alias explicitly") + } + if found != nil { + if debug, _ := flagutil.GetBoolFlag(cmd, "debug"); debug { + fmt.Fprintf(cmd.ErrOrStderr(), "[DEBUG] Linked project %s, environment %q (from %s)\n", + found.ProjectID, found.EnvironmentAlias, found.Path) + } + } + return nil +} + +// writeLinkStatus prints the link block of 'vf whoami'. +func writeLinkStatus(w io.Writer) { + fmt.Fprintln(w) + fmt.Fprintln(w, "Linked project:") + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintln(w, " unknown (the current directory cannot be read)") + return + } + path, err := link.Locate(cwd) + if err != nil { + fmt.Fprintf(w, " unknown (%v)\n", err) + return + } + if path == "" { + fmt.Fprintln(w, " none (run 'vf link ')") + return + } + l, err := link.Read(path) + if err != nil { + fmt.Fprintf(w, " unreadable: %v\n", err) + return + } + fmt.Fprintf(w, " %-12s %s (%s)\n", "project", l.ProjectName, l.ProjectID) + fmt.Fprintf(w, " %-12s %s\n", "environment", l.EnvironmentAlias) + if l.WorkspaceID != "" { + fmt.Fprintf(w, " %-12s %s\n", "workspace", l.WorkspaceID) + } + fmt.Fprintf(w, " %-12s %s\n", "file", path) +} + +// wantsPlainText reports whether a hand-written command should print its own +// human-readable text rather than a structured result. +func wantsPlainText(cmd *cobra.Command) bool { + if jq, _ := flagutil.GetStringFlag(cmd, "jq"); jq != "" { + return false + } + return output.Format(cmd) == "pretty" +} + +// linkError reports a failure the way the rest of the CLI does: a structured +// envelope in agent mode, and the message plus its fixes otherwise. +func linkError(cmd *cobra.Command, errorType, message string, hints ...string) error { + if output.IsAgentMode() { + return output.AgentModeError(cmd, errorType, message, hints) + } + var b strings.Builder + b.WriteString(message) + for _, h := range hints { + b.WriteString("\n → ") + b.WriteString(h) + } + return errors.New(b.String()) +} + +// statusCode extracts the HTTP status from an SDK error, or 0. +func statusCode(err error) int { + var apiErr *sdkerrors.SDKDefaultError + if errors.As(err, &apiErr) { + return apiErr.StatusCode + } + return 0 +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f9122e46..0144eb97 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -66,7 +66,9 @@ func NewRootCommand() (*cobra.Command, error) { return err } output.InitAgentMode(cmd) - return nil + // Fill --project-id / --environment-alias / --workspace-id from + // .voiceflow/project.json; see link.go. + return applyLinkDefaults(cmd) }, } if err := agent.InitAgentRoot(rootCmd); err != nil { @@ -149,6 +151,7 @@ func NewRootCommand() (*cobra.Command, error) { } initExploreCmd(rootCmd) initDocsCmd(rootCmd) + initLinkCmd(rootCmd) // vf link / vf unlink; see link.go // Global output format flag rootCmd.PersistentFlags().StringP("output-format", "o", "pretty", "Specify the output format. Options: pretty, json, yaml, table, toon.") diff --git a/internal/cli/whoami.go b/internal/cli/whoami.go index 75fd0428..f74dd219 100644 --- a/internal/cli/whoami.go +++ b/internal/cli/whoami.go @@ -53,6 +53,7 @@ func runWhoamiCmd(cmd *cobra.Command, args []string) error { } oauth.WriteStatus(out) // browser login session; see internal/oauth + writeLinkStatus(out) // project pinned to this directory; see link.go return nil } diff --git a/internal/link/link.go b/internal/link/link.go new file mode 100644 index 00000000..056a1254 --- /dev/null +++ b/internal/link/link.go @@ -0,0 +1,255 @@ +// Package link pins a Voiceflow project and environment to a directory, so +// vf commands run inside it need no --project-id or --environment-alias. +// +// A link is a small JSON file, .voiceflow/project.json, found by walking up +// from the working directory the way git finds .git. Nothing in it is secret: +// two ids, a name, an environment alias and a timestamp. +package link + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" +) + +const ( + // DirName and FileName locate a link: /.voiceflow/project.json. + DirName = ".voiceflow" + FileName = "project.json" + + // DefaultEnvironmentAlias is the environment every project starts with. + DefaultEnvironmentAlias = "main" + + // SkipDefaultsAnnotation marks a command that must not receive linked + // defaults: `vf link` itself, which decides them. + SkipDefaultsAnnotation = "vf.link.skip-defaults" +) + +// Link is the pinned project, as stored in .voiceflow/project.json. +type Link struct { + ProjectID string `json:"projectID"` + ProjectName string `json:"projectName,omitempty"` + WorkspaceID string `json:"workspaceID,omitempty"` + EnvironmentAlias string `json:"environmentAlias"` + LinkedAt time.Time `json:"linkedAt"` +} + +// Found is a link together with the file it was read from. +type Found struct { + Link + Path string `json:"-"` +} + +// Voiceflow project ids are 24-character hex object ids. +var projectIDPattern = regexp.MustCompile(`^[0-9a-fA-F]{24}$`) + +// ParseProjectID validates a project id and normalizes it to lower case. +// +// A Creator URL is refused with an explanation rather than parsed: the id in +// /project// is a version id, and the public API cannot look up the +// project a version belongs to. +func ParseProjectID(ref string) (string, error) { + ref = strings.TrimSpace(ref) + if projectIDPattern.MatchString(ref) { + return strings.ToLower(ref), nil + } + if strings.Contains(ref, "://") || strings.Contains(ref, "voiceflow.com/") { + return "", errors.New("that is a Creator URL, and the id in it is a version id, not a project id. " + + "Copy the Project ID from the agent's Settings → General (Metadata) in Creator") + } + return "", fmt.Errorf("%q is not a project id: expected 24 hexadecimal characters", ref) +} + +// Locate returns the path of the nearest link file at or above dir, or "" +// when there is none. It does not read the file, so a link that fails to +// parse can still be found and removed. +func Locate(dir string) (string, error) { + dir, err := filepath.Abs(dir) + if err != nil { + return "", err + } + for { + path := filepath.Join(dir, DirName, FileName) + info, err := os.Stat(path) + switch { + case err == nil && !info.IsDir(): + return path, nil + // A missing file, or a .voiceflow that is a file rather than a + // directory, just means no link at this level. + case err == nil, errors.Is(err, fs.ErrNotExist), errors.Is(err, syscall.ENOTDIR): + default: + return "", fmt.Errorf("check %s: %w", path, err) + } + parent := filepath.Dir(dir) + if parent == dir { + return "", nil + } + dir = parent + } +} + +// Find returns the nearest link at or above dir, or nil when there is none. +func Find(dir string) (*Found, error) { + path, err := Locate(dir) + if err != nil || path == "" { + return nil, err + } + l, err := Read(path) + if err != nil { + return nil, err + } + return &Found{Link: *l, Path: path}, nil +} + +// Read parses one link file. +func Read(path string) (*Link, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + var l Link + if err := json.Unmarshal(data, &l); err != nil { + return nil, fmt.Errorf("%s is not a valid link: %w", path, err) + } + if !projectIDPattern.MatchString(l.ProjectID) { + return nil, fmt.Errorf("%s is not a valid link: projectID %q is not a project id", path, l.ProjectID) + } + l.ProjectID = strings.ToLower(l.ProjectID) + // A hand-written link may name only the project; every project has main. + if l.EnvironmentAlias == "" { + l.EnvironmentAlias = DefaultEnvironmentAlias + } + return &l, nil +} + +// Save writes l to dir/.voiceflow/project.json and returns the file's path. +// The write goes through a temporary file and a rename, so an interrupted +// save never leaves half a link behind. +func Save(dir string, l Link) (string, error) { + linkDir := filepath.Join(dir, DirName) + if err := os.MkdirAll(linkDir, 0o755); err != nil { + return "", fmt.Errorf("create %s: %w", linkDir, err) + } + data, err := json.MarshalIndent(l, "", " ") + if err != nil { + return "", err + } + data = append(data, '\n') + + tmp, err := os.CreateTemp(linkDir, FileName+".tmp-*") + if err != nil { + return "", fmt.Errorf("write link: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) // no-op after a successful rename + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return "", fmt.Errorf("write link: %w", err) + } + if err := tmp.Close(); err != nil { + return "", fmt.Errorf("write link: %w", err) + } + // Not a secret: readable like any other project file. + if err := os.Chmod(tmpPath, 0o644); err != nil { + return "", fmt.Errorf("write link: %w", err) + } + path := filepath.Join(linkDir, FileName) + if err := os.Rename(tmpPath, path); err != nil { + return "", fmt.Errorf("write link: %w", err) + } + return path, nil +} + +// Remove deletes a link file, and its .voiceflow directory when nothing else +// lives there. +func Remove(path string) error { + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove %s: %w", path, err) + } + // Fails harmlessly when the directory still holds other files. + _ = os.Remove(filepath.Dir(path)) + return nil +} + +// The flags a link can fill. +const ( + projectIDFlag = "project-id" + environmentAliasFlag = "environment-alias" + workspaceIDFlag = "workspace-id" +) + +func (l Link) valueFor(flag string) string { + switch flag { + case projectIDFlag: + return l.ProjectID + case environmentAliasFlag: + return l.EnvironmentAlias + case workspaceIDFlag: + return l.WorkspaceID + } + return "" +} + +// deleteTargets maps a command group to the flag that names what its delete +// command destroys. A link never fills that flag: deleting a project, an +// environment or a workspace always names the target explicitly. +var deleteTargets = map[string]string{ + "project": projectIDFlag, + "environment": environmentAliasFlag, + "workspace": workspaceIDFlag, +} + +func isDeleteTarget(cmd *cobra.Command, flag string) bool { + if cmd.Name() != "delete" || !cmd.HasParent() { + return false + } + return deleteTargets[cmd.Parent().Name()] == flag +} + +// ApplyDefaults fills the project, environment and workspace flags that cmd +// has but was not given, from the nearest link at or above dir. An explicit +// flag always wins. It returns the link it used, or nil when none applied. +// +// The link file is only read when the command can use it, so commands that +// take no project (auth, docs, version) never fail on a damaged link. +func ApplyDefaults(cmd *cobra.Command, dir string) (*Found, error) { + if cmd.Annotations[SkipDefaultsAnnotation] == "true" { + return nil, nil + } + var wanted []string + for _, name := range []string{projectIDFlag, environmentAliasFlag, workspaceIDFlag} { + f := cmd.Flags().Lookup(name) + if f == nil || f.Changed || isDeleteTarget(cmd, name) { + continue + } + wanted = append(wanted, name) + } + if len(wanted) == 0 { + return nil, nil + } + + found, err := Find(dir) + if err != nil || found == nil { + return nil, err + } + for _, name := range wanted { + value := found.valueFor(name) + if value == "" { + continue + } + if err := cmd.Flags().Set(name, value); err != nil { + return nil, fmt.Errorf("apply --%s from %s: %w", name, found.Path, err) + } + } + return found, nil +} diff --git a/internal/link/link_test.go b/internal/link/link_test.go new file mode 100644 index 00000000..003e7d91 --- /dev/null +++ b/internal/link/link_test.go @@ -0,0 +1,279 @@ +package link + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" +) + +const testProjectID = "0123456789abcdef01234567" + +func writeLink(t *testing.T, dir, body string) string { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, DirName), 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, DirName, FileName) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestParseProjectIDAcceptsAndNormalizesAnID(t *testing.T) { + got, err := ParseProjectID(" 0123456789ABCDEF01234567 ") + if err != nil || got != testProjectID { + t.Fatalf("got %q, %v", got, err) + } +} + +func TestParseProjectIDExplainsCreatorURLs(t *testing.T) { + _, err := ParseProjectID("https://creator.voiceflow.com/project/" + testProjectID + "/canvas/abc") + if err == nil || !strings.Contains(err.Error(), "version id") || !strings.Contains(err.Error(), "Settings") { + t.Fatalf("want an explanation that names the version id and where to find the project id, got %v", err) + } +} + +func TestParseProjectIDRejectsOtherInput(t *testing.T) { + for _, in := range []string{"", "abc", testProjectID + "0", "zz23456789abcdef01234567"} { + if _, err := ParseProjectID(in); err == nil { + t.Errorf("%q: want an error", in) + } + } +} + +func TestSaveThenFindRoundTrips(t *testing.T) { + dir := t.TempDir() + want := Link{ProjectID: testProjectID, ProjectName: "Returns", WorkspaceID: "VzElNm0wjL", EnvironmentAlias: "dev", LinkedAt: time.Date(2026, 9, 29, 0, 0, 0, 0, time.UTC)} + + path, err := Save(dir, want) + if err != nil { + t.Fatal(err) + } + if path != filepath.Join(dir, DirName, FileName) { + t.Fatalf("saved to %s", path) + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0o644 { + t.Fatalf("want a 0644 file, got %v %v", info.Mode(), err) + } + leftovers, _ := filepath.Glob(filepath.Join(dir, DirName, "*.tmp-*")) + if len(leftovers) != 0 { + t.Fatalf("temporary files left behind: %v", leftovers) + } + + got, err := Find(dir) + if err != nil { + t.Fatal(err) + } + if got == nil || got.Link != want || got.Path != path { + t.Fatalf("got %+v, want %+v at %s", got, want, path) + } +} + +func TestFindWalksUpAndTheNearestLinkWins(t *testing.T) { + root := t.TempDir() + writeLink(t, root, `{"projectID":"`+testProjectID+`"}`) + deep := filepath.Join(root, "a", "b", "c") + if err := os.MkdirAll(deep, 0o755); err != nil { + t.Fatal(err) + } + + got, err := Find(deep) + if err != nil || got == nil || got.ProjectID != testProjectID { + t.Fatalf("want the root link from a subdirectory, got %+v %v", got, err) + } + + nearer := filepath.Join(root, "a") + writeLink(t, nearer, `{"projectID":"fedcba9876543210fedcba98"}`) + got, err = Find(deep) + if err != nil || got == nil || got.ProjectID != "fedcba9876543210fedcba98" { + t.Fatalf("want the nearer link, got %+v %v", got, err) + } +} + +func TestFindWithoutALinkReturnsNil(t *testing.T) { + got, err := Find(t.TempDir()) + if err != nil || got != nil { + t.Fatalf("got %+v %v", got, err) + } +} + +func TestFindIgnoresAVoiceflowFile(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, DirName), []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + got, err := Find(dir) + if err != nil || got != nil { + t.Fatalf("a .voiceflow file is not a link: got %+v %v", got, err) + } +} + +func TestReadDefaultsTheEnvironmentAndNormalizesTheID(t *testing.T) { + path := writeLink(t, t.TempDir(), `{"projectID":"0123456789ABCDEF01234567"}`) + l, err := Read(path) + if err != nil { + t.Fatal(err) + } + if l.ProjectID != testProjectID || l.EnvironmentAlias != DefaultEnvironmentAlias { + t.Fatalf("got %+v", l) + } +} + +func TestReadRejectsDamagedLinks(t *testing.T) { + for name, body := range map[string]string{ + "not json": `{"projectID":`, + "no project id": `{"environmentAlias":"main"}`, + "bad project": `{"projectID":"nope"}`, + } { + path := writeLink(t, t.TempDir(), body) + if _, err := Read(path); err == nil || !strings.Contains(err.Error(), path) { + t.Errorf("%s: want an error naming %s, got %v", name, path, err) + } + } +} + +func TestRemoveDeletesTheFileAndAnEmptyDirectory(t *testing.T) { + dir := t.TempDir() + path := writeLink(t, dir, `{"projectID":"`+testProjectID+`"}`) + if err := Remove(path); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, DirName)); !os.IsNotExist(err) { + t.Fatalf("want .voiceflow gone, got %v", err) + } +} + +func TestRemoveKeepsADirectoryWithOtherFiles(t *testing.T) { + dir := t.TempDir() + path := writeLink(t, dir, `{"projectID":"`+testProjectID+`"}`) + other := filepath.Join(dir, DirName, "notes.md") + if err := os.WriteFile(other, []byte("keep me"), 0o644); err != nil { + t.Fatal(err) + } + if err := Remove(path); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(other); err != nil { + t.Fatalf("other files must survive: %v", err) + } +} + +// commandTree builds `vf ` with the flags generated commands use. +func commandTree(group, name string, flags ...string) *cobra.Command { + root := &cobra.Command{Use: "vf"} + parent := &cobra.Command{Use: group} + cmd := &cobra.Command{Use: name} + for _, f := range flags { + cmd.Flags().String(f, "", "") + } + root.AddCommand(parent) + parent.AddCommand(cmd) + return cmd +} + +func linkedDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + writeLink(t, dir, `{"projectID":"`+testProjectID+`","workspaceID":"VzElNm0wjL","environmentAlias":"dev"}`) + return dir +} + +func flagValue(t *testing.T, cmd *cobra.Command, name string) (string, bool) { + t.Helper() + f := cmd.Flags().Lookup(name) + return f.Value.String(), f.Changed +} + +func TestApplyDefaultsFillsFlagsTheCommandHas(t *testing.T) { + cmd := commandTree("playbook", "list", projectIDFlag, environmentAliasFlag) + + found, err := ApplyDefaults(cmd, linkedDir(t)) + if err != nil || found == nil { + t.Fatalf("got %+v %v", found, err) + } + if v, changed := flagValue(t, cmd, projectIDFlag); v != testProjectID || !changed { + t.Errorf("project-id = %q (changed %v)", v, changed) + } + if v, changed := flagValue(t, cmd, environmentAliasFlag); v != "dev" || !changed { + t.Errorf("environment-alias = %q (changed %v)", v, changed) + } +} + +func TestApplyDefaultsNeverOverridesAnExplicitFlag(t *testing.T) { + cmd := commandTree("playbook", "list", projectIDFlag, environmentAliasFlag) + if err := cmd.Flags().Parse([]string{"--project-id", "fedcba9876543210fedcba98"}); err != nil { + t.Fatal(err) + } + + if _, err := ApplyDefaults(cmd, linkedDir(t)); err != nil { + t.Fatal(err) + } + if v, _ := flagValue(t, cmd, projectIDFlag); v != "fedcba9876543210fedcba98" { + t.Errorf("explicit project-id overridden: %q", v) + } + if v, _ := flagValue(t, cmd, environmentAliasFlag); v != "dev" { + t.Errorf("unset environment-alias should still come from the link: %q", v) + } +} + +func TestApplyDefaultsNeverFillsWhatADeleteDestroys(t *testing.T) { + cases := []struct{ group, keep string }{ + {"project", projectIDFlag}, + {"environment", environmentAliasFlag}, + {"workspace", workspaceIDFlag}, + } + for _, c := range cases { + cmd := commandTree(c.group, "delete", projectIDFlag, environmentAliasFlag, workspaceIDFlag) + if _, err := ApplyDefaults(cmd, linkedDir(t)); err != nil { + t.Fatal(err) + } + if _, changed := flagValue(t, cmd, c.keep); changed { + t.Errorf("%s delete: --%s must be named explicitly", c.group, c.keep) + } + } + + // Deleting something inside the project still gets the project filled in. + cmd := commandTree("playbook", "delete", projectIDFlag, environmentAliasFlag) + if _, err := ApplyDefaults(cmd, linkedDir(t)); err != nil { + t.Fatal(err) + } + if v, _ := flagValue(t, cmd, projectIDFlag); v != testProjectID { + t.Errorf("playbook delete should use the linked project, got %q", v) + } +} + +func TestApplyDefaultsSkipsAnnotatedCommands(t *testing.T) { + cmd := commandTree("link", "link", environmentAliasFlag) + cmd.Annotations = map[string]string{SkipDefaultsAnnotation: "true"} + found, err := ApplyDefaults(cmd, linkedDir(t)) + if err != nil || found != nil { + t.Fatalf("got %+v %v", found, err) + } + if _, changed := flagValue(t, cmd, environmentAliasFlag); changed { + t.Error("annotated command received a default") + } +} + +func TestApplyDefaultsDoesNotReadTheLinkForCommandsWithoutProjectFlags(t *testing.T) { + dir := t.TempDir() + writeLink(t, dir, `{broken`) + cmd := commandTree("docs", "search") + if found, err := ApplyDefaults(cmd, dir); err != nil || found != nil { + t.Fatalf("a damaged link must not break unrelated commands: %+v %v", found, err) + } +} + +func TestApplyDefaultsReportsADamagedLinkWhenItIsNeeded(t *testing.T) { + dir := t.TempDir() + path := writeLink(t, dir, `{broken`) + cmd := commandTree("playbook", "list", projectIDFlag) + if _, err := ApplyDefaults(cmd, dir); err == nil || !strings.Contains(err.Error(), path) { + t.Fatalf("want an error naming %s, got %v", path, err) + } +} diff --git a/internal/output/format.go b/internal/output/format.go new file mode 100644 index 00000000..35364d4c --- /dev/null +++ b/internal/output/format.go @@ -0,0 +1,14 @@ +// This file is not generated by Speakeasy. It exposes the resolved output +// format to hand-written commands that render their own human-readable text +// but must still honour --output-format and agent mode's TOON default. + +package output + +import "github.com/spf13/cobra" + +// Format returns the output format this invocation resolves to: the +// --output-format flag, then the config file, then toon in agent mode, then +// pretty. +func Format(cmd *cobra.Command) string { + return resolveOutputFormat(cmd) +} diff --git a/test/link.test.ts b/test/link.test.ts new file mode 100644 index 00000000..47491e4c --- /dev/null +++ b/test/link.test.ts @@ -0,0 +1,253 @@ +// Tests for `vf link` / `vf unlink`: pinning a project and environment to a +// directory so commands run inside it need no --project-id or +// --environment-alias. +// +// Every case runs the real binary in a fresh temporary directory with an +// isolated HOME, against a mock server on loopback. Nothing here reaches the +// network or reads the machine's real ~/.config/vf. +// Requires: go build -o vf ./cmd/vf + +import { execa } from 'execa'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const VF = path.resolve(__dirname, '..', 'vf'); + +// Every variable that puts the CLI into agent mode. Mirrors the list in +// internal/output/agentmode.go; a stray one on the host would silently flip +// the renderer under test. +const AGENT_ENV_VARS = [ + 'CLAUDECODE', 'CLAUDE_CODE', 'CURSOR_AGENT', 'CODEX', 'AIDER', 'CLINE', + 'WINDSURF_AGENT', 'GITHUB_COPILOT', 'AMAZON_Q', 'GEMINI_CODE_ASSIST', + 'SRC_CODY', 'FORCE_AGENT_MODE', +]; + +const PROJECT_ID = '0123456789abcdef01234567'; +const OTHER_PROJECT_ID = 'fedcba9876543210fedcba98'; +const WORKSPACE_ID = 'VzElNm0wjL'; + +const environment = (alias: string) => ({ + name: alias === 'main' ? 'Production' : alias, + alias, + isMain: alias === 'main', + releases: [], + createdAt: '2026-09-01T00:00:00.000Z', + trafficPercentage: alias === 'main' ? 100 : 0, +}); + +let server: http.Server; +let serverURL: string; +let requests: string[] = []; +let home: string; +let cwd: string; + +beforeAll(async () => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'vf-link-home-')); + server = http.createServer((req, res) => { + requests.push(`${req.method} ${req.url}`); + const url = new URL(req.url ?? '/', 'http://mock'); + const reply = (status: number, body: object) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + + if (url.pathname === `/v1/stable/project/${PROJECT_ID}`) { + return reply(200, { + project: { + id: PROJECT_ID, + name: 'Returns bot', + image: null, + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-02T00:00:00.000Z', + workspaceID: WORKSPACE_ID, + description: null, + }, + }); + } + if (url.pathname === '/v1/stable/environment') { + return reply(200, { environments: [environment('main'), environment('dev')] }); + } + const alias = url.pathname.match(/^\/v1\/stable\/environment\/([^/]+)$/)?.[1]; + if (alias === 'main' || alias === 'dev') return reply(200, { environment: environment(alias) }); + return reply(404, { statusCode: 404, message: 'Not found' }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + serverURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(home, { recursive: true, force: true }); +}); + +beforeEach(() => { + requests = []; + // Resolved, because the CLI reports real paths: on macOS the temp dir is + // /var/…, a symlink to /private/var/…. + cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vf-link-cwd-'))); +}); + +function run(args: string[], opts: { agentMode?: boolean; dir?: string } = {}) { + const env: Record = Object.fromEntries(AGENT_ENV_VARS.map((name) => [name, undefined])); + Object.assign(env, { HOME: home, CI: '', VF_TOKEN: 'vfp_test' }); + if (opts.agentMode) env.CLAUDECODE = '1'; + return execa({ reject: false, timeout: 20_000, stdin: 'ignore', env, extendEnv: true, cwd: opts.dir ?? cwd })(VF, args); +} + +const linkFile = (dir = cwd) => path.join(dir, '.voiceflow', 'project.json'); +const readLink = (dir = cwd) => JSON.parse(fs.readFileSync(linkFile(dir), 'utf-8')); + +function writeLink(body: object, dir = cwd) { + fs.mkdirSync(path.join(dir, '.voiceflow'), { recursive: true }); + fs.writeFileSync(linkFile(dir), JSON.stringify(body)); +} + +/** The request line a --dry-run would have sent. */ +function dryRunURL(stderr: string): URL { + const line = stderr.split('\n').find((l) => l.startsWith('[DRY-RUN] Would send:')); + expect(line, `no dry-run request in:\n${stderr}`).toBeDefined(); + return new URL(line!.replace(/^\[DRY-RUN\] Would send: \w+ /, '')); +} + +describe('vf link', () => { + it('links the project, writes .voiceflow/project.json, and prints a snippet for agents', async () => { + const result = await run(['link', PROJECT_ID, '--server-url', serverURL]); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain('Linked this directory to "Returns bot"'); + expect(result.stdout).toContain('CLAUDE.md'); + expect(readLink()).toMatchObject({ projectID: PROJECT_ID, projectName: 'Returns bot', workspaceID: WORKSPACE_ID, environmentAlias: 'main' }); + expect(requests).toEqual([ + `GET /v1/stable/project/${PROJECT_ID}`, + `GET /v1/stable/environment/main?projectID=${PROJECT_ID}`, + ]); + }); + + it('returns a structured result in agent mode, with the instructions snippet', async () => { + const result = await run(['link', PROJECT_ID, '--environment-alias', 'dev', '--server-url', serverURL, '--output-format', 'json'], { agentMode: true }); + + expect(result.exitCode, result.stderr).toBe(0); + const out = JSON.parse(result.stdout); + expect(out).toMatchObject({ projectID: PROJECT_ID, environmentAlias: 'dev', file: linkFile() }); + expect(out.agentInstructions).toContain('vf environment compile'); + expect(readLink().environmentAlias).toBe('dev'); + }); + + it('explains that a Creator URL carries a version id, and sends nothing', async () => { + const result = await run(['link', `https://creator.voiceflow.com/project/${PROJECT_ID}/canvas/abc`, '--server-url', serverURL]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('version id'); + expect(result.stderr).toContain('Settings → General'); + expect(requests).toEqual([]); + expect(fs.existsSync(linkFile())).toBe(false); + }); + + it('names the environments a project has when the alias is wrong', async () => { + const result = await run(['link', PROJECT_ID, '--environment-alias', 'staging', '--server-url', serverURL], { agentMode: true }); + + expect(result.exitCode).toBe(1); + const envelope = JSON.parse(result.stderr); + expect(envelope.error_type).toBe('environment_not_found'); + expect(JSON.stringify(envelope.hints)).toContain('main, dev'); + expect(fs.existsSync(linkFile())).toBe(false); + }); + + it('writes nothing on --dry-run', async () => { + const result = await run(['link', PROJECT_ID, '--dry-run']); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toContain('[DRY-RUN] Would write'); + expect(fs.existsSync(linkFile())).toBe(false); + }); +}); + +describe('commands in a linked directory', () => { + beforeEach(() => writeLink({ projectID: PROJECT_ID, projectName: 'Returns bot', workspaceID: WORKSPACE_ID, environmentAlias: 'dev' })); + + it('use the linked project and environment, from any directory below', async () => { + const deep = path.join(cwd, 'src', 'agents'); + fs.mkdirSync(deep, { recursive: true }); + + const result = await run(['playbook', 'list', '--dry-run'], { dir: deep }); + + expect(result.exitCode, result.stderr).toBe(0); + const sent = dryRunURL(result.stderr); + expect(sent.searchParams.get('projectID')).toBe(PROJECT_ID); + expect(sent.searchParams.get('environmentAlias')).toBe('dev'); + }); + + it('let an explicit flag win', async () => { + const result = await run(['playbook', 'list', '--project-id', OTHER_PROJECT_ID, '--dry-run']); + + expect(result.exitCode, result.stderr).toBe(0); + const sent = dryRunURL(result.stderr); + expect(sent.searchParams.get('projectID')).toBe(OTHER_PROJECT_ID); + expect(sent.searchParams.get('environmentAlias')).toBe('dev'); + }); + + it('never fill in what a delete destroys', async () => { + const result = await run(['project', 'delete', '--dry-run']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('missing required flag: --project-id'); + }); + + it('show the link in vf whoami', async () => { + const result = await run(['whoami']); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain('Linked project:'); + expect(result.stdout).toContain(`Returns bot (${PROJECT_ID})`); + expect(result.stdout).toContain(linkFile()); + }); +}); + +describe('a damaged link', () => { + beforeEach(() => { + fs.mkdirSync(path.join(cwd, '.voiceflow'), { recursive: true }); + fs.writeFileSync(linkFile(), '{broken'); + }); + + it('fails project commands with the file named and the fix', async () => { + const result = await run(['playbook', 'list', '--dry-run'], { agentMode: true }); + + expect(result.exitCode).toBe(1); + const envelope = JSON.parse(result.stderr); + expect(envelope.error_type).toBe('invalid_link'); + expect(envelope.message).toContain(linkFile()); + expect(JSON.stringify(envelope.hints)).toContain('vf unlink'); + }); + + it('does not break commands that take no project', async () => { + const result = await run(['version']); + expect(result.exitCode, result.stderr).toBe(0); + }); + + it('can still be removed with vf unlink', async () => { + const result = await run(['unlink']); + expect(result.exitCode, result.stderr).toBe(0); + expect(fs.existsSync(linkFile())).toBe(false); + }); +}); + +describe('vf unlink', () => { + it('removes the link that applies here, even from a subdirectory, and is safe to repeat', async () => { + writeLink({ projectID: PROJECT_ID, projectName: 'Returns bot' }); + const deep = path.join(cwd, 'src'); + fs.mkdirSync(deep); + + const first = await run(['unlink'], { dir: deep }); + expect(first.exitCode, first.stderr).toBe(0); + expect(first.stdout).toContain(`Unlinked "Returns bot" (${PROJECT_ID})`); + expect(fs.existsSync(path.join(cwd, '.voiceflow'))).toBe(false); + + const again = await run(['unlink'], { dir: deep }); + expect(again.exitCode, again.stderr).toBe(0); + expect(again.stdout).toContain('Nothing to unlink'); + }); +}); From 9d026fc71ab2bd0b7eae8fc74f397d343fbe0873 Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:35:33 -0400 Subject: [PATCH 2/5] fix: keep json tag options out of vf link and vf unlink output (COR-14197) In agent mode the output is TOON, whose encoder prints a json tag verbatim, so omitempty became part of the key: "file,omitempty", "projectID,omitempty". Found by the live end-to-end test. The tags are plain now, and a behaviour test checks both commands' TOON output. --- internal/cli/link.go | 8 ++++---- test/link.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/internal/cli/link.go b/internal/cli/link.go index d8293314..bcb125cf 100644 --- a/internal/cli/link.go +++ b/internal/cli/link.go @@ -75,7 +75,7 @@ type linkSummary struct { WorkspaceID string `json:"workspaceID"` EnvironmentAlias string `json:"environmentAlias"` File string `json:"file"` - ReplacedProjectID string `json:"replacedProjectID,omitempty"` + ReplacedProjectID string `json:"replacedProjectID"` AgentInstructions string `json:"agentInstructions"` } @@ -254,9 +254,9 @@ type unlinkResult struct { type unlinkSummary struct { Removed bool `json:"removed"` - ProjectID string `json:"projectID,omitempty"` - ProjectName string `json:"projectName,omitempty"` - File string `json:"file,omitempty"` + ProjectID string `json:"projectID"` + ProjectName string `json:"projectName"` + File string `json:"file"` } // applyLinkDefaults fills --project-id, --environment-alias and diff --git a/test/link.test.ts b/test/link.test.ts index 47491e4c..1606abbb 100644 --- a/test/link.test.ts +++ b/test/link.test.ts @@ -235,6 +235,21 @@ describe('a damaged link', () => { }); }); +describe('agent-mode output', () => { + // TOON, agent mode's default, prints a json tag verbatim, so a tag option + // like omitempty would become part of the key. + it('uses plain keys for vf link and vf unlink', async () => { + const linked = await run(['link', PROJECT_ID, '--server-url', serverURL], { agentMode: true }); + expect(linked.exitCode, linked.stderr).toBe(0); + expect(linked.stdout).not.toContain(',omit'); + + const unlinked = await run(['unlink'], { agentMode: true }); + expect(unlinked.exitCode, unlinked.stderr).toBe(0); + expect(unlinked.stdout).toContain('removed'); + expect(unlinked.stdout).not.toContain(',omit'); + }); +}); + describe('vf unlink', () => { it('removes the link that applies here, even from a subdirectory, and is safe to repeat', async () => { writeLink({ projectID: PROJECT_ID, projectName: 'Returns bot' }); From 783a9384458581dd79cd5eb7e0c6bd7ec5e70095 Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:11:15 -0400 Subject: [PATCH 3/5] fix: let --body and stdin win over linked defaults (COR-14197) Review found that a linked environment silently replaced one the user wrote in the request body. Link defaults were applied with Flags().Set, which marks a flag as set, and BuildRequest lets set flags override --body and stdin. In a folder linked to "dev", `transcript search --body '{"environmentAlias":"production"}'` sent "dev". 21 commands carry the environment alias in their body. A linked value is now a fallback, never an override: explicit flag, then --body or stdin, then the link. flagutil.SetLinkDefault puts the value in the flag without marking it set, and tags it as linked: - A required path or query parameter still gets it from the flag's value, as before. - A body field is only filled by BuildRequest's new last pass, and only when the flags, --body and stdin left it empty. - The interactive prompt treats a linked value as answered. Tests pin down the order with a request shaped like transcript search. Its body and stdin cases fail under the old behaviour. An end-to-end case runs transcript search in a linked folder. --- README.md | 3 +- docs/vf_link.md | 7 +- internal/cli/link.go | 7 +- internal/flagutil/linkdefault.go | 103 ++++++++++++++++++++++++ internal/flagutil/linkdefault_test.go | 109 ++++++++++++++++++++++++++ internal/flagutil/metadata.go | 6 ++ internal/interactive/interactive.go | 4 + internal/link/link.go | 12 ++- internal/link/link_test.go | 25 +++--- test/link.test.ts | 13 +++ 10 files changed, 269 insertions(+), 20 deletions(-) create mode 100644 internal/flagutil/linkdefault.go create mode 100644 internal/flagutil/linkdefault_test.go diff --git a/README.md b/README.md index 68782680..28eef5a3 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,8 @@ vf link 6a67842584dac97c7626ebaa --environment-alias dev `vf link` checks that the project and environment exist, then writes `.voiceflow/project.json`: ids, the project name and the environment alias, nothing secret. Commands run in that directory, or in any directory below it, -use the link, and an explicit flag always wins. `vf whoami` shows the link in +use the link. A linked value is only a fallback: an explicit flag, or a value in +`--body` or stdin, always wins. `vf whoami` shows the link in effect, and `vf unlink` removes it. - **Deletes always name their target.** A link never fills in the project, diff --git a/docs/vf_link.md b/docs/vf_link.md index a3460c48..fe7856a4 100644 --- a/docs/vf_link.md +++ b/docs/vf_link.md @@ -7,9 +7,10 @@ Pin a project and environment to this directory Pin a Voiceflow project and environment to the current directory. Every vf command run here, or in any directory below, then uses them by -default, so --project-id and --environment-alias can be left out. An explicit -flag always wins. Deleting a project, environment or workspace always needs -its flag: a link never fills in what a delete destroys. +default, so --project-id and --environment-alias can be left out. A linked +value is only a fallback: an explicit flag, or a value in --body or stdin, +always wins. Deleting a project, environment or workspace always needs its +flag: a link never fills in what a delete destroys. The link is .voiceflow/project.json. It holds ids, the project name and the environment alias — nothing secret. Remove it with 'vf unlink'. diff --git a/internal/cli/link.go b/internal/cli/link.go index bcb125cf..5ca6c966 100644 --- a/internal/cli/link.go +++ b/internal/cli/link.go @@ -33,9 +33,10 @@ func initLinkCmd(parent *cobra.Command) { Long: `Pin a Voiceflow project and environment to the current directory. Every vf command run here, or in any directory below, then uses them by -default, so --project-id and --environment-alias can be left out. An explicit -flag always wins. Deleting a project, environment or workspace always needs -its flag: a link never fills in what a delete destroys. +default, so --project-id and --environment-alias can be left out. A linked +value is only a fallback: an explicit flag, or a value in --body or stdin, +always wins. Deleting a project, environment or workspace always needs its +flag: a link never fills in what a delete destroys. The link is .voiceflow/project.json. It holds ids, the project name and the environment alias — nothing secret. Remove it with 'vf unlink'. diff --git a/internal/flagutil/linkdefault.go b/internal/flagutil/linkdefault.go new file mode 100644 index 00000000..ef76b676 --- /dev/null +++ b/internal/flagutil/linkdefault.go @@ -0,0 +1,103 @@ +// This file is not generated by Speakeasy. It carries the defaults a linked +// project (.voiceflow/project.json, see internal/link) supplies, at the +// lowest precedence: an explicit flag, then --body or stdin, then the link. + +package flagutil + +import ( + "fmt" + "reflect" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// LinkDefaultAnnotation marks a flag whose value came from a linked project +// rather than from the command line. +const LinkDefaultAnnotation = "vf.link.default" + +// SetLinkDefault gives a flag a linked project's value without marking it as +// set by the user. BuildRequest then treats it as a fallback: it fills a +// required path or query parameter, and any field that the flags, --body and +// stdin all left empty, but it never replaces a value the user supplied. +func SetLinkDefault(cmd *cobra.Command, name, value string) error { + f := lookupFlag(cmd, name) + if f == nil { + return fmt.Errorf("the command has no --%s flag", name) + } + if err := f.Value.Set(value); err != nil { + return err + } + if f.Annotations == nil { + f.Annotations = map[string][]string{} + } + f.Annotations[LinkDefaultAnnotation] = []string{value} + return nil +} + +// HasLinkDefault reports whether a flag holds a value from a linked project. +func HasLinkDefault(cmd *cobra.Command, name string) bool { + _, ok := linkDefault(cmd, name) + return ok +} + +func linkDefault(cmd *cobra.Command, name string) (string, bool) { + f := lookupFlag(cmd, name) + if f == nil || len(f.Annotations[LinkDefaultAnnotation]) == 0 { + return "", false + } + return f.Annotations[LinkDefaultAnnotation][0], true +} + +func lookupFlag(cmd *cobra.Command, name string) *pflag.Flag { + if f := cmd.Flags().Lookup(name); f != nil { + return f + } + return cmd.InheritedFlags().Lookup(name) +} + +// applyLinkDefaults is BuildRequest's last pass. Each string field that a +// linked project has a value for, and that neither a flag, --body nor stdin +// set, takes the link's value. +func applyLinkDefaults(cmd *cobra.Command, v reflect.Value, meta []FlagMeta) error { + for _, m := range meta { + if m.Kind != FlagKindString || FlagChanged(cmd, m.FlagName) { + continue + } + value, ok := linkDefault(cmd, m.FlagName) + if !ok || !fieldIsEmpty(v, m.FieldPath) { + continue + } + if err := setFieldByPath(v, m.FieldPath, reflect.ValueOf(value)); err != nil { + return fmt.Errorf("apply the linked --%s: %w", m.FlagName, err) + } + } + return nil +} + +// fieldIsEmpty reports whether the field at path holds its zero value, or +// sits behind a nil pointer. Unlike navigateToField it allocates nothing, so +// checking a field never creates an empty body. +func fieldIsEmpty(v reflect.Value, path string) bool { + current := v + for _, part := range strings.Split(path, ".") { + for current.Kind() == reflect.Ptr { + if current.IsNil() { + return true + } + current = current.Elem() + } + if current.Kind() != reflect.Struct { + return false + } + current = current.FieldByName(part) + if !current.IsValid() { + return false + } + } + if current.Kind() == reflect.Ptr { + return current.IsNil() + } + return current.IsZero() +} diff --git a/internal/flagutil/linkdefault_test.go b/internal/flagutil/linkdefault_test.go new file mode 100644 index 00000000..1c64c5ab --- /dev/null +++ b/internal/flagutil/linkdefault_test.go @@ -0,0 +1,109 @@ +package flagutil + +import ( + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// linkedRequest has the two shapes a linked flag lands in: a required query +// parameter (ProjectID) and an optional field in the JSON body +// (Body.EnvironmentAlias), as in `vf transcript search`. +type linkedRequest struct { + ProjectID string + Body linkedBody `request:"mediaType=application/json"` +} + +type linkedBody struct { + EnvironmentAlias *string `json:"environmentAlias,omitempty"` + SessionID *string `json:"sessionID,omitempty"` +} + +var linkedMeta = []FlagMeta{ + {FlagName: "project-id", FieldPath: "ProjectID", Kind: FlagKindString, Required: true}, + {FlagName: "environment-alias", FieldPath: "Body.EnvironmentAlias", Kind: FlagKindString, Optional: true}, + {FlagName: "session-id", FieldPath: "Body.SessionID", Kind: FlagKindString, Optional: true}, +} + +// buildLinked parses args, applies the link's values the way internal/link +// does, and builds the request. stdin is empty unless given. +func buildLinked(t *testing.T, stdin string, args ...string) *linkedRequest { + t.Helper() + cmd := &cobra.Command{Use: "search"} + RegisterFlags(cmd, linkedMeta) + cmd.Flags().String("body", "", "") + cmd.SetIn(strings.NewReader(stdin)) + if err := cmd.Flags().Parse(args); err != nil { + t.Fatal(err) + } + for name, value := range map[string]string{"project-id": "linked-project", "environment-alias": "dev"} { + if FlagChanged(cmd, name) { + continue // what internal/link does: never touch an explicit flag + } + if err := SetLinkDefault(cmd, name, value); err != nil { + t.Fatal(err) + } + } + req, err := BuildRequest[linkedRequest](cmd, linkedMeta, "Body", "body") + if err != nil { + t.Fatalf("BuildRequest(%v): %v", args, err) + } + return req +} + +func environment(req *linkedRequest) string { + if req.Body.EnvironmentAlias == nil { + return "" + } + return *req.Body.EnvironmentAlias +} + +func TestLinkDefaultsFillWhatNothingElseSet(t *testing.T) { + req := buildLinked(t, "") + if req.ProjectID != "linked-project" || environment(req) != "dev" { + t.Fatalf("got project %q, environment %q", req.ProjectID, environment(req)) + } +} + +func TestBodyWinsOverTheLink(t *testing.T) { + req := buildLinked(t, "", "--body", `{"environmentAlias":"production"}`) + if environment(req) != "production" { + t.Fatalf("a value in --body was replaced by the link: %q", environment(req)) + } + if req.ProjectID != "linked-project" { + t.Fatalf("a parameter the body does not carry still comes from the link: %q", req.ProjectID) + } +} + +func TestStdinWinsOverTheLink(t *testing.T) { + req := buildLinked(t, `{"environmentAlias":"production"}`) + if environment(req) != "production" { + t.Fatalf("a value on stdin was replaced by the link: %q", environment(req)) + } +} + +func TestLinkFillsABodyThatLeavesTheFieldOut(t *testing.T) { + req := buildLinked(t, "", "--body", `{"sessionID":"s1"}`) + if environment(req) != "dev" || req.Body.SessionID == nil || *req.Body.SessionID != "s1" { + t.Fatalf("got environment %q, session %v", environment(req), req.Body.SessionID) + } +} + +func TestExplicitFlagWinsOverBodyAndLink(t *testing.T) { + req := buildLinked(t, "", "--body", `{"environmentAlias":"production"}`, "--environment-alias", "staging", "--project-id", "explicit") + if environment(req) != "staging" || req.ProjectID != "explicit" { + t.Fatalf("got project %q, environment %q", req.ProjectID, environment(req)) + } +} + +func TestFieldIsEmptyAllocatesNothing(t *testing.T) { + var req linkedRequest + if !fieldIsEmpty(reflect.ValueOf(&req).Elem(), "Body.EnvironmentAlias") { + t.Fatal("an unset pointer field is empty") + } + if req.Body.EnvironmentAlias != nil { + t.Fatal("checking a field must not allocate it") + } +} diff --git a/internal/flagutil/metadata.go b/internal/flagutil/metadata.go index 06a08a7b..80fc8663 100644 --- a/internal/flagutil/metadata.go +++ b/internal/flagutil/metadata.go @@ -320,6 +320,12 @@ func BuildRequest[T any](cmd *cobra.Command, meta []FlagMeta, bodyFieldPath stri } } + // Priority 4: a linked project's values fill only what the flags, --body + // and stdin left empty; see linkdefault.go. + if err := applyLinkDefaults(cmd, v, meta); err != nil { + return nil, err + } + return &req, nil } diff --git a/internal/interactive/interactive.go b/internal/interactive/interactive.go index e36f37ff..de812e72 100644 --- a/internal/interactive/interactive.go +++ b/internal/interactive/interactive.go @@ -64,6 +64,10 @@ func isValueResolved(cmd *cobra.Command, m flagutil.FlagMeta) bool { if f := cmd.Flags().Lookup(m.FlagName); f != nil && f.Changed { return true } + // A linked project's value answers the flag; see internal/link. + if flagutil.HasLinkDefault(cmd, m.FlagName) { + return true + } if m.EnvVar != "" { if _, ok := os.LookupEnv(m.EnvVar); ok { return true diff --git a/internal/link/link.go b/internal/link/link.go index 056a1254..aef4db9e 100644 --- a/internal/link/link.go +++ b/internal/link/link.go @@ -19,6 +19,8 @@ import ( "time" "github.com/spf13/cobra" + + "github.com/voiceflow/cli/internal/flagutil" ) const ( @@ -217,8 +219,12 @@ func isDeleteTarget(cmd *cobra.Command, flag string) bool { } // ApplyDefaults fills the project, environment and workspace flags that cmd -// has but was not given, from the nearest link at or above dir. An explicit -// flag always wins. It returns the link it used, or nil when none applied. +// has but was not given, from the nearest link at or above dir. It returns +// the link it used, or nil when none applied. +// +// A linked value is a fallback, never an override: an explicit flag wins, +// and so does a value in --body or stdin. The flags are not marked as set; +// see flagutil.SetLinkDefault. // // The link file is only read when the command can use it, so commands that // take no project (auth, docs, version) never fail on a damaged link. @@ -247,7 +253,7 @@ func ApplyDefaults(cmd *cobra.Command, dir string) (*Found, error) { if value == "" { continue } - if err := cmd.Flags().Set(name, value); err != nil { + if err := flagutil.SetLinkDefault(cmd, name, value); err != nil { return nil, fmt.Errorf("apply --%s from %s: %w", name, found.Path, err) } } diff --git a/internal/link/link_test.go b/internal/link/link_test.go index 003e7d91..732ae6cf 100644 --- a/internal/link/link_test.go +++ b/internal/link/link_test.go @@ -8,6 +8,8 @@ import ( "time" "github.com/spf13/cobra" + + "github.com/voiceflow/cli/internal/flagutil" ) const testProjectID = "0123456789abcdef01234567" @@ -190,18 +192,21 @@ func flagValue(t *testing.T, cmd *cobra.Command, name string) (string, bool) { return f.Value.String(), f.Changed } -func TestApplyDefaultsFillsFlagsTheCommandHas(t *testing.T) { +// TestApplyDefaultsFillsFlagsAsFallbacks: the linked values are in the flags +// but the flags are not marked as set, so BuildRequest lets --body and stdin +// win over them (see flagutil.SetLinkDefault). +func TestApplyDefaultsFillsFlagsAsFallbacks(t *testing.T) { cmd := commandTree("playbook", "list", projectIDFlag, environmentAliasFlag) found, err := ApplyDefaults(cmd, linkedDir(t)) if err != nil || found == nil { t.Fatalf("got %+v %v", found, err) } - if v, changed := flagValue(t, cmd, projectIDFlag); v != testProjectID || !changed { - t.Errorf("project-id = %q (changed %v)", v, changed) - } - if v, changed := flagValue(t, cmd, environmentAliasFlag); v != "dev" || !changed { - t.Errorf("environment-alias = %q (changed %v)", v, changed) + for name, want := range map[string]string{projectIDFlag: testProjectID, environmentAliasFlag: "dev"} { + v, changed := flagValue(t, cmd, name) + if v != want || changed || !flagutil.HasLinkDefault(cmd, name) { + t.Errorf("--%s = %q, changed %v, linked %v: want %q, not changed, linked", name, v, changed, flagutil.HasLinkDefault(cmd, name), want) + } } } @@ -233,8 +238,8 @@ func TestApplyDefaultsNeverFillsWhatADeleteDestroys(t *testing.T) { if _, err := ApplyDefaults(cmd, linkedDir(t)); err != nil { t.Fatal(err) } - if _, changed := flagValue(t, cmd, c.keep); changed { - t.Errorf("%s delete: --%s must be named explicitly", c.group, c.keep) + if v, _ := flagValue(t, cmd, c.keep); v != "" || flagutil.HasLinkDefault(cmd, c.keep) { + t.Errorf("%s delete: --%s must be named explicitly, got %q from the link", c.group, c.keep, v) } } @@ -255,8 +260,8 @@ func TestApplyDefaultsSkipsAnnotatedCommands(t *testing.T) { if err != nil || found != nil { t.Fatalf("got %+v %v", found, err) } - if _, changed := flagValue(t, cmd, environmentAliasFlag); changed { - t.Error("annotated command received a default") + if v, _ := flagValue(t, cmd, environmentAliasFlag); v != "" || flagutil.HasLinkDefault(cmd, environmentAliasFlag) { + t.Errorf("annotated command received a default: %q", v) } } diff --git a/test/link.test.ts b/test/link.test.ts index 1606abbb..3acd5107 100644 --- a/test/link.test.ts +++ b/test/link.test.ts @@ -205,6 +205,19 @@ describe('commands in a linked directory', () => { expect(result.stdout).toContain(`Returns bot (${PROJECT_ID})`); expect(result.stdout).toContain(linkFile()); }); + + // transcript search carries the environment in its JSON body, not in the + // query: a linked value must not replace one the user wrote there. + it('let a value in --body win over the link', async () => { + const withBody = await run(['transcript', 'search', '--body', '{"environmentAlias":"production"}', '--dry-run']); + expect(withBody.exitCode, withBody.stderr).toBe(0); + expect(withBody.stderr).toContain('"environmentAlias": "production"'); + expect(dryRunURL(withBody.stderr).searchParams.get('projectID')).toBe(PROJECT_ID); + + const withoutBody = await run(['transcript', 'search', '--dry-run']); + expect(withoutBody.exitCode, withoutBody.stderr).toBe(0); + expect(withoutBody.stderr).toContain('"environmentAlias": "dev"'); + }); }); describe('a damaged link', () => { From 59df48d38834564e8e88de5a0dd73510e2ed83fe Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:11:15 -0400 Subject: [PATCH 4/5] fix: honour --usage and --dry-run in vf link and vf unlink (COR-14197) Review found that both hand-written commands ignored flags every command inherits: - `vf link --usage` called the API and could write the link. `vf link --usage` with no project id failed argument validation first. - `vf unlink --usage` removed the link. - `vf unlink --dry-run` removed the link despite promising no changes. - The generated usage table had no entry for either command, so EmitSchema errored for both. Both commands now start with the usual UsageRequested / EmitSchema guard, and vf link's argument check lets --usage through. The new usage.RegisterCommand adds a hand-written command's schema to the table and to the root schema, leaving the generated file untouched. vf unlink --dry-run prints the file it would remove and stops. --- internal/cli/link.go | 29 ++++++++++++++++++++++++++++- internal/usage/register.go | 14 ++++++++++++++ test/link.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 internal/usage/register.go diff --git a/internal/cli/link.go b/internal/cli/link.go index 5ca6c966..8f674d1e 100644 --- a/internal/cli/link.go +++ b/internal/cli/link.go @@ -23,6 +23,7 @@ import ( "github.com/voiceflow/cli/internal/sdk" "github.com/voiceflow/cli/internal/sdk/models/operations" "github.com/voiceflow/cli/internal/sdk/models/sdkerrors" + "github.com/voiceflow/cli/internal/usage" ) // initLinkCmd registers `vf link` and `vf unlink`. @@ -45,7 +46,13 @@ The project id is in Creator under the agent's Settings → General (Metadata). A Creator page URL will not do: the id in it is a version id.`, Example: ` vf link 6a67842584dac97c7626ebaa vf link 6a67842584dac97c7626ebaa --environment-alias dev`, - Args: cobra.ExactArgs(1), + // --usage prints the schema and needs no project id. + Args: func(cmd *cobra.Command, args []string) error { + if usage.UsageRequested(cmd) { + return nil + } + return cobra.ExactArgs(1)(cmd, args) + }, // vf link decides the defaults; it must not receive the old ones. Annotations: map[string]string{link.SkipDefaultsAnnotation: "true"}, RunE: runLinkCmd, @@ -62,6 +69,14 @@ the one here, or the nearest one above. Commands then need --project-id and Args: cobra.NoArgs, RunE: runUnlinkCmd, }) + + usage.RegisterCommand("link", `cmd "link" help="Pin a project and environment to this directory" { + arg "" help="The project to link: the Project ID in Creator, under the agent's Settings → General (Metadata)" + flag "-e --environment-alias " help="Environment to link" default="main" +} +`) + usage.RegisterCommand("unlink", `cmd "unlink" help="Remove the project link that applies to this directory" +`) } // linkResult wraps the summary for output.Result, which renders the first @@ -81,6 +96,9 @@ type linkSummary struct { } func runLinkCmd(cmd *cobra.Command, args []string) error { + if usage.UsageRequested(cmd) { + return usage.EmitSchema(cmd, cmd.OutOrStdout()) + } projectID, err := link.ParseProjectID(args[0]) if err != nil { return linkError(cmd, "invalid_project_id", err.Error(), @@ -212,6 +230,9 @@ func environmentNotFound(cmd *cobra.Command, envs *sdk.Environment, projectID, a } func runUnlinkCmd(cmd *cobra.Command, args []string) error { + if usage.UsageRequested(cmd) { + return usage.EmitSchema(cmd, cmd.OutOrStdout()) + } cwd, err := os.Getwd() if err != nil { return fmt.Errorf("find the current directory: %w", err) @@ -228,6 +249,12 @@ func runUnlinkCmd(cmd *cobra.Command, args []string) error { return nil } + // --dry-run promises no changes: say what would go, and leave it. + if client.IsDryRun(cmd) { + fmt.Fprintf(cmd.ErrOrStderr(), "[DRY-RUN] Would remove %s\n", path) + return nil + } + // Read what is being removed for the message; a damaged link is still removed. previous, _ := link.Read(path) if err := link.Remove(path); err != nil { diff --git a/internal/usage/register.go b/internal/usage/register.go new file mode 100644 index 00000000..27183476 --- /dev/null +++ b/internal/usage/register.go @@ -0,0 +1,14 @@ +// This file is not generated by Speakeasy. The generated schema table only +// knows the commands generated from the OpenAPI spec; hand-written commands +// register their own entry here so --usage works for them too. + +package usage + +// RegisterCommand adds the usage schema of a hand-written top-level command: +// the entry `vf --usage` prints, and a copy appended to the root +// schema, so `vf --usage` lists the command beside the generated ones. +// schema is a KDL `cmd` node, as in the generated table. +func RegisterCommand(name, schema string) { + usageSchemas[name] = schema + usageSchemas[""] += schema +} diff --git a/test/link.test.ts b/test/link.test.ts index 3acd5107..e965a508 100644 --- a/test/link.test.ts +++ b/test/link.test.ts @@ -220,6 +220,27 @@ describe('commands in a linked directory', () => { }); }); +describe('--usage', () => { + it('prints the schema for vf link and vf unlink, and changes nothing', async () => { + writeLink({ projectID: PROJECT_ID, projectName: 'Returns bot' }); + + for (const args of [['link', PROJECT_ID, '--usage'], ['link', '--usage'], ['unlink', '--usage']]) { + const result = await run([...args, '--server-url', serverURL]); + expect(result.exitCode, `${args.join(' ')}: ${result.stderr}`).toBe(0); + expect(result.stdout).toContain(`cmd "${args[0]}"`); + } + expect(requests).toEqual([]); + expect(readLink().projectName).toBe('Returns bot'); // not replaced, not removed + }); + + it('lists vf link and vf unlink in the root schema', async () => { + const result = await run(['--usage']); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain('cmd "link"'); + expect(result.stdout).toContain('cmd "unlink"'); + }); +}); + describe('a damaged link', () => { beforeEach(() => { fs.mkdirSync(path.join(cwd, '.voiceflow'), { recursive: true }); @@ -278,4 +299,13 @@ describe('vf unlink', () => { expect(again.exitCode, again.stderr).toBe(0); expect(again.stdout).toContain('Nothing to unlink'); }); + + it('only previews on --dry-run', async () => { + writeLink({ projectID: PROJECT_ID, projectName: 'Returns bot' }); + + const result = await run(['unlink', '--dry-run']); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toContain(`[DRY-RUN] Would remove ${linkFile()}`); + expect(fs.existsSync(linkFile())).toBe(true); + }); }); From ecc41bc6278ca802cdb89e7bf78c7d23ca5c2c37 Mon Sep 17 00:00:00 2001 From: Braden Ream <51544548+Bradenream@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:13:49 -0400 Subject: [PATCH 5/5] ci: re-run checks on master now that #37 has fixed its tests