diff --git a/cli/tapes/main.go b/cli/tapes/main.go index f13b487..19be589 100644 --- a/cli/tapes/main.go +++ b/cli/tapes/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" tapescmder "github.com/papercomputeco/tapes/cmd/tapes" @@ -8,7 +9,9 @@ import ( func main() { cmd := tapescmder.NewTapesCmd() - if err := cmd.Execute(); err != nil { + err := cmd.Execute() + if err != nil { + fmt.Printf("Error executing root command: %v", err) os.Exit(1) } } diff --git a/cli/tapesapi/main.go b/cli/tapesapi/main.go index c9b23e7..ec82923 100644 --- a/cli/tapesapi/main.go +++ b/cli/tapesapi/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" apicmder "github.com/papercomputeco/tapes/cmd/tapes/serve/api" @@ -8,10 +9,14 @@ import ( func main() { cmd := apicmder.NewAPICmd() + cmd.Use = "tapesapi" cmd.PersistentFlags().BoolP("debug", "d", false, "Enable debug logging") + cmd.PersistentFlags().String("config-dir", "", "Override path to .tapes/ config directory") - if err := cmd.Execute(); err != nil { + err := cmd.Execute() + if err != nil { + fmt.Printf("Error executing root command: %v", err) os.Exit(1) } } diff --git a/cli/tapesprox/main.go b/cli/tapesprox/main.go index d6bc2f1..65b221c 100644 --- a/cli/tapesprox/main.go +++ b/cli/tapesprox/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" proxycmder "github.com/papercomputeco/tapes/cmd/tapes/serve/proxy" @@ -8,10 +9,14 @@ import ( func main() { cmd := proxycmder.NewProxyCmd() + cmd.Use = "tapesproxy" cmd.PersistentFlags().BoolP("debug", "d", false, "Enable debug logging") + cmd.PersistentFlags().String("config-dir", "", "Override path to .tapes/ config directory") - if err := cmd.Execute(); err != nil { + err := cmd.Execute() + if err != nil { + fmt.Printf("Error executing root command: %v", err) os.Exit(1) } } diff --git a/cmd/tapes/chat/chat.go b/cmd/tapes/chat/chat.go index 3100296..208d86b 100644 --- a/cmd/tapes/chat/chat.go +++ b/cmd/tapes/chat/chat.go @@ -17,16 +17,17 @@ import ( "github.com/spf13/cobra" "go.uber.org/zap" + "github.com/papercomputeco/tapes/pkg/config" "github.com/papercomputeco/tapes/pkg/dotdir" "github.com/papercomputeco/tapes/pkg/logger" "github.com/papercomputeco/tapes/pkg/utils" ) type chatCommander struct { - proxy string - api string - model string - debug bool + proxyTarget string + apiTarget string + model string + debug bool logger *zap.Logger } @@ -73,7 +74,7 @@ or "tapes checkout" (no hash provided) to clear the checkout and start fresh. Examples: tapes chat --model llama3.2 - tapes chat --model llama3.2 --proxy http://localhost:8080` + tapes chat --model llama3.2 --proxy-target http://localhost:8080` const chatShortDesc string = "Experimental: Interactive LLM chat through the tapes proxy" @@ -84,6 +85,27 @@ func NewChatCmd() *cobra.Command { Use: "chat", Short: chatShortDesc, Long: chatLongDesc, + PreRunE: func(cmd *cobra.Command, _ []string) error { + configDir, _ := cmd.Flags().GetString("config-dir") + cfger, err := config.NewConfiger(configDir) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + cfg, err := cfger.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + if !cmd.Flags().Changed("api-target") { + cmder.apiTarget = cfg.Client.APITarget + } + + if !cmd.Flags().Changed("proxy-target") { + cmder.proxyTarget = cfg.Client.ProxyTarget + } + return nil + }, RunE: func(cmd *cobra.Command, _ []string) error { var err error cmder.debug, err = cmd.Flags().GetBool("debug") @@ -95,9 +117,9 @@ func NewChatCmd() *cobra.Command { }, } - cmd.PersistentFlags().StringVarP(&cmder.api, "api", "a", "http://localhost:8081", "Tapes API server address") - - cmd.Flags().StringVarP(&cmder.proxy, "proxy", "p", "http://localhost:8080", "Tapes proxy address") + defaults := config.NewDefaultConfig() + cmd.Flags().StringVarP(&cmder.apiTarget, "api-target", "a", defaults.Client.APITarget, "Tapes API server URL") + cmd.Flags().StringVarP(&cmder.proxyTarget, "proxy-target", "p", defaults.Client.ProxyTarget, "Tapes proxy URL") cmd.Flags().StringVarP(&cmder.model, "model", "m", "gemma3:latest", "Model name (e.g., gemma3:1b, ministral-3:latest)") return cmd @@ -199,13 +221,13 @@ func (c *chatCommander) sendAndStream(messages []ollamaMessage) (string, error) } c.logger.Debug("sending chat request", - zap.String("proxy", c.proxy), + zap.String("proxy_target", c.proxyTarget), zap.String("model", c.model), zap.Int("message_count", len(messages)), ) // POST to the proxy's Ollama-compatible chat endpoint - url := c.proxy + "/api/chat" + url := c.proxyTarget + "/api/chat" httpReq, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("creating request: %w", err) diff --git a/cmd/tapes/chat/chat_test.go b/cmd/tapes/chat/chat_test.go index 516ae36..f78a1f1 100644 --- a/cmd/tapes/chat/chat_test.go +++ b/cmd/tapes/chat/chat_test.go @@ -27,18 +27,16 @@ var _ = Describe("NewChatCmd", func() { Expect(flag.Shorthand).To(Equal("m")) }) - It("has --proxy flag with default value", func() { + It("has --proxy-target flag", func() { cmd := chatcmder.NewChatCmd() - flag := cmd.Flags().Lookup("proxy") + flag := cmd.Flags().Lookup("proxy-target") Expect(flag).NotTo(BeNil()) - Expect(flag.DefValue).To(Equal("http://localhost:8080")) }) - It("has persistent --api flag with default value", func() { + It("has --api-target flag", func() { cmd := chatcmder.NewChatCmd() - flag := cmd.PersistentFlags().Lookup("api") + flag := cmd.Flags().Lookup("api-target") Expect(flag).NotTo(BeNil()) - Expect(flag.DefValue).To(Equal("http://localhost:8081")) }) }) diff --git a/cmd/tapes/checkout/checkout.go b/cmd/tapes/checkout/checkout.go index 0cad95d..02a3760 100644 --- a/cmd/tapes/checkout/checkout.go +++ b/cmd/tapes/checkout/checkout.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" "go.uber.org/zap" + "github.com/papercomputeco/tapes/pkg/config" "github.com/papercomputeco/tapes/pkg/dotdir" "github.com/papercomputeco/tapes/pkg/llm" "github.com/papercomputeco/tapes/pkg/logger" @@ -21,9 +22,9 @@ import ( ) type checkoutCommander struct { - hash string - api string - debug bool + hash string + apiTarget string + debug bool logger *zap.Logger } @@ -69,6 +70,23 @@ func NewCheckoutCmd() *cobra.Command { Short: checkoutShortDesc, Long: checkoutLongDesc, Args: cobra.MaximumNArgs(1), + PreRunE: func(cmd *cobra.Command, _ []string) error { + configDir, _ := cmd.Flags().GetString("config-dir") + cfger, err := config.NewConfiger(configDir) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + cfg, err := cfger.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + if !cmd.Flags().Changed("api-target") { + cmder.apiTarget = cfg.Client.APITarget + } + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { cmder.hash = args[0] @@ -80,16 +98,12 @@ func NewCheckoutCmd() *cobra.Command { return fmt.Errorf("could not get debug flag: %w", err) } - cmder.api, err = cmd.Flags().GetString("api") - if err != nil { - return fmt.Errorf("could not get api flag: %w", err) - } - return cmder.run() }, } - cmd.PersistentFlags().StringVarP(&cmder.api, "api", "a", "http://localhost:8081", "Tapes API server address") + defaults := config.NewDefaultConfig() + cmd.Flags().StringVarP(&cmder.apiTarget, "api-target", "a", defaults.Client.APITarget, "Tapes API server URL") return cmd } @@ -110,7 +124,7 @@ func (c *checkoutCommander) run() error { c.logger.Debug("checking out conversation", zap.String("hash", c.hash), - zap.String("api", c.api), + zap.String("api_target", c.apiTarget), ) // Fetch the conversation history from the API @@ -149,7 +163,7 @@ func (c *checkoutCommander) run() error { // fetchHistory calls the API to get the conversation history for a given hash. func (c *checkoutCommander) fetchHistory(hash string) (*historyResponse, error) { - url := fmt.Sprintf("%s/dag/history/%s", c.api, hash) + url := fmt.Sprintf("%s/dag/history/%s", c.apiTarget, hash) client := &http.Client{Timeout: 10 * time.Second} req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) diff --git a/cmd/tapes/config/config.go b/cmd/tapes/config/config.go new file mode 100644 index 0000000..1a7fd07 --- /dev/null +++ b/cmd/tapes/config/config.go @@ -0,0 +1,47 @@ +// Package configcmder provides the config command for managing persistent +// tapes configuration stored in the .tapes/ directory. +package configcmder + +import ( + "github.com/spf13/cobra" +) + +const configLongDesc string = `Manage persistent tapes configuration. + +Configuration is stored as config.toml in the .tapes/ directory and provides +default values for command flags. CLI flags always take precedence over +config file values. + +Keys use dotted notation matching the TOML section structure: + proxy.provider, proxy.upstream, proxy.listen, + api.listen, storage.sqlite_path, + client.proxy_target, client.api_target, + vector_store.provider, vector_store.target, + embedding.provider, embedding.target, embedding.model, embedding.dimensions + +Use subcommands to get, set, or list configuration values: + tapes config set Set a configuration value + tapes config get Get a configuration value + tapes config list List all configuration values + +Examples: + tapes config set proxy.provider anthropic + tapes config set embedding.model nomic-embed-text + tapes config get proxy.provider + tapes config list` + +const configShortDesc string = "Manage persistent tapes configuration" + +func NewConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: configShortDesc, + Long: configLongDesc, + } + + cmd.AddCommand(newSetCmd()) + cmd.AddCommand(newGetCmd()) + cmd.AddCommand(newListCmd()) + + return cmd +} diff --git a/cmd/tapes/config/config_suite_test.go b/cmd/tapes/config/config_suite_test.go new file mode 100644 index 0000000..64d0864 --- /dev/null +++ b/cmd/tapes/config/config_suite_test.go @@ -0,0 +1,13 @@ +package configcmder_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestConfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Config Command Suite") +} diff --git a/cmd/tapes/config/config_test.go b/cmd/tapes/config/config_test.go new file mode 100644 index 0000000..1304ef6 --- /dev/null +++ b/cmd/tapes/config/config_test.go @@ -0,0 +1,164 @@ +package configcmder_test + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + configcmder "github.com/papercomputeco/tapes/cmd/tapes/config" +) + +var _ = Describe("NewConfigCmd", func() { + It("creates a command with the correct use string", func() { + cmd := configcmder.NewConfigCmd() + Expect(cmd.Use).To(Equal("config")) + }) + + It("has set, get, and list subcommands", func() { + cmd := configcmder.NewConfigCmd() + cmds := cmd.Commands() + subcommands := make([]string, 0, len(cmds)) + for _, sub := range cmds { + subcommands = append(subcommands, sub.Name()) + } + Expect(subcommands).To(ContainElements("set", "get", "list")) + }) +}) + +var _ = Describe("Config command execution", func() { + var ( + tmpDir string + origDir string + ) + + BeforeEach(func() { + var err error + tmpDir, err = os.MkdirTemp("", "tapes-config-test-*") + Expect(err).NotTo(HaveOccurred()) + + origDir, err = os.Getwd() + Expect(err).NotTo(HaveOccurred()) + + // Create a local .tapes dir so the manager picks it up + err = os.MkdirAll(filepath.Join(tmpDir, ".tapes"), 0o755) + Expect(err).NotTo(HaveOccurred()) + + err = os.Chdir(tmpDir) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + err := os.Chdir(origDir) + Expect(err).NotTo(HaveOccurred()) + os.RemoveAll(tmpDir) + }) + + Describe("set subcommand", func() { + It("sets a config value successfully", func() { + cmd := configcmder.NewConfigCmd() + cmd.SetArgs([]string{"set", "proxy.provider", "anthropic"}) + err := cmd.Execute() + Expect(err).NotTo(HaveOccurred()) + + // Verify the config file was created + _, err = os.Stat(filepath.Join(tmpDir, ".tapes", "config.toml")) + Expect(err).NotTo(HaveOccurred()) + }) + + It("rejects unknown keys", func() { + cmd := configcmder.NewConfigCmd() + cmd.SetArgs([]string{"set", "invalid_key", "value"}) + err := cmd.Execute() + Expect(err).To(HaveOccurred()) + }) + + It("requires exactly two arguments", func() { + cmd := configcmder.NewConfigCmd() + cmd.SetArgs([]string{"set", "proxy.provider"}) + err := cmd.Execute() + Expect(err).To(HaveOccurred()) + }) + + It("rejects zero arguments", func() { + cmd := configcmder.NewConfigCmd() + cmd.SetArgs([]string{"set"}) + err := cmd.Execute() + Expect(err).To(HaveOccurred()) + }) + + It("rejects invalid uint values", func() { + cmd := configcmder.NewConfigCmd() + cmd.SetArgs([]string{"set", "embedding.dimensions", "not-a-number"}) + err := cmd.Execute() + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("get subcommand", func() { + It("gets a previously set value", func() { + // First set a value + setCmd := configcmder.NewConfigCmd() + setCmd.SetArgs([]string{"set", "proxy.provider", "anthropic"}) + err := setCmd.Execute() + Expect(err).NotTo(HaveOccurred()) + + // Then get it + getCmd := configcmder.NewConfigCmd() + getCmd.SetArgs([]string{"get", "proxy.provider"}) + err = getCmd.Execute() + Expect(err).NotTo(HaveOccurred()) + }) + + It("runs without error for unset key", func() { + getCmd := configcmder.NewConfigCmd() + getCmd.SetArgs([]string{"get", "proxy.provider"}) + err := getCmd.Execute() + Expect(err).NotTo(HaveOccurred()) + }) + + It("rejects unknown keys", func() { + cmd := configcmder.NewConfigCmd() + cmd.SetArgs([]string{"get", "invalid_key"}) + err := cmd.Execute() + Expect(err).To(HaveOccurred()) + }) + + It("requires exactly one argument", func() { + cmd := configcmder.NewConfigCmd() + cmd.SetArgs([]string{"get"}) + err := cmd.Execute() + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("list subcommand", func() { + It("runs without error when no config exists", func() { + cmd := configcmder.NewConfigCmd() + cmd.SetArgs([]string{"list"}) + err := cmd.Execute() + Expect(err).NotTo(HaveOccurred()) + }) + + It("runs without error when config has values", func() { + // Set some values first + setCmd := configcmder.NewConfigCmd() + setCmd.SetArgs([]string{"set", "proxy.provider", "anthropic"}) + err := setCmd.Execute() + Expect(err).NotTo(HaveOccurred()) + + cmd := configcmder.NewConfigCmd() + cmd.SetArgs([]string{"list"}) + err = cmd.Execute() + Expect(err).NotTo(HaveOccurred()) + }) + + It("rejects any arguments", func() { + cmd := configcmder.NewConfigCmd() + cmd.SetArgs([]string{"list", "extra"}) + err := cmd.Execute() + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/cmd/tapes/config/get.go b/cmd/tapes/config/get.go new file mode 100644 index 0000000..011457b --- /dev/null +++ b/cmd/tapes/config/get.go @@ -0,0 +1,75 @@ +package configcmder + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/papercomputeco/tapes/pkg/config" +) + +const getLongDesc string = `Get a configuration value. + +Reads the value for the given key from the config.toml file +stored in the .tapes/ directory. Keys use dotted notation matching +the TOML section structure. + +Examples: + tapes config get proxy.provider + tapes config get embedding.model` + +const getShortDesc string = "Get a configuration value" + +func newGetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: getShortDesc, + Long: getLongDesc, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + configDir, _ := cmd.Flags().GetString("config-dir") + return runGet(args[0], configDir) + }, + ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return config.ValidConfigKeys(), cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + } + + return cmd +} + +func runGet(key, configDir string) error { + if !config.IsValidConfigKey(key) { + return fmt.Errorf("unknown config key: %q\n\nValid keys: %s", + key, strings.Join(config.ValidConfigKeys(), ", ")) + } + + cfger, err := config.NewConfiger(configDir) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + target := cfger.GetTarget() + if target != "" { + fmt.Printf("Using config file: %s\n\n", cfger.GetTarget()) + } else { + fmt.Print("No config file found. Using default config.\n\n") + } + + value, err := cfger.GetConfigValue(key) + if err != nil { + return err + } + + if value == "" { + fmt.Println("") + } else { + fmt.Println(value) + } + + return nil +} diff --git a/cmd/tapes/config/list.go b/cmd/tapes/config/list.go new file mode 100644 index 0000000..78c3dd0 --- /dev/null +++ b/cmd/tapes/config/list.go @@ -0,0 +1,73 @@ +package configcmder + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/papercomputeco/tapes/pkg/config" +) + +const listLongDesc string = `List all configuration values. + +Displays all configuration keys and their current values from the +config.toml file stored in the .tapes/ directory. + +Examples: + tapes config list` + +const listShortDesc string = "List all configuration values" + +func newListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: listShortDesc, + Long: listLongDesc, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + configDir, _ := cmd.Flags().GetString("config-dir") + return runList(configDir) + }, + } + + return cmd +} + +func runList(configDir string) error { + cfger, err := config.NewConfiger(configDir) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + target := cfger.GetTarget() + if target != "" { + fmt.Printf("Using config file: %s\n\n", cfger.GetTarget()) + } else { + fmt.Print("No config file found. Using default config.\n\n") + } + + keys := config.ValidConfigKeys() + + // Find the longest key name for alignment. + maxLen := 0 + for _, k := range keys { + if len(k) > maxLen { + maxLen = len(k) + } + } + + for _, key := range keys { + value, err := cfger.GetConfigValue(key) + if err != nil { + return err + } + + if value == "" { + fmt.Printf("%-*s = \n", maxLen, key) + } else { + fmt.Printf("%-*s = %q\n", maxLen, key, value) + } + } + + return nil +} diff --git a/cmd/tapes/config/set.go b/cmd/tapes/config/set.go new file mode 100644 index 0000000..47a84ec --- /dev/null +++ b/cmd/tapes/config/set.go @@ -0,0 +1,79 @@ +package configcmder + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/papercomputeco/tapes/pkg/config" +) + +const setLongDesc string = `Set a configuration value. + +Sets the given key to the provided value in the config.toml file +stored in the .tapes/ directory. Keys use dotted notation matching +the TOML section structure. + +Valid keys: + storage.sqlite_path, + proxy.provider, proxy.upstream, proxy.listen, + api.listen, + client.proxy_target, client.api_target, + vector_store.provider, vector_store.target, + embedding.provider, embedding.target, embedding.model, embedding.dimensions + +Examples: + tapes config set proxy.provider anthropic + tapes config set proxy.upstream https://api.anthropic.com + tapes config set embedding.dimensions 768` + +const setShortDesc string = "Set a configuration value" + +func newSetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "set ", + Short: setShortDesc, + Long: setLongDesc, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + configDir, _ := cmd.Flags().GetString("config-dir") + return runSet(args[0], args[1], configDir) + }, + ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return config.ValidConfigKeys(), cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + } + + return cmd +} + +func runSet(key, value, configDir string) error { + if !config.IsValidConfigKey(key) { + return fmt.Errorf("unknown config key: %q\n\nValid keys: %s", + key, strings.Join(config.ValidConfigKeys(), ", ")) + } + + cfger, err := config.NewConfiger(configDir) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + target := cfger.GetTarget() + if target != "" { + fmt.Printf("Using config file: %s\n\n", cfger.GetTarget()) + } else { + fmt.Print("No config file found. Using default config.\n\n") + } + + err = cfger.SetConfigValue(key, value) + if err != nil { + return err + } + + fmt.Printf("Set %s = %q\n", key, value) + return nil +} diff --git a/cmd/tapes/init/init.go b/cmd/tapes/init/init.go index 7e5f2b8..c1088a9 100644 --- a/cmd/tapes/init/init.go +++ b/cmd/tapes/init/init.go @@ -4,10 +4,15 @@ package initcmder import ( "fmt" + "io" + "net/http" "os" "path/filepath" + "strings" "github.com/spf13/cobra" + + "github.com/papercomputeco/tapes/pkg/config" ) const ( @@ -20,28 +25,40 @@ Creates a local .tapes/ directory that takes precedence over the default ~/.tapes/ directory for checkout state, storage, configuration, and other tapes operations. -This is useful for maintaining separate tapes state per project or directory. +A config.toml file is created with default configuration values. +Use --preset to initialize with a provider preset or a remote config URL. + +Available presets: openai, anthropic, ollama Examples: - tapes init` + tapes init + tapes init --preset openai + tapes init --preset anthropic + tapes init --preset ollama + tapes init --preset https://example.com/config.toml` const initShortDesc string = "Initialize a local .tapes/ directory" func NewInitCmd() *cobra.Command { + var preset string + cmd := &cobra.Command{ Use: "init", Short: initShortDesc, Long: initLongDesc, Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - return runInit() + RunE: func(cmd *cobra.Command, _ []string) error { + configDir, _ := cmd.Flags().GetString("config-dir") + return runInit(preset, configDir) }, } + cmd.Flags().StringVar(&preset, "preset", "", "Provider preset (openai, anthropic, ollama) or URL to a raw config.toml") + return cmd } -func runInit() error { +func runInit(preset, configDir string) error { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("getting current directory: %w", err) @@ -49,16 +66,80 @@ func runInit() error { dir := filepath.Join(cwd, dirName) - info, err := os.Stat(dir) - if err == nil && info.IsDir() { - fmt.Printf("Already initialized: %s\n", dir) - return nil - } - if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("creating .tapes directory: %w", err) } - fmt.Printf("Initialized .tapes directory: %s\n", dir) + configPath := filepath.Join(dir, "config.toml") + if _, err := os.Stat(configPath); os.IsNotExist(err) { + if err := os.WriteFile(configPath, []byte{}, 0o600); err != nil { + return fmt.Errorf("creating config.toml: %w", err) + } + } + + // Resolve the config to write. + cfg, err := resolveConfig(preset) + if err != nil { + return err + } + + // Save the config into the .tapes/ directory. + cfger, err := config.NewConfiger(configDir) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + if err := cfger.SaveConfig(cfg); err != nil { + return fmt.Errorf("writing config.toml: %w", err) + } + + if preset != "" { + fmt.Printf("Configuration written: %s\n", filepath.Join(dir, "config.toml")) + } else { + fmt.Printf("Default configuration written: %s\n", filepath.Join(dir, "config.toml")) + } + return nil } + +// resolveConfig determines the Config to use based on the --preset flag value. +// If empty, returns a default config. If a known preset name, returns the preset. +// If a URL (starts with http:// or https://), fetches and parses the remote TOML. +func resolveConfig(preset string) (*config.Config, error) { + if preset == "" { + return config.NewDefaultConfig(), nil + } + + // Check if it's a URL. + if strings.HasPrefix(preset, "http://") || strings.HasPrefix(preset, "https://") { + return fetchRemoteConfig(preset) + } + + // Otherwise treat it as a preset name. + return config.PresetConfig(preset) +} + +// fetchRemoteConfig downloads a config.toml from the given URL and parses it. +func fetchRemoteConfig(url string) (*config.Config, error) { + resp, err := http.Get(url) //nolint:gosec,noctx // User-provided URL is intentional. + if err != nil { + return nil, fmt.Errorf("fetching remote config from %q: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetching remote config from %q: HTTP %d", url, resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading remote config from %q: %w", url, err) + } + + cfg, err := config.ParseConfigTOML(data) + if err != nil { + return nil, fmt.Errorf("parsing remote config from %q: %w", url, err) + } + + return cfg, nil +} diff --git a/cmd/tapes/init/init_test.go b/cmd/tapes/init/init_test.go index 4caf470..6c332ff 100644 --- a/cmd/tapes/init/init_test.go +++ b/cmd/tapes/init/init_test.go @@ -1,13 +1,18 @@ package initcmder_test import ( + "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" + "github.com/BurntSushi/toml" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" initcmder "github.com/papercomputeco/tapes/cmd/tapes/init" + "github.com/papercomputeco/tapes/pkg/config" ) var _ = Describe("NewInitCmd", func() { @@ -27,6 +32,13 @@ var _ = Describe("NewInitCmd", func() { err := cmd.Args(cmd, []string{"extra"}) Expect(err).To(HaveOccurred()) }) + + It("has a --preset flag", func() { + cmd := initcmder.NewInitCmd() + f := cmd.Flags().Lookup("preset") + Expect(f).NotTo(BeNil()) + Expect(f.DefValue).To(Equal("")) + }) }) var _ = Describe("Init command execution", func() { @@ -64,6 +76,20 @@ var _ = Describe("Init command execution", func() { Expect(info.IsDir()).To(BeTrue()) }) + It("creates a config.toml with default values", func() { + cmd := initcmder.NewInitCmd() + cmd.SetArgs([]string{}) + err := cmd.Execute() + Expect(err).NotTo(HaveOccurred()) + + cfg := loadConfig(tmpDir) + Expect(cfg.Version).To(Equal(config.CurrentV)) + Expect(cfg.Proxy.Provider).To(Equal("ollama")) + Expect(cfg.Proxy.Upstream).To(Equal("http://localhost:11434")) + Expect(cfg.Proxy.Listen).To(Equal(":8080")) + Expect(cfg.API.Listen).To(Equal(":8081")) + }) + It("succeeds when .tapes directory already exists", func() { err := os.MkdirAll(filepath.Join(tmpDir, ".tapes"), 0o755) Expect(err).NotTo(HaveOccurred()) @@ -98,4 +124,161 @@ var _ = Describe("Init command execution", func() { Expect(err).NotTo(HaveOccurred()) Expect(string(data)).To(Equal(`{"hash":"abc"}`)) }) + + Describe("--preset with provider presets", func() { + It("creates config.toml with openai preset", func() { + cmd := initcmder.NewInitCmd() + cmd.SetArgs([]string{"--preset", "openai"}) + err := cmd.Execute() + Expect(err).NotTo(HaveOccurred()) + + cfg := loadConfig(tmpDir) + Expect(cfg.Version).To(Equal(config.CurrentV)) + Expect(cfg.Proxy.Provider).To(Equal("openai")) + Expect(cfg.Proxy.Upstream).To(Equal("https://api.openai.com")) + Expect(cfg.Proxy.Listen).To(Equal(":8080")) + Expect(cfg.API.Listen).To(Equal(":8081")) + }) + + It("creates config.toml with anthropic preset", func() { + cmd := initcmder.NewInitCmd() + cmd.SetArgs([]string{"--preset", "anthropic"}) + err := cmd.Execute() + Expect(err).NotTo(HaveOccurred()) + + cfg := loadConfig(tmpDir) + Expect(cfg.Version).To(Equal(config.CurrentV)) + Expect(cfg.Proxy.Provider).To(Equal("anthropic")) + Expect(cfg.Proxy.Upstream).To(Equal("https://api.anthropic.com")) + Expect(cfg.Proxy.Listen).To(Equal(":8080")) + Expect(cfg.API.Listen).To(Equal(":8081")) + }) + + It("creates config.toml with ollama preset", func() { + cmd := initcmder.NewInitCmd() + cmd.SetArgs([]string{"--preset", "ollama"}) + err := cmd.Execute() + Expect(err).NotTo(HaveOccurred()) + + cfg := loadConfig(tmpDir) + Expect(cfg.Version).To(Equal(config.CurrentV)) + Expect(cfg.Proxy.Provider).To(Equal("ollama")) + Expect(cfg.Proxy.Upstream).To(Equal("http://localhost:11434")) + Expect(cfg.Embedding.Provider).To(Equal("ollama")) + Expect(cfg.Embedding.Target).To(Equal("http://localhost:11434")) + Expect(cfg.Embedding.Model).To(Equal("nomic-embed-text")) + Expect(cfg.Embedding.Dimensions).To(Equal(uint(768))) + }) + + It("rejects unknown preset names", func() { + cmd := initcmder.NewInitCmd() + cmd.SetArgs([]string{"--preset", "invalid-provider"}) + err := cmd.Execute() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown preset")) + }) + }) + + Describe("--preset with remote URL", func() { + It("fetches and writes remote config.toml", func() { + remoteCfg := `version = 0 + +[proxy] +provider = "openai" +upstream = "https://api.openai.com/v1" +listen = ":9090" + +[embedding] +model = "text-embedding-3-small" +dimensions = 1536 +` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, remoteCfg) + })) + defer server.Close() + + cmd := initcmder.NewInitCmd() + cmd.SetArgs([]string{"--preset", server.URL}) + err := cmd.Execute() + Expect(err).NotTo(HaveOccurred()) + + cfg := loadConfig(tmpDir) + Expect(cfg.Version).To(Equal(0)) + Expect(cfg.Proxy.Provider).To(Equal("openai")) + Expect(cfg.Proxy.Upstream).To(Equal("https://api.openai.com/v1")) + Expect(cfg.Proxy.Listen).To(Equal(":9090")) + Expect(cfg.Embedding.Model).To(Equal("text-embedding-3-small")) + Expect(cfg.Embedding.Dimensions).To(Equal(uint(1536))) + }) + + It("returns error for non-200 HTTP response", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + cmd := initcmder.NewInitCmd() + cmd.SetArgs([]string{"--preset", server.URL}) + err := cmd.Execute() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("HTTP 404")) + }) + + It("returns error for invalid TOML from URL", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, "this is not valid toml [[[") + })) + defer server.Close() + + cmd := initcmder.NewInitCmd() + cmd.SetArgs([]string{"--preset", server.URL}) + err := cmd.Execute() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("parsing")) + }) + + It("returns error for unreachable URL", func() { + cmd := initcmder.NewInitCmd() + cmd.SetArgs([]string{"--preset", "http://127.0.0.1:1"}) + err := cmd.Execute() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("fetching remote config")) + }) + }) + + Describe("--preset overwrites config on re-init", func() { + It("overwrites existing config.toml when re-running with a different preset", func() { + // First init with openai + cmd1 := initcmder.NewInitCmd() + cmd1.SetArgs([]string{"--preset", "openai"}) + err := cmd1.Execute() + Expect(err).NotTo(HaveOccurred()) + + cfg := loadConfig(tmpDir) + Expect(cfg.Proxy.Provider).To(Equal("openai")) + + // Re-init with anthropic + cmd2 := initcmder.NewInitCmd() + cmd2.SetArgs([]string{"--preset", "anthropic"}) + err = cmd2.Execute() + Expect(err).NotTo(HaveOccurred()) + + cfg = loadConfig(tmpDir) + Expect(cfg.Proxy.Provider).To(Equal("anthropic")) + }) + }) }) + +// loadConfig is a test helper that reads and parses the config.toml from the +// .tapes directory within the given base directory. +func loadConfig(baseDir string) *config.Config { + configPath := filepath.Join(baseDir, ".tapes", "config.toml") + data, err := os.ReadFile(configPath) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + + cfg := &config.Config{} + err = toml.Unmarshal(data, cfg) + ExpectWithOffset(1, err).NotTo(HaveOccurred()) + return cfg +} diff --git a/cmd/tapes/search/search.go b/cmd/tapes/search/search.go index 475e4a9..e53a00b 100644 --- a/cmd/tapes/search/search.go +++ b/cmd/tapes/search/search.go @@ -15,6 +15,7 @@ import ( "go.uber.org/zap" apisearch "github.com/papercomputeco/tapes/api/search" + "github.com/papercomputeco/tapes/pkg/config" "github.com/papercomputeco/tapes/pkg/logger" ) @@ -52,6 +53,23 @@ func NewSearchCmd() *cobra.Command { Short: searchShortDesc, Long: searchLongDesc, Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, _ []string) error { + configDir, _ := cmd.Flags().GetString("config-dir") + cfger, err := config.NewConfiger(configDir) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + cfg, err := cfger.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + if !cmd.Flags().Changed("api-target") { + cmder.apiTarget = cfg.Client.APITarget + } + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { cmder.query = args[0] @@ -65,8 +83,9 @@ func NewSearchCmd() *cobra.Command { }, } + defaults := config.NewDefaultConfig() cmd.Flags().IntVarP(&cmder.topK, "top", "k", 5, "Number of results to return") - cmd.Flags().StringVar(&cmder.apiTarget, "api-target", "http://localhost:8081", "Tapes API server URL") + cmd.Flags().StringVar(&cmder.apiTarget, "api-target", defaults.Client.APITarget, "Tapes API server URL") return cmd } diff --git a/cmd/tapes/serve/api/api.go b/cmd/tapes/serve/api/api.go index a10f6eb..6e29960 100644 --- a/cmd/tapes/serve/api/api.go +++ b/cmd/tapes/serve/api/api.go @@ -9,6 +9,7 @@ import ( "go.uber.org/zap" "github.com/papercomputeco/tapes/api" + "github.com/papercomputeco/tapes/pkg/config" "github.com/papercomputeco/tapes/pkg/logger" "github.com/papercomputeco/tapes/pkg/merkle" "github.com/papercomputeco/tapes/pkg/storage" @@ -34,6 +35,26 @@ func NewAPICmd() *cobra.Command { Use: "api", Short: apiShortDesc, Long: apiLongDesc, + PreRunE: func(cmd *cobra.Command, _ []string) error { + configDir, _ := cmd.Flags().GetString("config-dir") + cfger, err := config.NewConfiger(configDir) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + cfg, err := cfger.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + if !cmd.Flags().Changed("listen") { + cmder.listen = cfg.API.Listen + } + if !cmd.Flags().Changed("sqlite") { + cmder.sqlitePath = cfg.Storage.SQLitePath + } + return nil + }, RunE: func(cmd *cobra.Command, _ []string) error { var err error cmder.debug, err = cmd.Flags().GetBool("debug") @@ -45,7 +66,8 @@ func NewAPICmd() *cobra.Command { }, } - cmd.Flags().StringVarP(&cmder.listen, "listen", "l", ":8081", "Address for API server to listen on") + defaults := config.NewDefaultConfig() + cmd.Flags().StringVarP(&cmder.listen, "listen", "l", defaults.API.Listen, "Address for API server to listen on") cmd.Flags().StringVarP(&cmder.sqlitePath, "sqlite", "s", "", "Path to SQLite database (default: in-memory)") return cmd diff --git a/cmd/tapes/serve/proxy/proxy.go b/cmd/tapes/serve/proxy/proxy.go index ff74ece..b8d7784 100644 --- a/cmd/tapes/serve/proxy/proxy.go +++ b/cmd/tapes/serve/proxy/proxy.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "go.uber.org/zap" + "github.com/papercomputeco/tapes/pkg/config" embeddingutils "github.com/papercomputeco/tapes/pkg/embeddings/utils" "github.com/papercomputeco/tapes/pkg/logger" "github.com/papercomputeco/tapes/pkg/storage" @@ -53,6 +54,47 @@ func NewProxyCmd() *cobra.Command { Use: "proxy", Short: proxyShortDesc, Long: proxyLongDesc, + PreRunE: func(cmd *cobra.Command, _ []string) error { + configDir, _ := cmd.Flags().GetString("config-dir") + cfger, err := config.NewConfiger(configDir) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + cfg, err := cfger.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + if !cmd.Flags().Changed("listen") { + cmder.listen = cfg.Proxy.Listen + } + if !cmd.Flags().Changed("upstream") { + cmder.upstream = cfg.Proxy.Upstream + } + if !cmd.Flags().Changed("provider") { + cmder.providerType = cfg.Proxy.Provider + } + if !cmd.Flags().Changed("sqlite") { + cmder.sqlitePath = cfg.Storage.SQLitePath + } + if !cmd.Flags().Changed("vector-store-provider") { + cmder.vectorStoreProvider = cfg.VectorStore.Provider + } + if !cmd.Flags().Changed("vector-store-target") { + cmder.vectorStoreTarget = cfg.VectorStore.Target + } + if !cmd.Flags().Changed("embedding-provider") { + cmder.embeddingProvider = cfg.Embedding.Provider + } + if !cmd.Flags().Changed("embedding-target") { + cmder.embeddingTarget = cfg.Embedding.Target + } + if !cmd.Flags().Changed("embedding-model") { + cmder.embeddingModel = cfg.Embedding.Model + } + return nil + }, RunE: func(cmd *cobra.Command, _ []string) error { var err error cmder.debug, err = cmd.Flags().GetBool("debug") @@ -64,15 +106,16 @@ func NewProxyCmd() *cobra.Command { }, } - cmd.Flags().StringVarP(&cmder.listen, "listen", "l", ":8080", "Address for proxy to listen on") - cmd.Flags().StringVarP(&cmder.upstream, "upstream", "u", "http://localhost:11434", "Upstream LLM provider URL") - cmd.Flags().StringVarP(&cmder.providerType, "provider", "p", "ollama", "LLM provider type (anthropic, openai, ollama)") + defaults := config.NewDefaultConfig() + cmd.Flags().StringVarP(&cmder.listen, "listen", "l", defaults.Proxy.Listen, "Address for proxy to listen on") + cmd.Flags().StringVarP(&cmder.upstream, "upstream", "u", defaults.Proxy.Upstream, "Upstream LLM provider URL") + cmd.Flags().StringVarP(&cmder.providerType, "provider", "p", defaults.Proxy.Provider, "LLM provider type (anthropic, openai, ollama)") cmd.Flags().StringVarP(&cmder.sqlitePath, "sqlite", "s", "", "Path to SQLite database (default: in-memory)") - cmd.Flags().StringVar(&cmder.vectorStoreProvider, "vector-store-provider", "sqlite", "Vector store provider type (e.g., chroma, sqlite)") - cmd.Flags().StringVar(&cmder.vectorStoreTarget, "vector-store-target", "", "Vector store URL (e.g., http://localhost:8000)") - cmd.Flags().StringVar(&cmder.embeddingProvider, "embedding-provider", "", "Embedding provider type (e.g., ollama)") - cmd.Flags().StringVar(&cmder.embeddingTarget, "embedding-target", "", "Embedding provider URL") - cmd.Flags().StringVar(&cmder.embeddingModel, "embedding-model", "", "Embedding model name (e.g., nomic-embed-text)") + cmd.Flags().StringVar(&cmder.vectorStoreProvider, "vector-store-provider", defaults.VectorStore.Provider, "Vector store provider type (e.g., chroma, sqlite)") + cmd.Flags().StringVar(&cmder.vectorStoreTarget, "vector-store-target", defaults.VectorStore.Target, "Vector store URL (e.g., http://localhost:8000)") + cmd.Flags().StringVar(&cmder.embeddingProvider, "embedding-provider", defaults.Embedding.Provider, "Embedding provider type (e.g., ollama)") + cmd.Flags().StringVar(&cmder.embeddingTarget, "embedding-target", defaults.Embedding.Target, "Embedding provider URL") + cmd.Flags().StringVar(&cmder.embeddingModel, "embedding-model", defaults.Embedding.Model, "Embedding model name (e.g., nomic-embed-text)") return cmd } diff --git a/cmd/tapes/serve/serve.go b/cmd/tapes/serve/serve.go index 3738e8c..1b76ee8 100644 --- a/cmd/tapes/serve/serve.go +++ b/cmd/tapes/serve/serve.go @@ -15,6 +15,7 @@ import ( "github.com/papercomputeco/tapes/api" apicmder "github.com/papercomputeco/tapes/cmd/tapes/serve/api" proxycmder "github.com/papercomputeco/tapes/cmd/tapes/serve/proxy" + "github.com/papercomputeco/tapes/pkg/config" "github.com/papercomputeco/tapes/pkg/dotdir" embeddingutils "github.com/papercomputeco/tapes/pkg/embeddings/utils" "github.com/papercomputeco/tapes/pkg/logger" @@ -65,6 +66,69 @@ func NewServeCmd() *cobra.Command { Use: "serve", Short: serveShortDesc, Long: serveLongDesc, + PreRunE: func(cmd *cobra.Command, _ []string) error { + configDir, _ := cmd.Flags().GetString("config-dir") + cfger, err := config.NewConfiger(configDir) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + cfg, err := cfger.LoadConfig() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + // Resolve default sqlite path from dotdir target. + dotdirManager := dotdir.NewManager() + defaultTargetDir, err := dotdirManager.Target(configDir) + if err != nil { + return fmt.Errorf("resolving target dir: %w", err) + } + defaultTargetSqliteFile := filepath.Join(defaultTargetDir, "tapes.sqlite") + + if !cmd.Flags().Changed("proxy-listen") { + cmder.proxyListen = cfg.Proxy.Listen + } + if !cmd.Flags().Changed("api-listen") { + cmder.apiListen = cfg.API.Listen + } + if !cmd.Flags().Changed("upstream") { + cmder.upstream = cfg.Proxy.Upstream + } + if !cmd.Flags().Changed("provider") { + cmder.providerType = cfg.Proxy.Provider + } + if !cmd.Flags().Changed("sqlite") { + if cfg.Storage.SQLitePath != "" { + cmder.sqlitePath = cfg.Storage.SQLitePath + } else { + cmder.sqlitePath = defaultTargetSqliteFile + } + } + if !cmd.Flags().Changed("vector-store-provider") { + cmder.vectorStoreProvider = cfg.VectorStore.Provider + } + if !cmd.Flags().Changed("vector-store-target") { + if cfg.VectorStore.Target != "" { + cmder.vectorStoreTarget = cfg.VectorStore.Target + } else { + cmder.vectorStoreTarget = defaultTargetSqliteFile + } + } + if !cmd.Flags().Changed("embedding-provider") { + cmder.embeddingProvider = cfg.Embedding.Provider + } + if !cmd.Flags().Changed("embedding-target") { + cmder.embeddingTarget = cfg.Embedding.Target + } + if !cmd.Flags().Changed("embedding-model") { + cmder.embeddingModel = cfg.Embedding.Model + } + if !cmd.Flags().Changed("embedding-dimensions") { + cmder.embeddingDimensions = cfg.Embedding.Dimensions + } + return nil + }, RunE: func(cmd *cobra.Command, _ []string) error { var err error cmder.debug, err = cmd.Flags().GetBool("debug") @@ -75,24 +139,18 @@ func NewServeCmd() *cobra.Command { }, } - dotdirManger := dotdir.NewManager() - defaultTargetDir, err := dotdirManger.Target("") - if err != nil { - panic(err) - } - defaultTargetSqliteFile := filepath.Join(defaultTargetDir, "tapes.sqlite") - - cmd.Flags().StringVarP(&cmder.proxyListen, "proxy-listen", "p", ":8080", "Address for proxy to listen on") - cmd.Flags().StringVarP(&cmder.apiListen, "api-listen", "a", ":8081", "Address for API server to listen on") - cmd.Flags().StringVarP(&cmder.upstream, "upstream", "u", "http://localhost:11434", "Upstream LLM provider URL") - cmd.Flags().StringVar(&cmder.providerType, "provider", "ollama", "LLM provider type (anthropic, openai, ollama)") - cmd.Flags().StringVarP(&cmder.sqlitePath, "sqlite", "s", defaultTargetSqliteFile, "Path to SQLite database (e.g., ./tapes.sqlite, in-memory)") - cmd.Flags().StringVar(&cmder.vectorStoreProvider, "vector-store-provider", "sqlite", "Vector store provider type (e.g., chroma, sqlite)") - cmd.Flags().StringVar(&cmder.vectorStoreTarget, "vector-store-target", defaultTargetSqliteFile, "Vector store target fielpath for sqlite or URL for vector store service (e.g., http://localhost:8000, ./db.sqlite)") - cmd.Flags().StringVar(&cmder.embeddingProvider, "embedding-provider", "ollama", "Embedding provider type (e.g., ollama)") - cmd.Flags().StringVar(&cmder.embeddingTarget, "embedding-target", "http://localhost:11434", "Embedding provider URL") - cmd.Flags().StringVar(&cmder.embeddingModel, "embedding-model", "embeddinggemma:latest", "Embedding model name (e.g., embeddinggemma:300m, nomic-embed-text)") - cmd.Flags().UintVar(&cmder.embeddingDimensions, "embedding-dimensions", 768, "Embedding dimensionality.") + defaults := config.NewDefaultConfig() + cmd.Flags().StringVarP(&cmder.proxyListen, "proxy-listen", "p", defaults.Proxy.Listen, "Address for proxy to listen on") + cmd.Flags().StringVarP(&cmder.apiListen, "api-listen", "a", defaults.API.Listen, "Address for API server to listen on") + cmd.Flags().StringVarP(&cmder.upstream, "upstream", "u", defaults.Proxy.Upstream, "Upstream LLM provider URL") + cmd.Flags().StringVar(&cmder.providerType, "provider", defaults.Proxy.Provider, "LLM provider type (anthropic, openai, ollama)") + cmd.Flags().StringVarP(&cmder.sqlitePath, "sqlite", "s", "", "Path to SQLite database (e.g., ./tapes.sqlite, in-memory)") + cmd.Flags().StringVar(&cmder.vectorStoreProvider, "vector-store-provider", defaults.VectorStore.Provider, "Vector store provider type (e.g., chroma, sqlite)") + cmd.Flags().StringVar(&cmder.vectorStoreTarget, "vector-store-target", defaults.VectorStore.Target, "Vector store target filepath for sqlite or URL for vector store service (e.g., http://localhost:8000, ./db.sqlite)") + cmd.Flags().StringVar(&cmder.embeddingProvider, "embedding-provider", defaults.Embedding.Provider, "Embedding provider type (e.g., ollama)") + cmd.Flags().StringVar(&cmder.embeddingTarget, "embedding-target", defaults.Embedding.Target, "Embedding provider URL") + cmd.Flags().StringVar(&cmder.embeddingModel, "embedding-model", defaults.Embedding.Model, "Embedding model name (e.g., nomic-embed-text)") + cmd.Flags().UintVar(&cmder.embeddingDimensions, "embedding-dimensions", defaults.Embedding.Dimensions, "Embedding dimensionality.") cmd.AddCommand(apicmder.NewAPICmd()) cmd.AddCommand(proxycmder.NewProxyCmd()) diff --git a/cmd/tapes/tapes.go b/cmd/tapes/tapes.go index 298c21c..268dcd9 100644 --- a/cmd/tapes/tapes.go +++ b/cmd/tapes/tapes.go @@ -6,6 +6,7 @@ import ( chatcmder "github.com/papercomputeco/tapes/cmd/tapes/chat" checkoutcmder "github.com/papercomputeco/tapes/cmd/tapes/checkout" + configcmder "github.com/papercomputeco/tapes/cmd/tapes/config" deckcmder "github.com/papercomputeco/tapes/cmd/tapes/deck" initcmder "github.com/papercomputeco/tapes/cmd/tapes/init" searchcmder "github.com/papercomputeco/tapes/cmd/tapes/search" @@ -26,14 +27,20 @@ Experimental: Chat through the proxy: tapes checkout Checkout a conversation point tapes checkout Clear checkout state, start fresh tapes status Show current checkout state - tapes init Initialize a local .tapes directory + tapes init Initialize a local .tapes directory + tapes init --preset Initialize with a provider preset or remote config Search sessions: tapes search Search sessions using semantic similarity Deck sessions: tapes deck ROI dashboard for sessions - tapes deck --web Local web dashboard` + tapes deck --web Local web dashboard + +Configuration: + tapes config set Set a configuration value + tapes config get Get a configuration value + tapes config list List all configuration values` const tapesShortDesc string = "Tapes - Agent Telemetry" @@ -46,10 +53,12 @@ func NewTapesCmd() *cobra.Command { // Global flags cmd.PersistentFlags().BoolP("debug", "d", false, "Enable debug logging") + cmd.PersistentFlags().String("config-dir", "", "Override path to .tapes/ config directory") // Add subcommands cmd.AddCommand(chatcmder.NewChatCmd()) cmd.AddCommand(checkoutcmder.NewCheckoutCmd()) + cmd.AddCommand(configcmder.NewConfigCmd()) cmd.AddCommand(deckcmder.NewDeckCmd()) cmd.AddCommand(initcmder.NewInitCmd()) cmd.AddCommand(searchcmder.NewSearchCmd()) diff --git a/go.mod b/go.mod index 884080d..48a26dc 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,16 @@ go 1.25.4 require ( entgo.io/ent v0.14.5 + github.com/BurntSushi/toml v1.6.0 github.com/asg017/sqlite-vec-go-bindings v0.1.6 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/gofiber/adaptor/v2 v2.2.1 github.com/gofiber/fiber/v2 v2.52.6 github.com/mattn/go-sqlite3 v1.14.24 github.com/modelcontextprotocol/go-sdk v1.2.0 + github.com/muesli/termenv v0.16.0 github.com/onsi/ginkgo/v2 v2.27.4 github.com/onsi/gomega v1.39.0 github.com/spf13/cobra v1.10.2 @@ -23,14 +28,10 @@ require ( github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect - github.com/charmbracelet/bubbles v0.21.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/inflect v0.19.0 // indirect @@ -50,14 +51,11 @@ require ( github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/valyala/fasthttp v1.62.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect diff --git a/go.sum b/go.sum index baad2c4..1326614 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 h1:E0wvcUXTkgyN4wy4LGtNzMNG ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w= entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4= entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= @@ -12,10 +14,12 @@ github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7X github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/asg017/sqlite-vec-go-bindings v0.1.6 h1:Nx0jAzyS38XpkKznJ9xQjFXz2X9tI7KqjwVxV8RNoww= github.com/asg017/sqlite-vec-go-bindings v0.1.6/go.mod h1:A8+cTt/nKFsYCQF6OgzSNpKZrzNo5gQsXBTfsXHXY0Q= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= @@ -30,13 +34,13 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7 github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -109,8 +113,6 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= @@ -119,7 +121,6 @@ github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -164,6 +165,8 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..cfb9f10 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,335 @@ +package config + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" + + "github.com/papercomputeco/tapes/pkg/dotdir" +) + +const ( + configFile = "config.toml" + + // v0 is the alpha version of the config + v0 = 0 + + // CurrentV is the currently supported version, points to v0 + CurrentV = v0 +) + +type Configer struct { + ddm *dotdir.Manager + targetPath string +} + +func NewConfiger(override string) (*Configer, error) { + cfger := &Configer{} + + cfger.ddm = dotdir.NewManager() + target, err := cfger.ddm.Target(override) + if err != nil { + return nil, err + } + + // If no .tapes/ directory was resolved, targetPath stays empty; + // LoadConfig will return defaults and SaveConfig will error clearly. + if target == "" { + return cfger, nil + } + + path := filepath.Join(target, configFile) + _, err = os.Stat(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("reading config: %w", err) + } + + // Always set targetPath when the directory exists so SaveConfig + // can create or overwrite the file. + cfger.targetPath = path + + return cfger, nil +} + +// ValidConfigKeys returns the sorted list of all supported configuration key names. +func ValidConfigKeys() []string { + keys := make([]string, 0, len(configKeys)) + for k := range configKeys { + keys = append(keys, k) + } + + // Return in a stable, logical order matching the TOML section layout. + ordered := []string{ + "storage.sqlite_path", + "proxy.provider", + "proxy.upstream", + "proxy.listen", + "api.listen", + "client.proxy_target", + "client.api_target", + "vector_store.provider", + "vector_store.target", + "embedding.provider", + "embedding.target", + "embedding.model", + "embedding.dimensions", + } + + // Sanity: only return keys that actually exist in the map. + result := make([]string, 0, len(ordered)) + for _, k := range ordered { + if _, ok := configKeys[k]; ok { + result = append(result, k) + } + } + + // Append any keys in the map that we missed in the ordered list. + seen := make(map[string]bool, len(result)) + for _, k := range result { + seen[k] = true + } + for _, k := range keys { + if !seen[k] { + result = append(result, k) + } + } + + return result +} + +// IsValidConfigKey returns true if the given key is a supported configuration key. +func IsValidConfigKey(key string) bool { + _, ok := configKeys[key] + return ok +} + +func (c *Configer) GetTarget() string { + return c.targetPath +} + +// LoadConfig loads the configuration from config.toml in the target .tapes/ directory. +// If the file does not exist, returns DefaultConfig() so callers always receive +// a fully-populated Config with sane defaults. Fields explicitly set in the file +// override the defaults. +// If overrideDir is non-empty, it is used instead of the default .tapes/ location. +func (c *Configer) LoadConfig() (*Config, error) { + if c.targetPath == "" { + return NewDefaultConfig(), nil + } + + data, err := os.ReadFile(c.targetPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return NewDefaultConfig(), nil + } + return nil, fmt.Errorf("reading config: %w", err) + } + + cfg, err := ParseConfigTOML(data) + if err != nil { + return nil, err + } + + // Merge in defaults: fill in any zero-value fields from the loaded config + applyDefaults(cfg) + + return cfg, nil +} + +// applyDefaults fills zero-value fields in cfg with values from DefaultConfig(). +func applyDefaults(cfg *Config) { + defaults := NewDefaultConfig() + + if cfg.Version == 0 { + cfg.Version = defaults.Version + } + + if cfg.Proxy.Provider == "" { + cfg.Proxy.Provider = defaults.Proxy.Provider + } + if cfg.Proxy.Upstream == "" { + cfg.Proxy.Upstream = defaults.Proxy.Upstream + } + if cfg.Proxy.Listen == "" { + cfg.Proxy.Listen = defaults.Proxy.Listen + } + + if cfg.API.Listen == "" { + cfg.API.Listen = defaults.API.Listen + } + + if cfg.Client.ProxyTarget == "" { + cfg.Client.ProxyTarget = defaults.Client.ProxyTarget + } + if cfg.Client.APITarget == "" { + cfg.Client.APITarget = defaults.Client.APITarget + } + + if cfg.VectorStore.Provider == "" { + cfg.VectorStore.Provider = defaults.VectorStore.Provider + } + + if cfg.Embedding.Provider == "" { + cfg.Embedding.Provider = defaults.Embedding.Provider + } + if cfg.Embedding.Target == "" { + cfg.Embedding.Target = defaults.Embedding.Target + } + if cfg.Embedding.Model == "" { + cfg.Embedding.Model = defaults.Embedding.Model + } + if cfg.Embedding.Dimensions == 0 { + cfg.Embedding.Dimensions = defaults.Embedding.Dimensions + } +} + +// SaveConfig persists the configuration to config.toml in the target .tapes/ directory. +func (c *Configer) SaveConfig(cfg *Config) error { + if cfg == nil { + return errors.New("cannot save nil config") + } + + if c.targetPath == "" { + return errors.New("cannot save empty target path") + } + + var buf bytes.Buffer + encoder := toml.NewEncoder(&buf) + if err := encoder.Encode(cfg); err != nil { + return fmt.Errorf("encoding config: %w", err) + } + + if err := os.WriteFile(c.targetPath, buf.Bytes(), 0o600); err != nil { + return fmt.Errorf("writing config: %w", err) + } + + return nil +} + +// SetConfigValue loads the config, sets the given key to the given value, and saves it. +// Returns an error if the key is not a valid config key. +func (c *Configer) SetConfigValue(key string, value string) error { + info, ok := configKeys[key] + if !ok { + return fmt.Errorf("unknown config key: %q", key) + } + + cfg, err := c.LoadConfig() + if err != nil { + return err + } + + if err := info.set(cfg, value); err != nil { + return err + } + + return c.SaveConfig(cfg) +} + +// GetConfigValue loads the config and returns the string representation of the given key. +// Returns an error if the key is not a valid config key. +func (c *Configer) GetConfigValue(key string) (string, error) { + info, ok := configKeys[key] + if !ok { + return "", fmt.Errorf("unknown config key: %q", key) + } + + cfg, err := c.LoadConfig() + if err != nil { + return "", err + } + + return info.get(cfg), nil +} + +// PresetConfig returns a Config with sane defaults for the named provider preset. +// Supported presets: "openai", "anthropic", "ollama". +// Returns an error if the preset name is not recognized. +func PresetConfig(name string) (*Config, error) { + switch strings.ToLower(name) { + case "openai": + return &Config{ + Version: CurrentV, + Proxy: ProxyConfig{ + Provider: "openai", + Upstream: "https://api.openai.com", + Listen: ":8080", + }, + API: APIConfig{ + Listen: ":8081", + }, + Client: ClientConfig{ + ProxyTarget: "http://localhost:8080", + APITarget: "http://localhost:8081", + }, + }, nil + + case "anthropic": + return &Config{ + Version: CurrentV, + Proxy: ProxyConfig{ + Provider: "anthropic", + Upstream: "https://api.anthropic.com", + Listen: ":8080", + }, + API: APIConfig{ + Listen: ":8081", + }, + Client: ClientConfig{ + ProxyTarget: "http://localhost:8080", + APITarget: "http://localhost:8081", + }, + }, nil + + case "ollama": + return &Config{ + Version: CurrentV, + Proxy: ProxyConfig{ + Provider: "ollama", + Upstream: "http://localhost:11434", + Listen: ":8080", + }, + API: APIConfig{ + Listen: ":8081", + }, + Client: ClientConfig{ + ProxyTarget: "http://localhost:8080", + APITarget: "http://localhost:8081", + }, + Embedding: EmbeddingConfig{ + Provider: "ollama", + Target: "http://localhost:11434", + Model: "nomic-embed-text", + Dimensions: 768, + }, + }, nil + + default: + return nil, fmt.Errorf("unknown preset: %q (available: openai, anthropic, ollama)", name) + } +} + +// ValidPresetNames returns the list of recognized preset names. +func ValidPresetNames() []string { + return []string{"openai", "anthropic", "ollama"} +} + +// ParseConfigTOML parses raw TOML bytes into a Config. +// Returns an error if the version field is present and not equal to CurrentConfigVersion. +func ParseConfigTOML(data []byte) (*Config, error) { + cfg := &Config{} + if err := toml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parsing config TOML: %w", err) + } + + if cfg.Version != 0 && cfg.Version != CurrentV { + return nil, fmt.Errorf("unsupported config version %d (expected %d)", cfg.Version, CurrentV) + } + + return cfg, nil +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..0785dba --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,696 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/papercomputeco/tapes/pkg/config" +) + +func TestConfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Config Suite") +} + +var _ = Describe("Configer config", func() { + var tmpDir string + + BeforeEach(func() { + var err error + tmpDir, err = os.MkdirTemp("", "config-test-*") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + os.RemoveAll(tmpDir) + }) + + Describe("LoadConfig", func() { + It("returns default config when no config file exists", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + cfg, err := c.LoadConfig() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + defaults := config.NewDefaultConfig() + Expect(cfg.Version).To(Equal(defaults.Version)) + Expect(cfg.Proxy.Provider).To(Equal(defaults.Proxy.Provider)) + Expect(cfg.Proxy.Upstream).To(Equal(defaults.Proxy.Upstream)) + Expect(cfg.Proxy.Listen).To(Equal(defaults.Proxy.Listen)) + Expect(cfg.API.Listen).To(Equal(defaults.API.Listen)) + Expect(cfg.Client.ProxyTarget).To(Equal(defaults.Client.ProxyTarget)) + Expect(cfg.Client.APITarget).To(Equal(defaults.Client.APITarget)) + Expect(cfg.VectorStore.Provider).To(Equal(defaults.VectorStore.Provider)) + Expect(cfg.Embedding.Provider).To(Equal(defaults.Embedding.Provider)) + Expect(cfg.Embedding.Target).To(Equal(defaults.Embedding.Target)) + Expect(cfg.Embedding.Model).To(Equal(defaults.Embedding.Model)) + Expect(cfg.Embedding.Dimensions).To(Equal(defaults.Embedding.Dimensions)) + }) + + It("loads a valid config file", func() { + data := `version = 0 + +[proxy] +provider = "anthropic" +upstream = "https://api.anthropic.com" + +[embedding] +dimensions = 768 +` + err := os.WriteFile(filepath.Join(tmpDir, "config.toml"), []byte(data), 0o600) + Expect(err).NotTo(HaveOccurred()) + + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + cfg, err := c.LoadConfig() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + Expect(cfg.Version).To(Equal(0)) + Expect(cfg.Proxy.Provider).To(Equal("anthropic")) + Expect(cfg.Proxy.Upstream).To(Equal("https://api.anthropic.com")) + Expect(cfg.Embedding.Dimensions).To(Equal(uint(768))) + }) + + It("loads all config fields", func() { + data := `version = 0 + +[storage] +sqlite_path = "/tmp/tapes.sqlite" + +[proxy] +provider = "openai" +upstream = "https://api.openai.com" +listen = ":9090" + +[api] +listen = ":9091" + +[client] +proxy_target = "http://myhost:9090" +api_target = "http://myhost:9091" + +[vector_store] +provider = "chroma" +target = "http://localhost:8000" + +[embedding] +provider = "ollama" +target = "http://localhost:11434" +model = "nomic-embed-text" +dimensions = 1024 +` + err := os.WriteFile(filepath.Join(tmpDir, "config.toml"), []byte(data), 0o600) + Expect(err).NotTo(HaveOccurred()) + + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + cfg, err := c.LoadConfig() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Version).To(Equal(0)) + Expect(cfg.Storage.SQLitePath).To(Equal("/tmp/tapes.sqlite")) + Expect(cfg.Proxy.Provider).To(Equal("openai")) + Expect(cfg.Proxy.Upstream).To(Equal("https://api.openai.com")) + Expect(cfg.Proxy.Listen).To(Equal(":9090")) + Expect(cfg.API.Listen).To(Equal(":9091")) + Expect(cfg.Client.ProxyTarget).To(Equal("http://myhost:9090")) + Expect(cfg.Client.APITarget).To(Equal("http://myhost:9091")) + Expect(cfg.VectorStore.Provider).To(Equal("chroma")) + Expect(cfg.VectorStore.Target).To(Equal("http://localhost:8000")) + Expect(cfg.Embedding.Provider).To(Equal("ollama")) + Expect(cfg.Embedding.Target).To(Equal("http://localhost:11434")) + Expect(cfg.Embedding.Model).To(Equal("nomic-embed-text")) + Expect(cfg.Embedding.Dimensions).To(Equal(uint(1024))) + }) + + It("returns error for malformed TOML", func() { + err := os.WriteFile(filepath.Join(tmpDir, "config.toml"), []byte("not valid toml [[["), 0o600) + Expect(err).NotTo(HaveOccurred()) + + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + cfg, err := c.LoadConfig() + Expect(err).To(HaveOccurred()) + Expect(cfg).To(BeNil()) + }) + + It("returns error for unsupported config version", func() { + data := `version = 99 +` + err := os.WriteFile(filepath.Join(tmpDir, "config.toml"), []byte(data), 0o600) + Expect(err).NotTo(HaveOccurred()) + + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + cfg, err := c.LoadConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unsupported config version")) + Expect(cfg).To(BeNil()) + }) + + It("accepts config with version 0 (omitted)", func() { + data := `[proxy] +provider = "openai" +` + err := os.WriteFile(filepath.Join(tmpDir, "config.toml"), []byte(data), 0o600) + Expect(err).NotTo(HaveOccurred()) + + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + cfg, err := c.LoadConfig() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Proxy.Provider).To(Equal("openai")) + }) + }) + + Describe("SaveConfig", func() { + It("persists config to disk", func() { + cfg := &config.Config{ + Version: config.CurrentV, + Proxy: config.ProxyConfig{ + Provider: "anthropic", + Upstream: "https://api.anthropic.com", + }, + Embedding: config.EmbeddingConfig{ + Dimensions: 768, + }, + } + + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + err = c.SaveConfig(cfg) + Expect(err).NotTo(HaveOccurred()) + + // Verify the file exists + _, err = os.Stat(filepath.Join(tmpDir, "config.toml")) + Expect(err).NotTo(HaveOccurred()) + + // Load it back and verify + loaded, err := c.LoadConfig() + Expect(err).NotTo(HaveOccurred()) + Expect(loaded.Proxy.Provider).To(Equal("anthropic")) + Expect(loaded.Proxy.Upstream).To(Equal("https://api.anthropic.com")) + Expect(loaded.Embedding.Dimensions).To(Equal(uint(768))) + }) + + It("returns error for nil config", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + err = c.SaveConfig(nil) + Expect(err).To(HaveOccurred()) + }) + + It("overwrites existing config", func() { + first := &config.Config{ + Version: config.CurrentV, + Proxy: config.ProxyConfig{Provider: "ollama"}, + } + second := &config.Config{ + Version: config.CurrentV, + Proxy: config.ProxyConfig{Provider: "anthropic"}, + } + + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + err = c.SaveConfig(first) + Expect(err).NotTo(HaveOccurred()) + + err = c.SaveConfig(second) + Expect(err).NotTo(HaveOccurred()) + + loaded, err := c.LoadConfig() + Expect(err).NotTo(HaveOccurred()) + Expect(loaded.Proxy.Provider).To(Equal("anthropic")) + }) + }) + + Describe("SetConfigValue", func() { + It("sets a string config key", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + err = c.SetConfigValue("proxy.provider", "anthropic") + Expect(err).NotTo(HaveOccurred()) + + cfg, err := c.LoadConfig() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Proxy.Provider).To(Equal("anthropic")) + }) + + It("sets a uint config key", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + err = c.SetConfigValue("embedding.dimensions", "1024") + Expect(err).NotTo(HaveOccurred()) + + cfg, err := c.LoadConfig() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Embedding.Dimensions).To(Equal(uint(1024))) + }) + + It("returns error for unknown key", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + err = c.SetConfigValue("nonexistent_key", "value") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown config key")) + }) + + It("returns error for invalid uint value", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + err = c.SetConfigValue("embedding.dimensions", "not-a-number") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid value")) + }) + + It("sets client.proxy_target", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + err = c.SetConfigValue("client.proxy_target", "http://remote:9090") + Expect(err).NotTo(HaveOccurred()) + + cfg, err := c.LoadConfig() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Client.ProxyTarget).To(Equal("http://remote:9090")) + }) + + It("sets client.api_target", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + err = c.SetConfigValue("client.api_target", "http://remote:9091") + Expect(err).NotTo(HaveOccurred()) + + cfg, err := c.LoadConfig() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Client.APITarget).To(Equal("http://remote:9091")) + }) + + It("preserves existing values when setting a new key", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + err = c.SetConfigValue("proxy.provider", "anthropic") + Expect(err).NotTo(HaveOccurred()) + + err = c.SetConfigValue("proxy.upstream", "https://api.anthropic.com") + Expect(err).NotTo(HaveOccurred()) + + cfg, err := c.LoadConfig() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Proxy.Provider).To(Equal("anthropic")) + Expect(cfg.Proxy.Upstream).To(Equal("https://api.anthropic.com")) + }) + }) + + Describe("GetConfigValue", func() { + It("gets a set config value", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + err = c.SetConfigValue("proxy.provider", "anthropic") + Expect(err).NotTo(HaveOccurred()) + + val, err := c.GetConfigValue("proxy.provider") + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("anthropic")) + }) + + It("returns default value when no config file exists", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + val, err := c.GetConfigValue("proxy.provider") + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal(config.NewDefaultConfig().Proxy.Provider)) + }) + + It("returns empty string for key with no default", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + val, err := c.GetConfigValue("storage.sqlite_path") + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(BeEmpty()) + }) + + It("returns error for unknown key", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + _, err = c.GetConfigValue("nonexistent_key") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown config key")) + }) + + It("returns default client target values when no config file exists", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + val, err := c.GetConfigValue("client.proxy_target") + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("http://localhost:8080")) + + val, err = c.GetConfigValue("client.api_target") + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("http://localhost:8081")) + }) + + It("gets a uint config value as string", func() { + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + err = c.SetConfigValue("embedding.dimensions", "512") + Expect(err).NotTo(HaveOccurred()) + + val, err := c.GetConfigValue("embedding.dimensions") + Expect(err).NotTo(HaveOccurred()) + Expect(val).To(Equal("512")) + }) + }) + + Describe("ValidConfigKeys", func() { + It("returns all expected keys", func() { + keys := config.ValidConfigKeys() + Expect(keys).To(ContainElements( + "storage.sqlite_path", + "proxy.provider", + "proxy.upstream", + "proxy.listen", + "api.listen", + "client.proxy_target", + "client.api_target", + "vector_store.provider", + "vector_store.target", + "embedding.provider", + "embedding.target", + "embedding.model", + "embedding.dimensions", + )) + }) + + It("returns keys in stable order", func() { + keys1 := config.ValidConfigKeys() + keys2 := config.ValidConfigKeys() + Expect(keys1).To(Equal(keys2)) + }) + }) + + Describe("IsValidConfigKey", func() { + It("returns true for valid keys", func() { + Expect(config.IsValidConfigKey("proxy.provider")).To(BeTrue()) + Expect(config.IsValidConfigKey("embedding.dimensions")).To(BeTrue()) + Expect(config.IsValidConfigKey("client.proxy_target")).To(BeTrue()) + Expect(config.IsValidConfigKey("client.api_target")).To(BeTrue()) + }) + + It("returns false for invalid keys", func() { + Expect(config.IsValidConfigKey("nonexistent")).To(BeFalse()) + Expect(config.IsValidConfigKey("")).To(BeFalse()) + }) + + It("returns false for old flat key names", func() { + Expect(config.IsValidConfigKey("provider")).To(BeFalse()) + Expect(config.IsValidConfigKey("upstream")).To(BeFalse()) + Expect(config.IsValidConfigKey("embedding_dimensions")).To(BeFalse()) + }) + }) + + Describe("round-trip", func() { + It("saves and loads config correctly with all fields", func() { + cfg := &config.Config{ + Version: config.CurrentV, + Storage: config.StorageConfig{ + SQLitePath: "/tmp/test.sqlite", + }, + Proxy: config.ProxyConfig{ + Provider: "openai", + Upstream: "https://api.openai.com", + Listen: ":9090", + }, + API: config.APIConfig{ + Listen: ":9091", + }, + Client: config.ClientConfig{ + ProxyTarget: "http://myhost:9090", + APITarget: "http://myhost:9091", + }, + VectorStore: config.VectorStoreConfig{ + Provider: "chroma", + Target: "http://localhost:8000", + }, + Embedding: config.EmbeddingConfig{ + Provider: "ollama", + Target: "http://localhost:11434", + Model: "nomic-embed-text", + Dimensions: 1024, + }, + } + + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + err = c.SaveConfig(cfg) + Expect(err).NotTo(HaveOccurred()) + + loaded, err := c.LoadConfig() + Expect(err).NotTo(HaveOccurred()) + Expect(loaded).To(Equal(cfg)) + }) + }) +}) + +var _ = Describe("PresetConfig", func() { + It("returns openai preset with correct defaults", func() { + cfg, err := config.PresetConfig("openai") + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Version).To(Equal(config.CurrentV)) + Expect(cfg.Proxy.Provider).To(Equal("openai")) + Expect(cfg.Proxy.Upstream).To(Equal("https://api.openai.com")) + Expect(cfg.Proxy.Listen).To(Equal(":8080")) + Expect(cfg.API.Listen).To(Equal(":8081")) + Expect(cfg.Client.ProxyTarget).To(Equal("http://localhost:8080")) + Expect(cfg.Client.APITarget).To(Equal("http://localhost:8081")) + }) + + It("returns anthropic preset with correct defaults", func() { + cfg, err := config.PresetConfig("anthropic") + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Version).To(Equal(config.CurrentV)) + Expect(cfg.Proxy.Provider).To(Equal("anthropic")) + Expect(cfg.Proxy.Upstream).To(Equal("https://api.anthropic.com")) + Expect(cfg.Proxy.Listen).To(Equal(":8080")) + Expect(cfg.API.Listen).To(Equal(":8081")) + Expect(cfg.Client.ProxyTarget).To(Equal("http://localhost:8080")) + Expect(cfg.Client.APITarget).To(Equal("http://localhost:8081")) + }) + + It("returns ollama preset with embedding defaults", func() { + cfg, err := config.PresetConfig("ollama") + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Version).To(Equal(config.CurrentV)) + Expect(cfg.Proxy.Provider).To(Equal("ollama")) + Expect(cfg.Proxy.Upstream).To(Equal("http://localhost:11434")) + Expect(cfg.Proxy.Listen).To(Equal(":8080")) + Expect(cfg.API.Listen).To(Equal(":8081")) + Expect(cfg.Client.ProxyTarget).To(Equal("http://localhost:8080")) + Expect(cfg.Client.APITarget).To(Equal("http://localhost:8081")) + Expect(cfg.Embedding.Provider).To(Equal("ollama")) + Expect(cfg.Embedding.Target).To(Equal("http://localhost:11434")) + Expect(cfg.Embedding.Model).To(Equal("nomic-embed-text")) + Expect(cfg.Embedding.Dimensions).To(Equal(uint(768))) + }) + + It("is case-insensitive", func() { + cfg, err := config.PresetConfig("OpenAI") + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Proxy.Provider).To(Equal("openai")) + + cfg, err = config.PresetConfig("ANTHROPIC") + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Proxy.Provider).To(Equal("anthropic")) + }) + + It("returns error for unknown preset", func() { + cfg, err := config.PresetConfig("nonexistent") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown preset")) + Expect(cfg).To(BeNil()) + }) +}) + +var _ = Describe("ValidPresetNames", func() { + It("returns the expected preset names", func() { + names := config.ValidPresetNames() + Expect(names).To(ConsistOf("openai", "anthropic", "ollama")) + }) +}) + +var _ = Describe("ParseConfigTOML", func() { + It("parses valid TOML into a Config", func() { + data := []byte(`version = 0 + +[proxy] +provider = "anthropic" +upstream = "https://api.anthropic.com" +listen = ":9090" + +[embedding] +dimensions = 512 +`) + cfg, err := config.ParseConfigTOML(data) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Version).To(Equal(0)) + Expect(cfg.Proxy.Provider).To(Equal("anthropic")) + Expect(cfg.Proxy.Upstream).To(Equal("https://api.anthropic.com")) + Expect(cfg.Proxy.Listen).To(Equal(":9090")) + Expect(cfg.Embedding.Dimensions).To(Equal(uint(512))) + }) + + It("returns error for invalid TOML", func() { + cfg, err := config.ParseConfigTOML([]byte("not valid [[[")) + Expect(err).To(HaveOccurred()) + Expect(cfg).To(BeNil()) + }) + + It("returns empty config for empty input", func() { + cfg, err := config.ParseConfigTOML([]byte("")) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + Expect(cfg.Proxy.Provider).To(BeEmpty()) + }) + + It("rejects unsupported config version", func() { + data := []byte(`version = 2 +`) + cfg, err := config.ParseConfigTOML(data) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unsupported config version")) + Expect(cfg).To(BeNil()) + }) +}) + +var _ = Describe("NewDefaultConfig", func() { + It("returns fully-populated defaults", func() { + cfg := config.NewDefaultConfig() + Expect(cfg.Version).To(Equal(config.CurrentV)) + Expect(cfg.Proxy.Provider).To(Equal("ollama")) + Expect(cfg.Proxy.Upstream).To(Equal("http://localhost:11434")) + Expect(cfg.Proxy.Listen).To(Equal(":8080")) + Expect(cfg.API.Listen).To(Equal(":8081")) + Expect(cfg.Client.ProxyTarget).To(Equal("http://localhost:8080")) + Expect(cfg.Client.APITarget).To(Equal("http://localhost:8081")) + Expect(cfg.VectorStore.Provider).To(Equal("sqlite")) + Expect(cfg.Embedding.Provider).To(Equal("ollama")) + Expect(cfg.Embedding.Target).To(Equal("http://localhost:11434")) + Expect(cfg.Embedding.Model).To(Equal("embeddinggemma")) + Expect(cfg.Embedding.Dimensions).To(Equal(uint(768))) + }) +}) + +var _ = Describe("applyDefaults via LoadConfig", func() { + var tmpDir string + + BeforeEach(func() { + var err error + tmpDir, err = os.MkdirTemp("", "config-defaults-test-*") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + os.RemoveAll(tmpDir) + }) + + It("fills in defaults for unset fields in a partial config", func() { + // Config file only sets proxy.provider; everything else should get defaults. + data := `version = 0 + +[proxy] +provider = "anthropic" +` + err := os.WriteFile(filepath.Join(tmpDir, "config.toml"), []byte(data), 0o600) + Expect(err).NotTo(HaveOccurred()) + + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + cfg, err := c.LoadConfig() + Expect(err).NotTo(HaveOccurred()) + + // Explicitly set value should be preserved. + Expect(cfg.Proxy.Provider).To(Equal("anthropic")) + + // Unset fields should get defaults. + defaults := config.NewDefaultConfig() + Expect(cfg.Proxy.Upstream).To(Equal(defaults.Proxy.Upstream)) + Expect(cfg.Proxy.Listen).To(Equal(defaults.Proxy.Listen)) + Expect(cfg.API.Listen).To(Equal(defaults.API.Listen)) + Expect(cfg.Client.ProxyTarget).To(Equal(defaults.Client.ProxyTarget)) + Expect(cfg.Client.APITarget).To(Equal(defaults.Client.APITarget)) + Expect(cfg.VectorStore.Provider).To(Equal(defaults.VectorStore.Provider)) + Expect(cfg.Embedding.Provider).To(Equal(defaults.Embedding.Provider)) + Expect(cfg.Embedding.Target).To(Equal(defaults.Embedding.Target)) + Expect(cfg.Embedding.Model).To(Equal(defaults.Embedding.Model)) + Expect(cfg.Embedding.Dimensions).To(Equal(defaults.Embedding.Dimensions)) + }) + + It("does not overwrite explicitly set values", func() { + data := `version = 0 + +[proxy] +provider = "openai" +upstream = "https://api.openai.com" +listen = ":9090" + +[api] +listen = ":9091" + +[client] +proxy_target = "http://remote:9090" +api_target = "http://remote:9091" + +[embedding] +provider = "openai" +target = "https://api.openai.com" +model = "text-embedding-3-small" +dimensions = 1536 +` + err := os.WriteFile(filepath.Join(tmpDir, "config.toml"), []byte(data), 0o600) + Expect(err).NotTo(HaveOccurred()) + + c, err := config.NewConfiger(tmpDir) + Expect(err).NotTo(HaveOccurred()) + + cfg, err := c.LoadConfig() + Expect(err).NotTo(HaveOccurred()) + + Expect(cfg.Proxy.Provider).To(Equal("openai")) + Expect(cfg.Proxy.Upstream).To(Equal("https://api.openai.com")) + Expect(cfg.Proxy.Listen).To(Equal(":9090")) + Expect(cfg.API.Listen).To(Equal(":9091")) + Expect(cfg.Client.ProxyTarget).To(Equal("http://remote:9090")) + Expect(cfg.Client.APITarget).To(Equal("http://remote:9091")) + Expect(cfg.Embedding.Provider).To(Equal("openai")) + Expect(cfg.Embedding.Target).To(Equal("https://api.openai.com")) + Expect(cfg.Embedding.Model).To(Equal("text-embedding-3-small")) + Expect(cfg.Embedding.Dimensions).To(Equal(uint(1536))) + }) +}) diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go new file mode 100644 index 0000000..72960bd --- /dev/null +++ b/pkg/config/defaults.go @@ -0,0 +1,46 @@ +package config + +const ( + defaultProvider = "ollama" + defaultUpstream = "http://localhost:11434" + defaultProxyListen = ":8080" + defaultAPIListen = ":8081" + + defaultClientProxyTarget = "http://localhost:8080" + defaultClientAPITarget = "http://localhost:8081" + + defaultVectorProvider = "sqlite" + + defaultEmbeddingModel = "embeddinggemma" + defaultEmbeddingDimensions = 768 + defaultEmbeddingTarget = "http://localhost:11434" +) + +// NewDefaultConfig returns a Config with sane defaults for all fields. +// This is the single source of truth for default values. +func NewDefaultConfig() *Config { + return &Config{ + Version: CurrentV, + Proxy: ProxyConfig{ + Provider: defaultProvider, + Upstream: defaultUpstream, + Listen: defaultProxyListen, + }, + API: APIConfig{ + Listen: defaultAPIListen, + }, + Client: ClientConfig{ + ProxyTarget: defaultClientProxyTarget, + APITarget: defaultClientAPITarget, + }, + VectorStore: VectorStoreConfig{ + Provider: defaultVectorProvider, + }, + Embedding: EmbeddingConfig{ + Provider: defaultProvider, + Target: defaultUpstream, + Model: defaultEmbeddingModel, + Dimensions: defaultEmbeddingDimensions, + }, + } +} diff --git a/pkg/config/types.go b/pkg/config/types.go new file mode 100644 index 0000000..14b01a0 --- /dev/null +++ b/pkg/config/types.go @@ -0,0 +1,132 @@ +package config + +import ( + "fmt" + "strconv" +) + +// Config represents the persistent tapes configuration stored as config.toml +// in the .tapes/ directory. The TOML layout uses sections for logical grouping. +type Config struct { + Version int `toml:"version"` + Storage StorageConfig `toml:"storage"` + Proxy ProxyConfig `toml:"proxy"` + API APIConfig `toml:"api"` + Client ClientConfig `toml:"client"` + VectorStore VectorStoreConfig `toml:"vector_store"` + Embedding EmbeddingConfig `toml:"embedding"` +} + +// StorageConfig holds shared storage settings used by both proxy and API. +type StorageConfig struct { + SQLitePath string `toml:"sqlite_path,omitempty"` +} + +// ProxyConfig holds proxy-specific settings. +type ProxyConfig struct { + Provider string `toml:"provider,omitempty"` + Upstream string `toml:"upstream,omitempty"` + Listen string `toml:"listen,omitempty"` +} + +// APIConfig holds API server settings. +type APIConfig struct { + Listen string `toml:"listen,omitempty"` +} + +// ClientConfig holds settings for CLI commands that connect to the running +// proxy and API servers (e.g. tapes chat, tapes search, tapes checkout). +// Values are full URLs (scheme + host + port). +type ClientConfig struct { + ProxyTarget string `toml:"proxy_target,omitempty"` + APITarget string `toml:"api_target,omitempty"` +} + +// VectorStoreConfig holds vector store settings. +type VectorStoreConfig struct { + Provider string `toml:"provider,omitempty"` + Target string `toml:"target,omitempty"` +} + +// EmbeddingConfig holds embedding provider settings. +type EmbeddingConfig struct { + Provider string `toml:"provider,omitempty"` + Target string `toml:"target,omitempty"` + Model string `toml:"model,omitempty"` + Dimensions uint `toml:"dimensions,omitempty"` +} + +// configKeyInfo maps a user-facing dotted key name to a getter and setter on *Config. +type configKeyInfo struct { + get func(c *Config) string + set func(c *Config, v string) error +} + +// configKeys is the authoritative map of all supported config keys. +// Keys use dotted notation matching the TOML section structure. +var configKeys = map[string]configKeyInfo{ + "storage.sqlite_path": { + get: func(c *Config) string { return c.Storage.SQLitePath }, + set: func(c *Config, v string) error { c.Storage.SQLitePath = v; return nil }, + }, + "proxy.provider": { + get: func(c *Config) string { return c.Proxy.Provider }, + set: func(c *Config, v string) error { c.Proxy.Provider = v; return nil }, + }, + "proxy.upstream": { + get: func(c *Config) string { return c.Proxy.Upstream }, + set: func(c *Config, v string) error { c.Proxy.Upstream = v; return nil }, + }, + "proxy.listen": { + get: func(c *Config) string { return c.Proxy.Listen }, + set: func(c *Config, v string) error { c.Proxy.Listen = v; return nil }, + }, + "api.listen": { + get: func(c *Config) string { return c.API.Listen }, + set: func(c *Config, v string) error { c.API.Listen = v; return nil }, + }, + "client.proxy_target": { + get: func(c *Config) string { return c.Client.ProxyTarget }, + set: func(c *Config, v string) error { c.Client.ProxyTarget = v; return nil }, + }, + "client.api_target": { + get: func(c *Config) string { return c.Client.APITarget }, + set: func(c *Config, v string) error { c.Client.APITarget = v; return nil }, + }, + "vector_store.provider": { + get: func(c *Config) string { return c.VectorStore.Provider }, + set: func(c *Config, v string) error { c.VectorStore.Provider = v; return nil }, + }, + "vector_store.target": { + get: func(c *Config) string { return c.VectorStore.Target }, + set: func(c *Config, v string) error { c.VectorStore.Target = v; return nil }, + }, + "embedding.provider": { + get: func(c *Config) string { return c.Embedding.Provider }, + set: func(c *Config, v string) error { c.Embedding.Provider = v; return nil }, + }, + "embedding.target": { + get: func(c *Config) string { return c.Embedding.Target }, + set: func(c *Config, v string) error { c.Embedding.Target = v; return nil }, + }, + "embedding.model": { + get: func(c *Config) string { return c.Embedding.Model }, + set: func(c *Config, v string) error { c.Embedding.Model = v; return nil }, + }, + "embedding.dimensions": { + get: func(c *Config) string { + if c.Embedding.Dimensions == 0 { + return "" + } + return strconv.FormatUint(uint64(c.Embedding.Dimensions), 10) + }, + set: func(c *Config, v string) error { + n, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return fmt.Errorf("invalid value for embedding.dimensions: %w", err) + } + c.Embedding.Dimensions = uint(n) + return nil + }, + }, +} diff --git a/pkg/dotdir/manager.go b/pkg/dotdir/manager.go index 378bcde..449af5a 100644 --- a/pkg/dotdir/manager.go +++ b/pkg/dotdir/manager.go @@ -27,31 +27,33 @@ func NewManager() *Manager { // 1. Provided override // 2. Local ./.tapes/ dir // 3. Home ~/.tapes/ dir -// 4. If none found, attempt to create ~/.tapes/ dir +// 4. If none found, returns "", nil (i.e., the empty state) func (m *Manager) Target(overrideDir string) (string, error) { var dir string switch { case overrideDir != "": dir = overrideDir + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("error creating tapes directory %s: %w", dir, err) + } case m.localDirExists(): cwd, err := os.Getwd() if err != nil { - return "", fmt.Errorf("getting current directory: %w", err) + return "", fmt.Errorf("error getting current directory: %w", err) } dir = filepath.Join(cwd, dirName) - default: + case m.homeDirExists(): home, err := os.UserHomeDir() if err != nil { - return "", fmt.Errorf("getting home directory: %w", err) + return "", fmt.Errorf("error getting home directory: %w", err) } dir = filepath.Join(home, dirName) - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", fmt.Errorf("creating tapes directory %s: %w", dir, err) + default: + return "", nil } return filepath.Abs(dir) @@ -68,3 +70,14 @@ func (m *Manager) localDirExists() bool { info, err := os.Stat(filepath.Join(cwd, dirName)) return err == nil && info.IsDir() } + +// homeDirExists checks whether a ~/.tapes/ directory exists on the system. +func (m *Manager) homeDirExists() bool { + home, err := os.UserHomeDir() + if err != nil { + return false + } + + info, err := os.Stat(filepath.Join(home, dirName)) + return err == nil && info.IsDir() +} diff --git a/pkg/dotdir/manager_test.go b/pkg/dotdir/manager_test.go index a900e33..daf8a1a 100644 --- a/pkg/dotdir/manager_test.go +++ b/pkg/dotdir/manager_test.go @@ -88,7 +88,7 @@ var _ = Describe("dotdir", func() { Expect(result).To(Equal(localTapes)) }) - It("falls back to the home dir when no local .tapes dir exists and no override is provided", func() { + It("returns empty string when no local .tapes dir exists, no home .tapes dir exists, and no override is provided", func() { // Ensure we're in a directory without a .tapes subdir emptyDir := filepath.Join(tmpDir, "empty") Expect(os.Mkdir(emptyDir, 0o755)).To(Succeed()) @@ -98,12 +98,14 @@ var _ = Describe("dotdir", func() { Expect(os.Chdir(emptyDir)).To(Succeed()) DeferCleanup(func() { os.Chdir(origDir) }) - home, err := os.UserHomeDir() - Expect(err).NotTo(HaveOccurred()) + // Override HOME so that ~/.tapes from the real home dir is not found. + origHome := os.Getenv("HOME") + Expect(os.Setenv("HOME", emptyDir)).To(Succeed()) + DeferCleanup(func() { os.Setenv("HOME", origHome) }) result, err := m.Target("") Expect(err).NotTo(HaveOccurred()) - Expect(result).To(Equal(filepath.Join(home, ".tapes"))) + Expect(result).To(BeEmpty()) }) }) })