Path: blob/main/test/integration/integration_test.go
3436 views
package integration_test12import (3"encoding/json"4"fmt"5"github.com/kardolus/chatgpt-cli/agent/tools"6"github.com/kardolus/chatgpt-cli/api"7"github.com/kardolus/chatgpt-cli/cache"8"github.com/kardolus/chatgpt-cli/config"9"github.com/kardolus/chatgpt-cli/history"10"github.com/kardolus/chatgpt-cli/internal"11"github.com/kardolus/chatgpt-cli/test"12"github.com/onsi/gomega/gexec"13"github.com/sclevine/spec"14"github.com/sclevine/spec/report"15"io"16"log"17"os"18"os/exec"19"path"20"path/filepath"21"strconv"22"strings"23"sync"24"testing"25"time"2627. "github.com/onsi/gomega"28)2930const (31gitCommit = "some-git-commit"32gitVersion = "some-git-version"33servicePort = ":8080"34serviceURL = "http://0.0.0.0" + servicePort35)3637var (38once sync.Once39)4041func TestIntegration(t *testing.T) {42defer gexec.CleanupBuildArtifacts()43spec.Run(t, "Integration Tests", testIntegration, spec.Report(report.Terminal{}))44}4546func testIntegration(t *testing.T, when spec.G, it spec.S) {47it.Before(func() {48RegisterTestingT(t)49Expect(os.Unsetenv(internal.ConfigHomeEnv)).To(Succeed())50Expect(os.Unsetenv(internal.DataHomeEnv)).To(Succeed())51})5253when("Read and Write History", func() {54const threadName = "default-thread"5556var (57tmpDir string58fileIO *history.FileIO59messages []api.Message60err error61)6263it.Before(func() {64tmpDir, err = os.MkdirTemp("", "chatgpt-cli-test")65Expect(err).NotTo(HaveOccurred())6667fileIO, _ = history.New()68fileIO = fileIO.WithDirectory(tmpDir)69fileIO.SetThread(threadName)7071messages = []api.Message{72{73Role: "user",74Content: "Test message 1",75},76{77Role: "assistant",78Content: "Test message 2",79},80}81})8283it.After(func() {84Expect(os.RemoveAll(tmpDir)).To(Succeed())85})8687it("writes the messages to the file", func() {88var historyEntries []history.History89for _, message := range messages {90historyEntries = append(historyEntries, history.History{91Message: message,92})93}9495err = fileIO.Write(historyEntries)96Expect(err).NotTo(HaveOccurred())97})9899it("reads the messages from the file", func() {100var historyEntries []history.History101for _, message := range messages {102historyEntries = append(historyEntries, history.History{103Message: message,104})105}106107err = fileIO.Write(historyEntries) // need to write before reading108Expect(err).NotTo(HaveOccurred())109110readEntries, err := fileIO.Read()111Expect(err).NotTo(HaveOccurred())112Expect(readEntries).To(Equal(historyEntries))113})114})115116when("Read, Write and Delete Cache", func() {117var (118tmpDir string119storeDir string120err error121)122123const endpoint = "http://127.0.0.1:8000/mcp"124125it.Before(func() {126tmpDir, err = os.MkdirTemp("", "chatgpt-cli-cache-test")127Expect(err).NotTo(HaveOccurred())128129// Simulate what will likely become ~/.chatgpt-cli/cache/mcp/sessions130storeDir = filepath.Join(tmpDir, "cache", "mcp", "sessions")131})132133it.After(func() {134Expect(os.RemoveAll(tmpDir)).To(Succeed())135})136137it("writes, reads, and deletes a session id", func() {138fs := cache.NewFileStore(storeDir)139c := cache.New(fs)140141Expect(c.SetSessionID(endpoint, "sid-1")).To(Succeed())142143got, err := c.GetSessionID(endpoint)144Expect(err).NotTo(HaveOccurred())145Expect(got).To(Equal("sid-1"))146147Expect(c.DeleteSessionID(endpoint)).To(Succeed())148149// After delete, Get should error (os.ErrNotExist bubbling up)150_, err = c.GetSessionID(endpoint)151Expect(err).To(HaveOccurred())152})153154it("persists across cache instances (simulates separate CLI invocations)", func() {155fs1 := cache.NewFileStore(storeDir)156c1 := cache.New(fs1)157158Expect(c1.SetSessionID(endpoint, "sid-abc")).To(Succeed())159160// New instances, same underlying directory161fs2 := cache.NewFileStore(storeDir)162c2 := cache.New(fs2)163164got, err := c2.GetSessionID(endpoint)165Expect(err).NotTo(HaveOccurred())166Expect(got).To(Equal("sid-abc"))167})168169it("overwrites an existing session id (rotation)", func() {170fs := cache.NewFileStore(storeDir)171c := cache.New(fs)172173Expect(c.SetSessionID(endpoint, "sid-old")).To(Succeed())174Expect(c.SetSessionID(endpoint, "sid-new")).To(Succeed())175176got, err := c.GetSessionID(endpoint)177Expect(err).NotTo(HaveOccurred())178Expect(got).To(Equal("sid-new"))179})180})181182when("Read, Write, List, Delete Config", func() {183var (184tmpDir string185tmpFile *os.File186historyDir string187configIO *config.FileIO188testConfig config.Config189err error190)191192it.Before(func() {193tmpDir, err = os.MkdirTemp("", "chatgpt-cli-test")194Expect(err).NotTo(HaveOccurred())195196historyDir, err = os.MkdirTemp(tmpDir, "history")197Expect(err).NotTo(HaveOccurred())198199tmpFile, err = os.CreateTemp(tmpDir, "config.yaml")200Expect(err).NotTo(HaveOccurred())201202Expect(tmpFile.Close()).To(Succeed())203204configIO = config.NewStore().WithConfigPath(tmpFile.Name()).WithHistoryPath(historyDir)205206testConfig = config.Config{207Model: "test-model",208}209})210211it.After(func() {212Expect(os.RemoveAll(tmpDir)).To(Succeed())213})214215when("performing a migration", func() {216defaults := config.NewStore().ReadDefaults()217218it("it doesn't apply a migration when max_tokens is 0", func() {219testConfig.MaxTokens = 0220221err = configIO.Write(testConfig) // need to write before reading222Expect(err).NotTo(HaveOccurred())223224readConfig, err := configIO.Read()225Expect(err).NotTo(HaveOccurred())226Expect(readConfig).To(Equal(testConfig))227})228it("it migrates small values of max_tokens as expected", func() {229testConfig.MaxTokens = defaults.ContextWindow - 1230231err = configIO.Write(testConfig) // need to write before reading232Expect(err).NotTo(HaveOccurred())233234readConfig, err := configIO.Read()235Expect(err).NotTo(HaveOccurred())236237expectedConfig := testConfig238expectedConfig.MaxTokens = defaults.MaxTokens239expectedConfig.ContextWindow = defaults.ContextWindow240241Expect(readConfig).To(Equal(expectedConfig))242})243it("it migrates large values of max_tokens as expected", func() {244testConfig.MaxTokens = defaults.ContextWindow + 1245246err = configIO.Write(testConfig) // need to write before reading247Expect(err).NotTo(HaveOccurred())248249readConfig, err := configIO.Read()250Expect(err).NotTo(HaveOccurred())251252expectedConfig := testConfig253expectedConfig.MaxTokens = defaults.MaxTokens254expectedConfig.ContextWindow = testConfig.MaxTokens255256Expect(readConfig).To(Equal(expectedConfig))257})258})259260it("lists all the threads", func() {261files := []string{"thread1.json", "thread2.json", "thread3.json"}262263for _, file := range files {264file, err := os.Create(filepath.Join(historyDir, file))265Expect(err).NotTo(HaveOccurred())266267Expect(file.Close()).To(Succeed())268}269270result, err := configIO.List()271Expect(err).NotTo(HaveOccurred())272Expect(result).To(HaveLen(3))273Expect(result[2]).To(Equal("thread3.json"))274})275276it("deletes the thread", func() {277files := []string{"thread1.json", "thread2.json", "thread3.json"}278279for _, file := range files {280file, err := os.Create(filepath.Join(historyDir, file))281Expect(err).NotTo(HaveOccurred())282283Expect(file.Close()).To(Succeed())284}285286err = configIO.Delete("thread2")287Expect(err).NotTo(HaveOccurred())288289_, err = os.Stat(filepath.Join(historyDir, "thread2.json"))290Expect(os.IsNotExist(err)).To(BeTrue())291292_, err = os.Stat(filepath.Join(historyDir, "thread3.json"))293Expect(os.IsNotExist(err)).To(BeFalse())294})295})296297when("Performing the Lifecycle", func() {298const (299exitSuccess = 0300exitFailure = 1301)302303var (304homeDir string305filePath string306configFile string307err error308apiKeyEnvVar string309)310311runCommand := func(args ...string) string {312command := exec.Command(binaryPath, args...)313session, err := gexec.Start(command, io.Discard, io.Discard)314315ExpectWithOffset(1, err).NotTo(HaveOccurred())316<-session.Exited317318if tmp := string(session.Err.Contents()); tmp != "" {319fmt.Printf("error output: %s", string(session.Err.Contents()))320}321322ExpectWithOffset(1, session).Should(gexec.Exit(0))323return string(session.Out.Contents())324}325326runCommandWithStdin := func(stdin io.Reader, args ...string) string {327command := exec.Command(binaryPath, args...)328command.Stdin = stdin329session, err := gexec.Start(command, io.Discard, io.Discard)330331ExpectWithOffset(1, err).NotTo(HaveOccurred())332<-session.Exited333334if tmp := string(session.Err.Contents()); tmp != "" {335fmt.Printf("error output: %s", tmp)336}337338ExpectWithOffset(1, session).Should(gexec.Exit(0))339return string(session.Out.Contents())340}341342checkConfigFileContent := func(expectedContent string) {343content, err := os.ReadFile(configFile)344ExpectWithOffset(1, err).NotTo(HaveOccurred())345ExpectWithOffset(1, string(content)).To(ContainSubstring(expectedContent))346}347348it.Before(func() {349once.Do(func() {350SetDefaultEventuallyTimeout(10 * time.Second)351352log.Println("Building binary...")353Expect(buildBinary()).To(Succeed())354log.Println("Binary built successfully!")355356log.Println("Starting mock server...")357Expect(runMockServer()).To(Succeed())358log.Println("Mock server started!")359360Eventually(func() (string, error) {361return curl(fmt.Sprintf("%s/ping", serviceURL))362}).Should(ContainSubstring("pong"))363})364365homeDir, err = os.MkdirTemp("", "mockHome")366Expect(err).NotTo(HaveOccurred())367368apiKeyEnvVar = config.NewManager(config.NewStore()).WithEnvironment().APIKeyEnvVarName()369370Expect(os.Setenv("HOME", homeDir)).To(Succeed())371Expect(os.Setenv(apiKeyEnvVar, expectedToken)).To(Succeed())372})373374it.After(func() {375gexec.Kill()376Expect(os.RemoveAll(homeDir))377})378379when("resolving the API key", func() {380var secretFile string381382it.Before(func() {383secretFile = filepath.Join(homeDir, ".chatgpt-cli", "secret.key")384Expect(os.MkdirAll(filepath.Dir(secretFile), 0700)).To(Succeed())385Expect(os.WriteFile(secretFile, []byte(expectedToken+"\n"), 0600)).To(Succeed())386})387388it.After(func() {389Expect(os.RemoveAll(filepath.Dir(secretFile))).To(Succeed())390Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())391Expect(os.Unsetenv("OPENAI_API_KEY_FILE")).To(Succeed())392})393394it("prefers the API key from environment variable over the file", func() {395Expect(os.Setenv(apiKeyEnvVar, "env-api-key")).To(Succeed())396Expect(os.Setenv("OPENAI_API_KEY_FILE", secretFile)).To(Succeed())397398cmd := exec.Command(binaryPath, "--config")399session, err := gexec.Start(cmd, io.Discard, io.Discard)400Expect(err).NotTo(HaveOccurred())401Eventually(session).Should(gexec.Exit(exitSuccess))402output := string(session.Out.Contents())403Expect(output).To(ContainSubstring("env-api-key"))404})405406it("uses the file if env var is not set", func() {407Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())408Expect(os.Setenv("OPENAI_API_KEY_FILE", secretFile)).To(Succeed())409410cmd := exec.Command(binaryPath, "--list-models")411session, err := gexec.Start(cmd, io.Discard, io.Discard)412Expect(err).NotTo(HaveOccurred())413Eventually(session).Should(gexec.Exit(exitSuccess))414})415416it("errors if neither env var nor file is set", func() {417Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())418Expect(os.Unsetenv("OPENAI_API_KEY_FILE")).To(Succeed())419420cmd := exec.Command(binaryPath, "--list-models")421session, err := gexec.Start(cmd, io.Discard, io.Discard)422Expect(err).NotTo(HaveOccurred())423Eventually(session).Should(gexec.Exit(exitFailure))424errOutput := string(session.Err.Contents())425Expect(errOutput).To(ContainSubstring("API key is required"))426})427})428429it("should not require an API key for the --version flag", func() {430Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())431432command := exec.Command(binaryPath, "--version")433session, err := gexec.Start(command, io.Discard, io.Discard)434Expect(err).NotTo(HaveOccurred())435436Eventually(session).Should(gexec.Exit(exitSuccess))437})438439it("should require a hidden folder for the --list-threads flag", func() {440command := exec.Command(binaryPath, "--list-threads")441session, err := gexec.Start(command, io.Discard, io.Discard)442Expect(err).NotTo(HaveOccurred())443444Eventually(session).Should(gexec.Exit(exitFailure))445446output := string(session.Err.Contents())447Expect(output).To(ContainSubstring(".chatgpt-cli/history: no such file or directory"))448})449450it("should return an error when --new-thread is used with --set-thread", func() {451command := exec.Command(binaryPath, "--new-thread", "--set-thread", "some-thread")452session, err := gexec.Start(command, io.Discard, io.Discard)453Expect(err).NotTo(HaveOccurred())454455Eventually(session).Should(gexec.Exit(exitFailure))456457output := string(session.Err.Contents())458Expect(output).To(ContainSubstring("the --new-thread flag cannot be used with the --set-thread or --thread flags"))459})460461it("should return an error when --new-thread is used with --thread", func() {462command := exec.Command(binaryPath, "--new-thread", "--thread", "some-thread")463session, err := gexec.Start(command, io.Discard, io.Discard)464Expect(err).NotTo(HaveOccurred())465466Eventually(session).Should(gexec.Exit(exitFailure))467468output := string(session.Err.Contents())469Expect(output).To(ContainSubstring("the --new-thread flag cannot be used with the --set-thread or --thread flags"))470})471472it("should require an argument for the --set-model flag", func() {473command := exec.Command(binaryPath, "--set-model")474session, err := gexec.Start(command, io.Discard, io.Discard)475Expect(err).NotTo(HaveOccurred())476477Eventually(session).Should(gexec.Exit(exitFailure))478479output := string(session.Err.Contents())480Expect(output).To(ContainSubstring("flag needs an argument: --set-model"))481})482483it("should require an argument for the --set-thread flag", func() {484command := exec.Command(binaryPath, "--set-thread")485session, err := gexec.Start(command, io.Discard, io.Discard)486Expect(err).NotTo(HaveOccurred())487488Eventually(session).Should(gexec.Exit(exitFailure))489490output := string(session.Err.Contents())491Expect(output).To(ContainSubstring("flag needs an argument: --set-thread"))492})493494it("should require an argument for the --set-max-tokens flag", func() {495command := exec.Command(binaryPath, "--set-max-tokens")496session, err := gexec.Start(command, io.Discard, io.Discard)497Expect(err).NotTo(HaveOccurred())498499Eventually(session).Should(gexec.Exit(exitFailure))500501output := string(session.Err.Contents())502Expect(output).To(ContainSubstring("flag needs an argument: --set-max-tokens"))503})504505it("should require an argument for the --set-context-window flag", func() {506command := exec.Command(binaryPath, "--set-context-window")507session, err := gexec.Start(command, io.Discard, io.Discard)508Expect(err).NotTo(HaveOccurred())509510Eventually(session).Should(gexec.Exit(exitFailure))511512output := string(session.Err.Contents())513Expect(output).To(ContainSubstring("flag needs an argument: --set-context-window"))514})515516it("should warn when config.yaml does not exist and OPENAI_CONFIG_HOME is set", func() {517configHomeDir := "does-not-exist"518Expect(os.Setenv(internal.ConfigHomeEnv, configHomeDir)).To(Succeed())519520configFilePath := path.Join(configHomeDir, "config.yaml")521Expect(configFilePath).NotTo(BeAnExistingFile())522523command := exec.Command(binaryPath, "llm query")524session, err := gexec.Start(command, io.Discard, io.Discard)525Expect(err).NotTo(HaveOccurred())526527Eventually(session).Should(gexec.Exit(exitSuccess))528529output := string(session.Err.Contents())530Expect(output).To(ContainSubstring(fmt.Sprintf("Warning: config.yaml doesn't exist in %s, create it", configHomeDir)))531532// Unset the variable to prevent pollution533Expect(os.Unsetenv(internal.ConfigHomeEnv)).To(Succeed())534})535536it("should NOT warn when config.yaml does not exist and OPENAI_CONFIG_HOME is NOT set", func() {537configHomeDir := "does-not-exist"538Expect(os.Unsetenv(internal.ConfigHomeEnv)).To(Succeed())539540configFilePath := path.Join(configHomeDir, "config.yaml")541Expect(configFilePath).NotTo(BeAnExistingFile())542543command := exec.Command(binaryPath, "llm query")544session, err := gexec.Start(command, io.Discard, io.Discard)545Expect(err).NotTo(HaveOccurred())546547Eventually(session).Should(gexec.Exit(exitSuccess))548549output := string(session.Out.Contents())550Expect(output).NotTo(ContainSubstring(fmt.Sprintf("Warning: config.yaml doesn't exist in %s, create it", configHomeDir)))551})552553it("should require the chatgpt-cli folder but not an API key for the --set-model flag", func() {554Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())555556command := exec.Command(binaryPath, "--set-model", "123")557session, err := gexec.Start(command, io.Discard, io.Discard)558Expect(err).NotTo(HaveOccurred())559560Eventually(session).Should(gexec.Exit(exitFailure))561562output := string(session.Err.Contents())563Expect(output).To(ContainSubstring("config directory does not exist:"))564Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))565})566567it("should require the chatgpt-cli folder but not an API key for the --set-thread flag", func() {568Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())569570command := exec.Command(binaryPath, "--set-thread", "thread-name")571session, err := gexec.Start(command, io.Discard, io.Discard)572Expect(err).NotTo(HaveOccurred())573574Eventually(session).Should(gexec.Exit(exitFailure))575576output := string(session.Err.Contents())577Expect(output).To(ContainSubstring("config directory does not exist:"))578Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))579})580581it("should require the chatgpt-cli folder but not an API key for the --set-max-tokens flag", func() {582Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())583584command := exec.Command(binaryPath, "--set-max-tokens", "789")585session, err := gexec.Start(command, io.Discard, io.Discard)586Expect(err).NotTo(HaveOccurred())587588Eventually(session).Should(gexec.Exit(exitFailure))589590output := string(session.Err.Contents())591Expect(output).To(ContainSubstring("config directory does not exist:"))592Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))593})594595it("should require the chatgpt-cli folder but not an API key for the --set-context-window flag", func() {596Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())597598command := exec.Command(binaryPath, "--set-context-window", "789")599session, err := gexec.Start(command, io.Discard, io.Discard)600Expect(err).NotTo(HaveOccurred())601602Eventually(session).Should(gexec.Exit(exitFailure))603604output := string(session.Err.Contents())605Expect(output).To(ContainSubstring("config directory does not exist:"))606Expect(output).NotTo(ContainSubstring(apiKeyEnvVar))607})608609it("should return the expected result for the --version flag", func() {610output := runCommand("--version")611612Expect(output).To(ContainSubstring(fmt.Sprintf("commit %s", gitCommit)))613Expect(output).To(ContainSubstring(fmt.Sprintf("version %s", gitVersion)))614})615616it("should return the expected result for the --list-models flag", func() {617output := runCommand("--list-models")618619Expect(output).To(ContainSubstring("* gpt-4o (current)"))620Expect(output).To(ContainSubstring("- gpt-3.5-turbo"))621Expect(output).To(ContainSubstring("- gpt-3.5-turbo-0301"))622})623624it("should return the expected result for the --query flag", func() {625Expect(os.Setenv("OPENAI_TRACK_TOKEN_USAGE", "false")).To(Succeed())626627output := runCommand("--query", "some-query")628629expectedResponse := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`630Expect(output).To(ContainSubstring(expectedResponse))631Expect(output).NotTo(ContainSubstring("Token Usage:"))632})633634it("should display token usage after a query when configured to do so", func() {635Expect(os.Setenv("OPENAI_TRACK_TOKEN_USAGE", "true")).To(Succeed())636637output := runCommand("--query", "tell me a 5 line joke")638Expect(output).To(ContainSubstring("Token Usage:"))639})640641it("prints debug information with the --debug flag", func() {642output := runCommand("--query", "tell me a joke", "--debug")643644Expect(output).To(ContainSubstring("Generated cURL command"))645Expect(output).To(ContainSubstring("/v1/chat/completions"))646Expect(output).To(ContainSubstring("--header \"Authorization: Bearer ${OPENAI_API_KEY}\""))647Expect(output).To(ContainSubstring("--header 'Content-Type: application/json'"))648Expect(output).To(ContainSubstring("--header 'User-Agent: chatgpt-cli'"))649Expect(output).To(ContainSubstring("\"model\":\"gpt-4o\""))650Expect(output).To(ContainSubstring("\"messages\":"))651Expect(output).To(ContainSubstring("Response"))652653Expect(os.Unsetenv("OPENAI_DEBUG")).To(Succeed())654})655656it("should assemble http errors as expected", func() {657Expect(os.Setenv(apiKeyEnvVar, "wrong-token")).To(Succeed())658659command := exec.Command(binaryPath, "--query", "some-query")660session, err := gexec.Start(command, io.Discard, io.Discard)661Expect(err).NotTo(HaveOccurred())662663Eventually(session).Should(gexec.Exit(exitFailure))664665output := string(session.Err.Contents())666667// see error.json668Expect(output).To(Equal("http status 401: Incorrect API key provided\n"))669})670671when("loading configuration via --target", func() {672var (673configDir string674mainConfig string675targetConfig string676)677678it.Before(func() {679RegisterTestingT(t)680681var err error682configDir, err = os.MkdirTemp("", "chatgpt-cli-test")683Expect(err).NotTo(HaveOccurred())684685Expect(os.Setenv("OPENAI_CONFIG_HOME", configDir)).To(Succeed())686687mainConfig = filepath.Join(configDir, "config.yaml")688targetConfig = filepath.Join(configDir, "config.testtarget.yaml")689690Expect(os.WriteFile(mainConfig, []byte("model: gpt-4o\n"), 0644)).To(Succeed())691Expect(os.WriteFile(targetConfig, []byte("model: gpt-3.5-turbo-0301\n"), 0644)).To(Succeed())692})693694it("should load config.testtarget.yaml when using --target", func() {695cmd := exec.Command(binaryPath, "--target", "testtarget", "--config")696697session, err := gexec.Start(cmd, io.Discard, io.Discard)698Expect(err).NotTo(HaveOccurred())699700Eventually(session).Should(gexec.Exit(0))701output := string(session.Out.Contents())702Expect(output).To(ContainSubstring("gpt-3.5-turbo-0301"))703})704705it("should fall back to config.yaml when --target is not used", func() {706cmd := exec.Command(binaryPath, "--config")707708session, err := gexec.Start(cmd, io.Discard, io.Discard)709Expect(err).NotTo(HaveOccurred())710711Eventually(session).Should(gexec.Exit(0))712output := string(session.Out.Contents())713Expect(output).To(ContainSubstring("gpt-4o"))714})715})716717when("there is a hidden chatgpt-cli folder in the home dir", func() {718it.Before(func() {719filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")720Expect(os.MkdirAll(filePath, 0777)).To(Succeed())721})722723it.After(func() {724Expect(os.RemoveAll(filePath)).To(Succeed())725})726727it("should not require an API key for the --list-threads flag", func() {728historyPath := path.Join(filePath, "history")729Expect(os.MkdirAll(historyPath, 0777)).To(Succeed())730731Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())732733command := exec.Command(binaryPath, "--list-threads")734session, err := gexec.Start(command, io.Discard, io.Discard)735Expect(err).NotTo(HaveOccurred())736737Eventually(session).Should(gexec.Exit(exitSuccess))738})739740it("migrates the legacy history as expected", func() {741// Legacy history file should not exist742legacyFile := path.Join(filePath, "history")743Expect(legacyFile).NotTo(BeAnExistingFile())744745// History should not exist yet746historyFile := path.Join(filePath, "history", "default.json")747Expect(historyFile).NotTo(BeAnExistingFile())748749bytes, err := test.FileToBytes("history.json")750Expect(err).NotTo(HaveOccurred())751752Expect(os.WriteFile(legacyFile, bytes, 0644)).To(Succeed())753Expect(legacyFile).To(BeARegularFile())754755// Perform a query756command := exec.Command(binaryPath, "--query", "some-query")757session, err := gexec.Start(command, io.Discard, io.Discard)758Expect(err).NotTo(HaveOccurred())759760// The CLI response should be as expected761Eventually(session).Should(gexec.Exit(exitSuccess))762763output := string(session.Out.Contents())764765response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`766Expect(output).To(ContainSubstring(response))767768// The history file should have the expected content769Expect(path.Dir(historyFile)).To(BeADirectory())770content, err := os.ReadFile(historyFile)771772Expect(err).NotTo(HaveOccurred())773Expect(content).NotTo(BeEmpty())774Expect(string(content)).To(ContainSubstring(response))775776// The legacy file should now be a directory777Expect(legacyFile).To(BeADirectory())778Expect(legacyFile).NotTo(BeARegularFile())779780// The content was moved to the new file781Expect(string(content)).To(ContainSubstring("Of course! Which city are you referring to?"))782})783784it("should not require an API key for the --clear-history flag", func() {785Expect(os.Unsetenv(apiKeyEnvVar)).To(Succeed())786787command := exec.Command(binaryPath, "--clear-history")788session, err := gexec.Start(command, io.Discard, io.Discard)789Expect(err).NotTo(HaveOccurred())790791Eventually(session).Should(gexec.Exit(exitSuccess))792})793794it("keeps track of history", func() {795// History should not exist yet796historyDir := path.Join(filePath, "history")797historyFile := path.Join(historyDir, "default.json")798Expect(historyFile).NotTo(BeAnExistingFile())799800// Perform a query and check response801response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`802output := runCommand("--query", "some-query")803Expect(output).To(ContainSubstring(response))804805// Check if history file was created with expected content806Expect(historyDir).To(BeADirectory())807checkHistoryContent := func(expectedContent string) {808content, err := os.ReadFile(historyFile)809Expect(err).NotTo(HaveOccurred())810Expect(string(content)).To(ContainSubstring(expectedContent))811}812checkHistoryContent(response)813814// Clear the history using the CLI815runCommand("--clear-history")816Expect(historyFile).NotTo(BeAnExistingFile())817818// Test omitting history through environment variable819omitHistoryEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "OMIT_HISTORY", 1)820envValue := "true"821Expect(os.Setenv(omitHistoryEnvKey, envValue)).To(Succeed())822823// Perform another query with history omitted824runCommand("--query", "some-query")825// The history file should NOT be recreated826Expect(historyFile).NotTo(BeAnExistingFile())827828// Cleanup: Unset the environment variable829Expect(os.Unsetenv(omitHistoryEnvKey)).To(Succeed())830})831832it("should not add binary data to the history", func() {833historyDir := path.Join(filePath, "history")834historyFile := path.Join(historyDir, "default.json")835Expect(historyFile).NotTo(BeAnExistingFile())836837response := `I don't have personal opinions about bars, but here are some popular bars in Red Hook, Brooklyn:`838839// Create a pipe to simulate binary input840r, w := io.Pipe()841defer r.Close()842843// Run the command with piped binary input844binaryData := []byte{0x00, 0xFF, 0x42, 0x10}845go func() {846defer w.Close()847_, err := w.Write(binaryData)848Expect(err).NotTo(HaveOccurred())849}()850851// Run the command with stdin redirected852output := runCommandWithStdin(r, "--query", "some-query")853Expect(output).To(ContainSubstring(response))854855Expect(historyDir).To(BeADirectory())856checkHistoryContent := func(expectedContent string) {857content, err := os.ReadFile(historyFile)858Expect(err).NotTo(HaveOccurred())859Expect(string(content)).To(ContainSubstring(expectedContent))860}861checkHistoryContent(response)862})863864it("should return the expected result for the --list-threads flag", func() {865historyDir := path.Join(filePath, "history")866Expect(os.Mkdir(historyDir, 0755)).To(Succeed())867868files := []string{"thread1.json", "thread2.json", "thread3.json", "default.json"}869870Expect(os.MkdirAll(historyDir, 7555)).To(Succeed())871872for _, file := range files {873file, err := os.Create(filepath.Join(historyDir, file))874Expect(err).NotTo(HaveOccurred())875876Expect(file.Close()).To(Succeed())877}878879output := runCommand("--list-threads")880881Expect(output).To(ContainSubstring("* default (current)"))882Expect(output).To(ContainSubstring("- thread1"))883Expect(output).To(ContainSubstring("- thread2"))884Expect(output).To(ContainSubstring("- thread3"))885})886887it("should delete the expected thread using the --delete-threads flag", func() {888historyDir := path.Join(filePath, "history")889Expect(os.Mkdir(historyDir, 0755)).To(Succeed())890891files := []string{"thread1.json", "thread2.json", "thread3.json", "default.json"}892893Expect(os.MkdirAll(historyDir, 7555)).To(Succeed())894895for _, file := range files {896file, err := os.Create(filepath.Join(historyDir, file))897Expect(err).NotTo(HaveOccurred())898899Expect(file.Close()).To(Succeed())900}901902runCommand("--delete-thread", "thread2")903904output := runCommand("--list-threads")905906Expect(output).To(ContainSubstring("* default (current)"))907Expect(output).To(ContainSubstring("- thread1"))908Expect(output).NotTo(ContainSubstring("- thread2"))909Expect(output).To(ContainSubstring("- thread3"))910})911912it("should delete the expected threads using the --delete-threads flag with a wildcard", func() {913historyDir := filepath.Join(filePath, "history")914Expect(os.Mkdir(historyDir, 0755)).To(Succeed())915916files := []string{917"start1.json", "start2.json", "start3.json",918"1end.json", "2end.json", "3end.json",919"1middle1.json", "2middle2.json", "3middle3.json",920"other1.json", "other2.json",921}922923createTestFiles := func(dir string, filenames []string) {924for _, filename := range filenames {925file, err := os.Create(filepath.Join(dir, filename))926Expect(err).NotTo(HaveOccurred())927Expect(file.Close()).To(Succeed())928}929}930931createTestFiles(historyDir, files)932933output := runCommand("--list-threads")934expectedThreads := []string{935"start1", "start2", "start3",936"1end", "2end", "3end",937"1middle1", "2middle2", "3middle3",938"other1", "other2",939}940for _, thread := range expectedThreads {941Expect(output).To(ContainSubstring("- " + thread))942}943944tests := []struct {945pattern string946remainingAfter []string947}{948{"start*", []string{"1end", "2end", "3end", "1middle1", "2middle2", "3middle3", "other1", "other2"}},949{"*end", []string{"1middle1", "2middle2", "3middle3", "other1", "other2"}},950{"*middle*", []string{"other1", "other2"}},951{"*", []string{}}, // Should delete all remaining threads952}953954for _, tt := range tests {955runCommand("--delete-thread", tt.pattern)956output = runCommand("--list-threads")957958for _, thread := range tt.remainingAfter {959Expect(output).To(ContainSubstring("- " + thread))960}961}962})963964it("should throw an error when a non-existent thread is deleted using the --delete-threads flag", func() {965command := exec.Command(binaryPath, "--delete-thread", "does-not-exist")966session, err := gexec.Start(command, io.Discard, io.Discard)967Expect(err).NotTo(HaveOccurred())968969Eventually(session).Should(gexec.Exit(exitFailure))970})971972it("should not throw an error --clear-history is called without there being a history", func() {973command := exec.Command(binaryPath, "--clear-history")974session, err := gexec.Start(command, io.Discard, io.Discard)975Expect(err).NotTo(HaveOccurred())976977Eventually(session).Should(gexec.Exit(exitSuccess))978})979980when("configurable flags are set", func() {981it.Before(func() {982configFile = path.Join(filePath, "config.yaml")983Expect(configFile).NotTo(BeAnExistingFile())984})985986it("has a configurable default model", func() {987oldModel := "gpt-4o"988newModel := "gpt-3.5-turbo-0301"989990// Verify initial model991output := runCommand("--list-models")992Expect(output).To(ContainSubstring("* " + oldModel + " (current)"))993Expect(output).To(ContainSubstring("- " + newModel))994995// Update model996runCommand("--set-model", newModel)997998// Check configFile is created and contains the new model999Expect(configFile).To(BeAnExistingFile())1000checkConfigFileContent(newModel)10011002// Verify updated model through --list-models1003output = runCommand("--list-models")10041005Expect(output).To(ContainSubstring("* " + newModel + " (current)"))1006})10071008it("has a configurable default context-window", func() {1009defaults := config.NewStore().ReadDefaults()10101011// Initial check for default context-window1012output := runCommand("--config")1013Expect(output).To(ContainSubstring(strconv.Itoa(defaults.ContextWindow)))10141015// Update and verify context-window1016newContextWindow := "100000"1017runCommand("--set-context-window", newContextWindow)1018Expect(configFile).To(BeAnExistingFile())1019checkConfigFileContent(newContextWindow)10201021// Verify update through --config1022output = runCommand("--config")1023Expect(output).To(ContainSubstring(newContextWindow))10241025// Environment variable takes precedence1026envContext := "123"1027modelEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "CONTEXT_WINDOW", 1)1028Expect(os.Setenv(modelEnvKey, envContext)).To(Succeed())10291030// Verify environment variable override1031output = runCommand("--config")1032Expect(output).To(ContainSubstring(envContext))1033Expect(os.Unsetenv(modelEnvKey)).To(Succeed())1034})10351036it("has a configurable default max-tokens", func() {1037defaults := config.NewStore().ReadDefaults()10381039// Initial check for default max-tokens1040output := runCommand("--config")1041Expect(output).To(ContainSubstring(strconv.Itoa(defaults.MaxTokens)))10421043// Update and verify max-tokens1044newMaxTokens := "81724"1045runCommand("--set-max-tokens", newMaxTokens)1046Expect(configFile).To(BeAnExistingFile())1047checkConfigFileContent(newMaxTokens)10481049// Verify update through --config1050output = runCommand("--config")1051Expect(output).To(ContainSubstring(newMaxTokens))10521053// Environment variable takes precedence1054modelEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "MAX_TOKENS", 1)1055Expect(os.Setenv(modelEnvKey, newMaxTokens)).To(Succeed())10561057// Verify environment variable override1058output = runCommand("--config")1059Expect(output).To(ContainSubstring(newMaxTokens))1060Expect(os.Unsetenv(modelEnvKey)).To(Succeed())1061})10621063it("has a configurable default thread", func() {1064defaults := config.NewStore().ReadDefaults()10651066// Initial check for default thread1067output := runCommand("--config")1068Expect(output).To(ContainSubstring(defaults.Thread))10691070// Update and verify thread1071newThread := "new-thread"1072runCommand("--set-thread", newThread)1073Expect(configFile).To(BeAnExistingFile())1074checkConfigFileContent(newThread)10751076// Verify update through --config1077output = runCommand("--config")1078Expect(output).To(ContainSubstring(newThread))10791080// Environment variable takes precedence1081threadEnvKey := strings.Replace(apiKeyEnvVar, "API_KEY", "THREAD", 1)1082Expect(os.Setenv(threadEnvKey, newThread)).To(Succeed())10831084// Verify environment variable override1085output = runCommand("--config")1086Expect(output).To(ContainSubstring(newThread))1087Expect(os.Unsetenv(threadEnvKey)).To(Succeed())1088})1089})1090})10911092when("configuration precedence", func() {1093var (1094defaultModel = "gpt-4o"1095newModel = "gpt-3.5-turbo-0301"1096envModel = "gpt-3.5-env-model"1097envVar string1098)10991100it.Before(func() {1101envVar = strings.Replace(apiKeyEnvVar, "API_KEY", "MODEL", 1)1102filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")1103Expect(os.MkdirAll(filePath, 0777)).To(Succeed())11041105configFile = path.Join(filePath, "config.yaml")1106Expect(configFile).NotTo(BeAnExistingFile())1107})11081109it("uses environment variable over config file", func() {1110// Step 1: Set a model in the config file.1111runCommand("--set-model", newModel)1112checkConfigFileContent(newModel)11131114// Step 2: Verify the model from config is used.1115output := runCommand("--list-models")1116Expect(output).To(ContainSubstring("* " + newModel + " (current)"))11171118// Step 3: Set environment variable and verify it takes precedence.1119Expect(os.Setenv(envVar, envModel)).To(Succeed())1120output = runCommand("--list-models")1121Expect(output).To(ContainSubstring("* " + envModel + " (current)"))11221123// Step 4: Unset environment variable and verify it falls back to config file.1124Expect(os.Unsetenv(envVar)).To(Succeed())1125output = runCommand("--list-models")1126Expect(output).To(ContainSubstring("* " + newModel + " (current)"))1127})11281129it("uses command-line flag over environment variable", func() {1130// Step 1: Set environment variable.1131Expect(os.Setenv(envVar, envModel)).To(Succeed())11321133// Step 2: Verify environment variable does not override flag.1134output := runCommand("--model", newModel, "--list-models")1135Expect(output).To(ContainSubstring("* " + newModel + " (current)"))1136})11371138it("falls back to default when config and env are absent", func() {1139// Step 1: Ensure no config file and no environment variable.1140Expect(os.Unsetenv(envVar)).To(Succeed())11411142// Step 2: Verify it falls back to the default model.1143output := runCommand("--list-models")1144Expect(output).To(ContainSubstring("* " + defaultModel + " (current)"))1145})1146})11471148when("show-history flag is used", func() {1149var tmpDir string1150var err error1151var historyFile string11521153it.Before(func() {1154RegisterTestingT(t)1155tmpDir, err = os.MkdirTemp("", "chatgpt-cli-test")1156Expect(err).NotTo(HaveOccurred())1157historyFile = filepath.Join(tmpDir, "default.json")11581159messages := []api.Message{1160{Role: "user", Content: "Hello"},1161{Role: "assistant", Content: "Hi, how can I help you?"},1162{Role: "user", Content: "Tell me about the weather"},1163{Role: "assistant", Content: "It's sunny today."},1164}1165data, err := json.Marshal(messages)1166Expect(err).NotTo(HaveOccurred())11671168Expect(os.WriteFile(historyFile, data, 0644)).To(Succeed())11691170// This is legacy: we need a config dir in order to have a history dir1171filePath = path.Join(os.Getenv("HOME"), ".chatgpt-cli")1172Expect(os.MkdirAll(filePath, 0777)).To(Succeed())11731174Expect(os.Setenv("OPENAI_DATA_HOME", tmpDir)).To(Succeed())1175})11761177it("prints the history for the default thread", func() {1178output := runCommand("--show-history")11791180// Check that the output contains the history as expected1181Expect(output).To(ContainSubstring("**USER** 👤:\nHello"))1182Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nHi, how can I help you?"))1183Expect(output).To(ContainSubstring("**USER** 👤:\nTell me about the weather"))1184Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nIt's sunny today."))1185})11861187it("prints the history for a specific thread when specified", func() {1188specificThread := "specific-thread"1189specificHistoryFile := filepath.Join(tmpDir, specificThread+".json")11901191// Create a specific thread with custom history1192messages := []api.Message{1193{Role: "user", Content: "What's the capital of Belgium?"},1194{Role: "assistant", Content: "The capital of Belgium is Brussels."},1195}1196data, err := json.Marshal(messages)1197Expect(err).NotTo(HaveOccurred())1198Expect(os.WriteFile(specificHistoryFile, data, 0644)).To(Succeed())11991200// Run the --show-history flag with the specific thread1201output := runCommand("--show-history", specificThread)12021203// Check that the output contains the history as expected1204Expect(output).To(ContainSubstring("**USER** 👤:\nWhat's the capital of Belgium?"))1205Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nThe capital of Belgium is Brussels."))1206})12071208it("concatenates user messages correctly", func() {1209// Create history where two user messages are concatenated1210messages := []api.Message{1211{Role: "user", Content: "Part one"},1212{Role: "user", Content: " and part two"},1213{Role: "assistant", Content: "This is a response."},1214}1215data, err := json.Marshal(messages)1216Expect(err).NotTo(HaveOccurred())1217Expect(os.WriteFile(historyFile, data, 0644)).To(Succeed())12181219output := runCommand("--show-history")12201221// Check that the concatenated user messages are displayed correctly1222Expect(output).To(ContainSubstring("**USER** 👤:\nPart one and part two"))1223Expect(output).To(ContainSubstring("**ASSISTANT** 🤖:\nThis is a response."))1224})1225})1226})12271228when("Agent Files ops", func() {1229var (1230tmpDir string1231err error1232ops tools.FSIOFileOps1233)12341235it.Before(func() {1236tmpDir, err = os.MkdirTemp("", "chatgpt-cli-files-it")1237Expect(err).NotTo(HaveOccurred())12381239ops = tools.NewFSIOFileOps(osReader{}, osWriter{})1240})12411242it.After(func() {1243Expect(os.RemoveAll(tmpDir)).To(Succeed())1244})12451246it("WriteFile writes and overwrites full content", func() {1247p := filepath.Join(tmpDir, "a.txt")12481249Expect(ops.WriteFile(p, []byte("hello\n"))).To(Succeed())1250b, err := os.ReadFile(p)1251Expect(err).NotTo(HaveOccurred())1252Expect(string(b)).To(Equal("hello\n"))12531254Expect(ops.WriteFile(p, []byte("goodbye\n"))).To(Succeed())1255b, err = os.ReadFile(p)1256Expect(err).NotTo(HaveOccurred())1257Expect(string(b)).To(Equal("goodbye\n"))1258})12591260it("PatchFile applies unified diff and persists changes", func() {1261p := filepath.Join(tmpDir, "b.txt")1262Expect(os.WriteFile(p, []byte("a\nb\nc\n"), 0o644)).To(Succeed())12631264diff := []byte(1265"@@ -1,3 +1,3 @@\n" +1266" a\n" +1267"-b\n" +1268"+B\n" +1269" c\n",1270)12711272res, err := ops.PatchFile(p, diff)1273Expect(err).NotTo(HaveOccurred())1274Expect(res.Hunks).To(Equal(1))12751276got, err := os.ReadFile(p)1277Expect(err).NotTo(HaveOccurred())1278Expect(string(got)).To(Equal("a\nB\nc\n"))1279})12801281it("PatchFile is a no-op when patch produces no changes", func() {1282p := filepath.Join(tmpDir, "c.txt")1283Expect(os.WriteFile(p, []byte("a\nb\n"), 0o644)).To(Succeed())12841285// Patch that effectively keeps the file identical.1286diff := []byte(1287"@@ -1,2 +1,2 @@\n" +1288" a\n" +1289" b\n",1290)12911292res, err := ops.PatchFile(p, diff)1293Expect(err).NotTo(HaveOccurred())1294Expect(res.Hunks).To(Equal(1))12951296got, err := os.ReadFile(p)1297Expect(err).NotTo(HaveOccurred())1298Expect(string(got)).To(Equal("a\nb\n"))1299})13001301it("PatchFile returns a wrapped error when patch cannot be applied", func() {1302p := filepath.Join(tmpDir, "d.txt")1303Expect(os.WriteFile(p, []byte("a\nb\nc\n"), 0o644)).To(Succeed())13041305// Context mismatch: expects 'x' where file has 'b'1306diff := []byte(1307"@@ -1,3 +1,3 @@\n" +1308" a\n" +1309"-x\n" +1310"+B\n" +1311" c\n",1312)13131314res, err := ops.PatchFile(p, diff)1315Expect(err).To(HaveOccurred())1316Expect(err.Error()).To(ContainSubstring("apply patch " + p + ":"))1317Expect(res.Hunks).To(Equal(1))1318})13191320it("ReplaceBytesInFile replaces all occurrences when n<=0", func() {1321p := filepath.Join(tmpDir, "e.txt")1322Expect(os.WriteFile(p, []byte("aa bb aa bb aa\n"), 0o644)).To(Succeed())13231324res, err := ops.ReplaceBytesInFile(p, []byte("aa"), []byte("XX"), 0)1325Expect(err).NotTo(HaveOccurred())1326Expect(res.OccurrencesFound).To(Equal(3))1327Expect(res.Replaced).To(Equal(3))13281329got, err := os.ReadFile(p)1330Expect(err).NotTo(HaveOccurred())1331Expect(string(got)).To(Equal("XX bb XX bb XX\n"))1332})13331334it("ReplaceBytesInFile replaces only the first n occurrences when n>0", func() {1335p := filepath.Join(tmpDir, "f.txt")1336Expect(os.WriteFile(p, []byte("x x x x\n"), 0o644)).To(Succeed())13371338res, err := ops.ReplaceBytesInFile(p, []byte("x"), []byte("y"), 2)1339Expect(err).NotTo(HaveOccurred())1340Expect(res.OccurrencesFound).To(Equal(4))1341Expect(res.Replaced).To(Equal(2))13421343got, err := os.ReadFile(p)1344Expect(err).NotTo(HaveOccurred())1345Expect(string(got)).To(Equal("y y x x\n"))1346})13471348it("ReplaceBytesInFile errors when old pattern is empty", func() {1349p := filepath.Join(tmpDir, "g.txt")1350Expect(os.WriteFile(p, []byte("hello\n"), 0o644)).To(Succeed())13511352res, err := ops.ReplaceBytesInFile(p, []byte(""), []byte("x"), -1)1353Expect(err).To(HaveOccurred())1354Expect(err.Error()).To(ContainSubstring("old pattern must be non-empty"))1355Expect(res).To(Equal(tools.ReplaceResult{}))1356})13571358it("ReplaceBytesInFile errors when pattern is not found", func() {1359p := filepath.Join(tmpDir, "h.txt")1360Expect(os.WriteFile(p, []byte("hello\n"), 0o644)).To(Succeed())13611362res, err := ops.ReplaceBytesInFile(p, []byte("nope"), []byte("x"), -1)1363Expect(err).To(HaveOccurred())1364Expect(err.Error()).To(ContainSubstring("pattern not found"))1365Expect(res.OccurrencesFound).To(Equal(0))1366Expect(res.Replaced).To(Equal(0))1367})13681369it("ReplaceBytesInFile errors when replacement produces no change", func() {1370p := filepath.Join(tmpDir, "i.txt")1371Expect(os.WriteFile(p, []byte("hello hello\n"), 0o644)).To(Succeed())13721373res, err := ops.ReplaceBytesInFile(p, []byte("hello"), []byte("hello"), -1)1374Expect(err).To(HaveOccurred())1375Expect(err.Error()).To(ContainSubstring("no changes applied"))1376Expect(res.OccurrencesFound).To(Equal(2))1377Expect(res.Replaced).To(Equal(0))1378})1379})1380}13811382type osReader struct{}13831384func (osReader) Open(name string) (*os.File, error) {1385return os.Open(name)1386}13871388func (osReader) ReadBufferFromFile(file *os.File) ([]byte, error) {1389return io.ReadAll(file)1390}13911392func (r osReader) ReadFile(name string) ([]byte, error) {1393f, err := r.Open(name)1394if err != nil {1395return nil, err1396}1397defer func() { _ = f.Close() }()1398return r.ReadBufferFromFile(f)1399}14001401type osWriter struct{}14021403func (osWriter) Create(path string) (*os.File, error) {1404if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {1405return nil, err1406}1407return os.Create(path)1408}14091410func (osWriter) Write(f *os.File, data []byte) error {1411_, err := f.Write(data)1412return err1413}141414151416