-
Notifications
You must be signed in to change notification settings - Fork 2
Epic: Phase 3 - Project Restructuring #3
Copy link
Copy link
Closed
Description
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.gotocmd/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.Commandfactory functions - Create
run{Name}(opts *{Name}Options, client *Client) errorfunctions - 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
Configstruct with all fields - Implement
Validate()method - Implement
LoadFromEnv()method - Implement
Load(path)andSave(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.gointernal/cmd/root/root.gointernal/cmd/channels/channels.gointernal/cmd/channels/list.gointernal/cmd/channels/get.gointernal/cmd/channels/create.gointernal/cmd/channels/archive.gointernal/cmd/channels/set_topic.gointernal/cmd/channels/set_purpose.gointernal/cmd/channels/invite.gointernal/cmd/users/users.gointernal/cmd/users/list.gointernal/cmd/users/get.gointernal/cmd/messages/messages.gointernal/cmd/messages/send.gointernal/cmd/messages/update.gointernal/cmd/messages/delete.gointernal/cmd/messages/history.gointernal/cmd/messages/thread.gointernal/cmd/messages/react.gointernal/cmd/workspace/workspace.gointernal/cmd/workspace/info.gointernal/cmd/config/config.gointernal/cmd/config/set_token.gointernal/cmd/config/delete_token.gointernal/cmd/config/show.gointernal/config/config.gointernal/config/config_test.go
Delete (after migration):
main.gocmd/root.gocmd/channels.gocmd/users.gocmd/messages.gocmd/workspace.gocmd/config.go
Modify:
Makefile(update build path).goreleaser.yaml(update build path)
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels