Skip to content

Epic: Phase 3 - Project Restructuring #3

@rianjs

Description

@rianjs

Phase 3: Project Restructuring

Priority: P1 - Major refactoring for maintainability
Estimated Sub-tasks: 3
Blocked By: Phase 2 (Tests must exist before restructuring)

Summary

Restructure the project to match the cli-guide.md recommended layout. This includes moving to a hierarchical command structure, implementing the options struct pattern, and extracting a dedicated config package.


Sub-Tasks

1. Restructure Project to Match Guide Layout

  • Move main.go to cmd/slack-cli/main.go
  • Create internal/cmd/root/root.go
  • Create internal/cmd/channels/ directory with separate files per subcommand
  • Create internal/cmd/users/ directory
  • Create internal/cmd/messages/ directory
  • Create internal/cmd/workspace/ directory
  • Create internal/cmd/config/ directory
  • Update all imports
  • Update Makefile build path
  • Update .goreleaser.yaml build path
  • Verify all tests still pass
  • Verify binary still works

Current Structure:

slack-cli/
├── main.go
├── cmd/
│   ├── root.go
│   ├── channels.go
│   ├── users.go
│   ├── messages.go
│   ├── workspace.go
│   └── config.go
└── internal/
    ├── client/
    └── keychain/

Target Structure:

slack-cli/
├── cmd/slack-cli/
│   └── main.go
├── internal/
│   ├── cmd/
│   │   ├── root/
│   │   │   └── root.go
│   │   ├── channels/
│   │   │   ├── channels.go      # Parent command
│   │   │   ├── list.go
│   │   │   ├── list_test.go
│   │   │   ├── get.go
│   │   │   ├── create.go
│   │   │   ├── archive.go
│   │   │   └── ...
│   │   ├── users/
│   │   │   ├── users.go
│   │   │   ├── list.go
│   │   │   └── get.go
│   │   ├── messages/
│   │   │   ├── messages.go
│   │   │   ├── send.go
│   │   │   ├── history.go
│   │   │   └── ...
│   │   ├── workspace/
│   │   │   ├── workspace.go
│   │   │   └── info.go
│   │   └── config/
│   │       ├── config.go
│   │       ├── set_token.go
│   │       └── ...
│   ├── client/
│   ├── keychain/
│   ├── config/         # Configuration management
│   ├── view/           # Output formatting
│   └── version/
└── api/                # Future: public API client

Entry Point Update:

// cmd/slack-cli/main.go
package main

import (
    "fmt"
    "os"
    
    "github.com/piekstra/slack-cli/internal/cmd/root"
)

func main() {
    cmd := root.NewCmdRoot()
    if err := cmd.Execute(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %s\n", err)
        os.Exit(1)
    }
}

2. Implement Options Struct Pattern for All Commands

  • Create options struct for each command
  • Move flag definitions to struct fields
  • Create NewCmd{Name}() *cobra.Command factory functions
  • Create run{Name}(opts *{Name}Options, client *Client) error functions
  • Add client injection point for testing
  • Update all tests to use new pattern

Current Pattern:

var listCmd = &cobra.Command{
    Use: "list",
    RunE: func(cmd *cobra.Command, args []string) error {
        types, _ := cmd.Flags().GetString("types")
        // ... inline logic
    },
}

func init() {
    channelsCmd.AddCommand(listCmd)
    listCmd.Flags().String("types", "", "...")
}

Target Pattern:

type listOptions struct {
    types           string
    excludeArchived bool
    limit           int
    output          string
    noColor         bool
}

func NewCmdList() *cobra.Command {
    opts := &listOptions{}
    
    cmd := &cobra.Command{
        Use:   "list",
        Short: "List all channels",
        Long:  `List all channels in the workspace with optional filtering.`,
        Example: `  # List all public channels
  slack-cli channels list
  
  # List only private channels
  slack-cli channels list --types=private_channel
  
  # Output as JSON for scripting
  slack-cli channels list -o json | jq '.[].name'`,
        RunE: func(cmd *cobra.Command, args []string) error {
            opts.output, _ = cmd.Flags().GetString("output")
            opts.noColor, _ = cmd.Flags().GetBool("no-color")
            return runList(opts, nil)
        },
    }
    
    cmd.Flags().StringVar(&opts.types, "types", "", "Channel types: public_channel,private_channel,mpim,im")
    cmd.Flags().BoolVar(&opts.excludeArchived, "exclude-archived", true, "Exclude archived channels")
    cmd.Flags().IntVar(&opts.limit, "limit", 100, "Maximum channels to return")
    
    return cmd
}

func runList(opts *listOptions, c *client.Client) error {
    // Create client if not injected (for testing)
    if c == nil {
        var err error
        c, err = client.New()
        if err != nil {
            return err
        }
    }
    
    channels, err := c.ListChannels(opts.types, opts.excludeArchived, opts.limit)
    if err != nil {
        return fmt.Errorf("failed to list channels: %w", err)
    }
    
    renderer := view.NewRenderer(view.Format(opts.output), opts.noColor)
    // ... render output
}

3. Extract and Standardize Configuration Package

  • Create internal/config/config.go
  • Define Config struct with all fields
  • Implement Validate() method
  • Implement LoadFromEnv() method
  • Implement Load(path) and Save(path) methods
  • Implement LoadWithEnv(path) combined method
  • Implement DefaultConfigPath() with XDG support
  • Integrate with existing keychain for token storage

Implementation:

// internal/config/config.go
package config

import (
    "errors"
    "fmt"
    "os"
    "path/filepath"
    
    "gopkg.in/yaml.v3"
)

type Config struct {
    APIToken       string `yaml:"api_token,omitempty"`
    DefaultChannel string `yaml:"default_channel,omitempty"`
    OutputFormat   string `yaml:"output_format,omitempty"` // table, json, plain
    NoColor        bool   `yaml:"no_color,omitempty"`
}

func (c *Config) Validate() error {
    // Token can come from keychain or env, so not required in file
    return nil
}

func (c *Config) LoadFromEnv() {
    if token := os.Getenv("SLACK_API_TOKEN"); token != "" {
        c.APIToken = token
    }
    if os.Getenv("NO_COLOR") != "" {
        c.NoColor = true
    }
}

func DefaultConfigPath() string {
    if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" {
        return filepath.Join(xdgConfig, "slack-cli", "config.yml")
    }
    home, _ := os.UserHomeDir()
    return filepath.Join(home, ".config", "slack-cli", "config.yml")
}

func Load(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        if os.IsNotExist(err) {
            return &Config{}, nil // Return empty config if file doesn't exist
        }
        return nil, fmt.Errorf("failed to read config: %w", err)
    }
    
    var cfg Config
    if err := yaml.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("failed to parse config: %w", err)
    }
    return &cfg, nil
}

func (c *Config) Save(path string) error {
    dir := filepath.Dir(path)
    if err := os.MkdirAll(dir, 0755); err != nil {
        return fmt.Errorf("failed to create config directory: %w", err)
    }
    
    data, err := yaml.Marshal(c)
    if err != nil {
        return fmt.Errorf("failed to marshal config: %w", err)
    }
    
    if err := os.WriteFile(path, data, 0600); err != nil {
        return fmt.Errorf("failed to write config: %w", err)
    }
    return nil
}

func LoadWithEnv(path string) (*Config, error) {
    cfg, err := Load(path)
    if err != nil {
        return nil, err
    }
    cfg.LoadFromEnv()
    return cfg, nil
}

Acceptance Criteria

  • All tests pass after restructuring
  • Binary still works identically to before
  • All commands use options struct pattern
  • All commands have factory functions
  • Config package supports file and env vars
  • Import organization follows standard library → external → local pattern

Dependencies

  • Blocked By: Phase 2 (tests must exist before refactoring)
  • Blocks: Phase 4 (output consistency)

Files to Create

New:

  • cmd/slack-cli/main.go
  • internal/cmd/root/root.go
  • internal/cmd/channels/channels.go
  • internal/cmd/channels/list.go
  • internal/cmd/channels/get.go
  • internal/cmd/channels/create.go
  • internal/cmd/channels/archive.go
  • internal/cmd/channels/set_topic.go
  • internal/cmd/channels/set_purpose.go
  • internal/cmd/channels/invite.go
  • internal/cmd/users/users.go
  • internal/cmd/users/list.go
  • internal/cmd/users/get.go
  • internal/cmd/messages/messages.go
  • internal/cmd/messages/send.go
  • internal/cmd/messages/update.go
  • internal/cmd/messages/delete.go
  • internal/cmd/messages/history.go
  • internal/cmd/messages/thread.go
  • internal/cmd/messages/react.go
  • internal/cmd/workspace/workspace.go
  • internal/cmd/workspace/info.go
  • internal/cmd/config/config.go
  • internal/cmd/config/set_token.go
  • internal/cmd/config/delete_token.go
  • internal/cmd/config/show.go
  • internal/config/config.go
  • internal/config/config_test.go

Delete (after migration):

  • main.go
  • cmd/root.go
  • cmd/channels.go
  • cmd/users.go
  • cmd/messages.go
  • cmd/workspace.go
  • cmd/config.go

Modify:

  • Makefile (update build path)
  • .goreleaser.yaml (update build path)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions