diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 181f093..5bd456f 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -26,20 +26,31 @@ func NewRootCmd() *cobra.Command { // where folder would otherwise be interpreted as a subcommand and fail. Args: cobra.ArbitraryArgs, // When no subcommand is supplied, execute the push command - RunE: NewPushCmd(opts).RunE, + RunE: newPushCmd(opts).RunE, } - // Add global flags rootCmd.PersistentFlags().StringVarP(&opts.OutputFile, "output", "o", "", "Output file") - rootCmd.PersistentFlags().StringVar(&opts.IgnoreFile, "ignore", "", "Ignore file (default: .gitignore)") + rootCmd.PersistentFlags().StringVarP(&opts.IgnoreFile, "ignore", "i", "", "Ignore file (default: .gitignore)") rootCmd.PersistentFlags().BoolVarP(&opts.KeepFile, "keep", "k", false, "Keep the generated file after pushing") + var showLineNumbers bool + rootCmd.PersistentFlags().BoolVarP(&showLineNumbers, "line-numbers", "n", false, "Show line numbers in output (overrides config setting)") + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { + // Set the pointer only if the flag was explicitly provided; otherwise + // leave it as nil to use the project settings. + if cmd.Flags().Changed("line-numbers") { + opts.ShowLineNumbers = &showLineNumbers + } + return nil + } + // Add commands rootCmd.AddCommand( - NewGenerateCmd(opts), - NewPushCmd(opts), - NewPurgeCmd(), - NewSetupCmd(), + newGenerateCmd(opts), + newPushCmd(opts), + newPurgeCmd(), + newSetupCmd(), + newConfigCmd(), ) return rootCmd diff --git a/internal/cli/cmd_config.go b/internal/cli/cmd_config.go new file mode 100644 index 0000000..30d16d1 --- /dev/null +++ b/internal/cli/cmd_config.go @@ -0,0 +1,271 @@ +package cli + +import ( + "fmt" + + "github.com/holonoms/sandworm/internal/config" + "github.com/spf13/cobra" +) + +// ConfigOption represents a configuration option +type ConfigOption struct { + Key string + Description string + Default string + ValidValues []string // For enumerated values like true/false + Validator func(string) error +} + +// Registry of all available configuration options +var configOptions = []ConfigOption{ + { + Key: "claude.organization_id", + Description: "The organization ID to use for the Claude API", + Default: "", + }, + { + Key: "claude.project_id", + Description: "The project ID to use for the Claude API", + Default: "", + }, + { + Key: "claude.document_id", + Description: "The document ID to use for the Claude API", + Default: "", + }, + { + Key: "processor.print_line_numbers", + Description: "Print line numbers in the output", + Default: "false", + ValidValues: []string{"true", "false"}, + Validator: validateBoolOption, + }, +} + +// MARK: Sub-commands + +// newConfigCmd creates the config command and its subcommands +func newConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage project configuration", + } + + // Add subcommands + cmd.AddCommand( + newConfigListCmd(), + newConfigGetCmd(), + newConfigSetCmd(), + newConfigUnsetCmd(), + ) + + return cmd +} + +func newConfigListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all configuration values", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return runConfigList() + }, + } + + return cmd +} + +func runConfigList() error { + cfg, err := config.New(".") + if err != nil { + return fmt.Errorf("unable to load config: %w", err) + } + + fmt.Println("Available configuration options:") + fmt.Println() + + for _, option := range configOptions { + fmt.Printf(" %s\n", option.Key) + fmt.Printf(" Description: %s\n", option.Description) + fmt.Printf(" Default: %s\n", option.Default) + + if cfg.Has(option.Key) { + value := cfg.Get(option.Key) + fmt.Printf(" Current: %s\n", value) + } else { + fmt.Printf(" Current: %s (default)\n", option.Default) + } + fmt.Println() + } + + return nil +} + +func newConfigSetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "set ", + Short: "Set a configuration value", + Args: cobra.ExactArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + return runConfigSet(args[0], args[1]) + }, + ValidArgsFunction: func( + _ *cobra.Command, + args []string, + _ string, + ) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return configOptionsKeys(), cobra.ShellCompDirectiveNoFileComp + } + // For values, provide common completions based on the key + if len(args) == 1 { + option := findConfigOption(args[0]) + if option != nil && len(option.ValidValues) > 0 { + return option.ValidValues, cobra.ShellCompDirectiveNoFileComp + } + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + } + + return cmd +} + +func runConfigSet(key, value string) error { + // Find the configuration option + option := findConfigOption(key) + if option == nil { + return fmt.Errorf("unknown configuration option: %s\n\nRun 'sandworm config list' to see available options", key) + } + + // Validate the value + if option.Validator != nil { + if err := option.Validator(value); err != nil { + return fmt.Errorf("invalid value for %s: %w", key, err) + } + } + + cfg, err := config.New(".") + if err != nil { + return fmt.Errorf("unable to load config: %w", err) + } + + if err := cfg.Set(key, value); err != nil { + return fmt.Errorf("unable to set config: %w", err) + } + + fmt.Printf("Set %s = %s\n", key, value) + return nil +} + +func newConfigGetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a configuration value", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return runConfigGet(args[0]) + }, + ValidArgsFunction: func( + _ *cobra.Command, + args []string, + _ string, + ) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return configOptionsKeys(), cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + } + + return cmd +} + +func runConfigGet(key string) error { + // Validate that the key is a known option + option := findConfigOption(key) + if option == nil { + return fmt.Errorf("unknown configuration option: %s\n\nRun 'sandworm config list' to see available options", key) + } + + cfg, err := config.New(".") + if err != nil { + return fmt.Errorf("unable to load config: %w", err) + } + + if !cfg.Has(key) { + fmt.Printf("%s = %s (default)\n", key, option.Default) + return nil + } + + value := cfg.Get(key) + fmt.Printf("%s = %s\n", key, value) + return nil +} + +func newConfigUnsetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "unset ", + Short: "Unset a configuration value", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return runConfigUnset(args[0]) + }, + ValidArgsFunction: func( + _ *cobra.Command, + args []string, + _ string, + ) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return configOptionsKeys(), cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + } + + return cmd +} + +func runConfigUnset(key string) error { + cfg, err := config.New(".") + if err != nil { + return fmt.Errorf("unable to load config: %w", err) + } + + if err := cfg.Delete(key); err != nil { + return fmt.Errorf("unable to unset config: %w", err) + } + + fmt.Printf("Unset %s\n", key) + return nil +} + +// MARK: Helpers + +// findConfigOption finds a config option by key +func findConfigOption(key string) *ConfigOption { + for i := range configOptions { + if configOptions[i].Key == key { + return &configOptions[i] + } + } + return nil +} + +func configOptionsKeys() []string { + keys := make([]string, len(configOptions)) + for i, option := range configOptions { + keys[i] = option.Key + } + return keys +} + +// MARK: Validators + +// validateBoolOption validates that a value is either "true" or "false" +func validateBoolOption(value string) error { + if value != "true" && value != "false" { + return fmt.Errorf("value must be either 'true' or 'false', got: %s", value) + } + return nil +} diff --git a/internal/cli/cmd_generate.go b/internal/cli/cmd_generate.go index 8eda226..4687cdf 100644 --- a/internal/cli/cmd_generate.go +++ b/internal/cli/cmd_generate.go @@ -3,13 +3,14 @@ package cli import ( "fmt" + "github.com/holonoms/sandworm/internal/config" "github.com/holonoms/sandworm/internal/processor" "github.com/holonoms/sandworm/internal/util" "github.com/spf13/cobra" ) -// NewGenerateCmd creates the generate command -func NewGenerateCmd(opts *Options) *cobra.Command { +// newGenerateCmd creates the generate command +func newGenerateCmd(opts *Options) *cobra.Command { cmd := &cobra.Command{ Use: "generate [directory]", Short: "Generate concatenated file only", @@ -38,7 +39,25 @@ func runGenerate(opts *Options) (int64, error) { opts.Directory = "." } - p, err := processor.New(opts.Directory, opts.OutputFile, opts.IgnoreFile) + printLineNumbers := false + if opts.ShowLineNumbers != nil { + printLineNumbers = *opts.ShowLineNumbers + } else { + // If line-numbers flag wasn't explicitly set, load & check the project's settings. + cfg, err := config.New(opts.Directory) + if err != nil { + return 0, fmt.Errorf("unable to load config: %w", err) + } + + if cfg.Has("processor.print_line_numbers") { + value := cfg.Get("processor.print_line_numbers") + if value == "true" { + printLineNumbers = true + } + } + } + + p, err := processor.New(opts.Directory, opts.OutputFile, opts.IgnoreFile, printLineNumbers) if err != nil { return 0, fmt.Errorf("unable to create processor: %w", err) } diff --git a/internal/cli/cmd_purge.go b/internal/cli/cmd_purge.go index ed9dfe8..7769d4f 100644 --- a/internal/cli/cmd_purge.go +++ b/internal/cli/cmd_purge.go @@ -6,8 +6,8 @@ import ( "github.com/spf13/cobra" ) -// NewPurgeCmd creates the purge command -func NewPurgeCmd() *cobra.Command { +// newPurgeCmd creates the purge command +func newPurgeCmd() *cobra.Command { cmd := &cobra.Command{ Use: "purge", Short: "Remove all files from Claude project", diff --git a/internal/cli/cmd_push.go b/internal/cli/cmd_push.go index 6b4b1ea..3b06dcf 100644 --- a/internal/cli/cmd_push.go +++ b/internal/cli/cmd_push.go @@ -11,8 +11,8 @@ import ( "github.com/spf13/cobra" ) -// NewPushCmd creates the push command -func NewPushCmd(opts *Options) *cobra.Command { +// newPushCmd creates the push command +func newPushCmd(opts *Options) *cobra.Command { cmd := &cobra.Command{ Use: "push [directory]", Short: "Generate and push to Claude", diff --git a/internal/cli/cmd_setup.go b/internal/cli/cmd_setup.go index 8afc471..0286c80 100644 --- a/internal/cli/cmd_setup.go +++ b/internal/cli/cmd_setup.go @@ -6,8 +6,8 @@ import ( "github.com/spf13/cobra" ) -// NewSetupCmd creates the setup command -func NewSetupCmd() *cobra.Command { +// newSetupCmd creates the setup command +func newSetupCmd() *cobra.Command { cmd := &cobra.Command{ Use: "setup", Short: "Configure Claude project", diff --git a/internal/cli/options.go b/internal/cli/options.go index 4bdb628..96d486f 100644 --- a/internal/cli/options.go +++ b/internal/cli/options.go @@ -26,6 +26,10 @@ type Options struct { // Directory specifies the root directory to process. // If empty, defaults to the current directory ("."). Directory string + + // ShowLineNumbers determines whether to show line numbers in the output. + // If nil, the value from config will be used. If set, it overrides the config. + ShowLineNumbers *bool } // SetDefaults sets default values for options based on the command context diff --git a/internal/config/config.go b/internal/config/config.go index 3e06ef4..30c530d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -118,6 +118,32 @@ func (c *Config) Delete(key string) error { return c.saveProject() } +// GetAllKeys returns all configuration keys as a slice of strings +func (c *Config) GetAllKeys() []string { + var keys []string + + // Add global keys + for section, sectionData := range c.global { + for subKey := range sectionData { + keys = append(keys, section+"."+subKey) + } + } + + // Add project keys + for section, sectionData := range c.project { + for subKey := range sectionData { + keys = append(keys, section+"."+subKey) + } + } + + return keys +} + +// IsGlobalKey checks if a key is stored in global config +func (c *Config) IsGlobalKey(key string) bool { + return globalKeys[key] +} + // MARK: Internal helper functions func splitKey(key string) (section, subKey string) { diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 2d52280..850fc30 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -7,6 +7,7 @@ package processor import ( "bufio" "fmt" + "math" "os" "path/filepath" "strings" @@ -84,20 +85,22 @@ go.sum // Processor handles the concatenation of project files into a single document type Processor struct { - rootDir string - outputFile string - ignoreFile string - matcher gitignore.Matcher + rootDir string + outputFile string + ignoreFile string + matcher gitignore.Matcher + printLineNumbers bool } // New creates a new Processor instance -func New(rootDir, outputFile, ignoreFile string) (*Processor, error) { +func New(rootDir, outputFile, ignoreFile string, printLineNumbers bool) (*Processor, error) { rootDir = filepath.Clean(rootDir) p := &Processor{ - rootDir: rootDir, - outputFile: outputFile, - ignoreFile: ignoreFile, + rootDir: rootDir, + outputFile: outputFile, + ignoreFile: ignoreFile, + printLineNumbers: printLineNumbers, } // Initialize patterns with EXTRA_IGNORES @@ -217,7 +220,7 @@ func (p *Processor) collectFiles() ([]string, error) { if err != nil { return fmt.Errorf("failed to get relative path: %w", err) } - + // Normalize to forward slashes for consistent processing // This ensures gitignore patterns work and output is uniform across platforms normalizedPath := filepath.ToSlash(relPath) @@ -263,14 +266,21 @@ func (p *Processor) writeContents(w *bufio.Writer, files []string) error { return err } - // Read and write file contents + // Read file contents content, err := os.ReadFile(filepath.Join(p.rootDir, file)) if err != nil { return fmt.Errorf("failed to read file %s: %w", file, err) } - if _, err := w.Write(content); err != nil { - return err + // Write file contents with optional line numbers + if p.printLineNumbers { + if err := p.writeContentWithLineNumbers(w, content); err != nil { + return err + } + } else { + if _, err := w.Write(content); err != nil { + return err + } } if _, err := w.WriteString("\n"); err != nil { @@ -279,3 +289,29 @@ func (p *Processor) writeContents(w *bufio.Writer, files []string) error { } return nil } + +// writeContentWithLineNumbers writes file content with line numbers +func (p *Processor) writeContentWithLineNumbers(w *bufio.Writer, content []byte) error { + lines := strings.Split(string(content), "\n") + + // Calculate the number of digits needed for the largest line number + numLines := len(lines) + if numLines == 0 { + return nil + } + + // Calculate padding based on the number of lines to dynamically adjust + // the width of the line numbers. + padding := int(math.Log10(float64(numLines))) + formatStr := fmt.Sprintf("%%%dd: %%s\n", (padding + 1)) + + for i, line := range lines { + lineNum := i + 1 + formattedLine := fmt.Sprintf(formatStr, lineNum, line) + if _, err := w.WriteString(formattedLine); err != nil { + return err + } + } + + return nil +} diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 15faa64..4143fe2 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -34,7 +34,7 @@ func TestProcessor(t *testing.T) { createFile("dir1/file2.txt", "Content 2") outputFile := filepath.Join(tmpDir, "output.txt") - p, err := New(tmpDir, outputFile, "") + p, err := New(tmpDir, outputFile, "", false) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -81,7 +81,7 @@ func TestProcessor(t *testing.T) { createFile("keep.txt", "Should be kept") outputFile := filepath.Join(tmpDir, "output.txt") - p, err := New(tmpDir, outputFile, filepath.Join(tmpDir, ".gitignore")) + p, err := New(tmpDir, outputFile, filepath.Join(tmpDir, ".gitignore"), false) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -116,7 +116,7 @@ func TestProcessor(t *testing.T) { createFile("text.txt", "Regular text file") outputFile := filepath.Join(tmpDir, "output.txt") - p, err := New(tmpDir, outputFile, "") + p, err := New(tmpDir, outputFile, "", false) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -151,7 +151,7 @@ func TestProcessor(t *testing.T) { createFile("keep.txt", "Should be kept") outputFile := filepath.Join(tmpDir, "output.txt") - p, err := New(tmpDir, outputFile, filepath.Join(tmpDir, "custom.ignore")) + p, err := New(tmpDir, outputFile, filepath.Join(tmpDir, "custom.ignore"), false) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -208,7 +208,7 @@ func TestProcessor(t *testing.T) { // Process the files outputFile := filepath.Join(tmpDir, "output.txt") - p, err := New(tmpDir, outputFile, "") + p, err := New(tmpDir, outputFile, "", false) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -239,4 +239,75 @@ func TestProcessor(t *testing.T) { } } }) + + t.Run("line numbers", func(t *testing.T) { + // Reset temp directory + os.RemoveAll(tmpDir) + os.MkdirAll(tmpDir, 0o755) + + // Create test files with multiple lines + createFile("file1.txt", "Line 1\nLine 2\nLine 3") + createFile("dir1/file2.txt", "First line\nSecond line") + + outputFile := filepath.Join(tmpDir, "output.txt") + p, err := New(tmpDir, outputFile, "", true) // Enable line numbers + if err != nil { + t.Fatalf("Failed to create processor: %v", err) + } + + size, err := p.Process() + if err != nil { + t.Fatalf("Process failed: %v", err) + } + + if size == 0 { + t.Error("Expected non-zero file size") + } + + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + output := string(content) + + // Check for expected content with line numbers + if !strings.Contains(output, "PROJECT STRUCTURE:") { + t.Error("Missing project structure section") + } + if !strings.Contains(output, "FILE CONTENTS:") { + t.Error("Missing file contents section") + } + + // Check that line numbers are present + if !strings.Contains(output, "1: Line 1") { + t.Error("Missing line number 1 for file1.txt") + } + if !strings.Contains(output, "2: Line 2") { + t.Error("Missing line number 2 for file1.txt") + } + if !strings.Contains(output, "3: Line 3") { + t.Error("Missing line number 3 for file1.txt") + } + if !strings.Contains(output, "1: First line") { + t.Error("Missing line number 1 for file2.txt") + } + if !strings.Contains(output, "2: Second line") { + t.Error("Missing line number 2 for file2.txt") + } + + // Check that the line number format is correct (3 spaces + number + colon + space) + lines := strings.Split(output, "\n") + lineNumberPattern := "1: " + foundLineNumber := false + for _, line := range lines { + if strings.Contains(line, lineNumberPattern) { + foundLineNumber = true + break + } + } + if !foundLineNumber { + t.Error("Line number format is incorrect") + } + }) }