···2626 Create() *cobra.Command
2727}
28282929-// MovieCommand implements CommandGroup for movie-related commands
2929+// MovieCommand implements [CommandGroup] for movie-related commands
3030type MovieCommand struct {
3131 handler *handlers.MovieHandler
3232}
···193193 return root
194194}
195195196196-// BookCommand implements CommandGroup for book-related commands
196196+// BookCommand implements [CommandGroup] for book-related commands
197197type BookCommand struct {
198198 handler *handlers.BookHandler
199199}
···409409410410 return root
411411}
412412+413413+// ArticleCommand implements [CommandGroup] for article-related commands
414414+type ArticleCommand struct {
415415+ handler *handlers.ArticleHandler
416416+}
417417+418418+// NewArticleCommand creates a new ArticleCommand with the given handler
419419+func NewArticleCommand(handler *handlers.ArticleHandler) *ArticleCommand {
420420+ return &ArticleCommand{handler: handler}
421421+}
422422+423423+func (c *ArticleCommand) Create() *cobra.Command {
424424+ root := &cobra.Command{Use: "article", Short: "Manage saved articles"}
425425+426426+ addCmd := &cobra.Command{
427427+ Use: "add <url>",
428428+ Short: "Parse and save article from URL",
429429+ Long: `Parse and save article content from a supported website.
430430+431431+The article will be parsed using domain-specific XPath rules and saved
432432+as both Markdown and HTML files. Article metadata is stored in the database.`,
433433+ Args: cobra.ExactArgs(1),
434434+ RunE: func(cmd *cobra.Command, args []string) error {
435435+436436+ defer c.handler.Close()
437437+ return c.handler.Add(cmd.Context(), args[0])
438438+ },
439439+ }
440440+ root.AddCommand(addCmd)
441441+442442+ listCmd := &cobra.Command{
443443+ Use: "list [query]",
444444+ Short: "List saved articles",
445445+ Aliases: []string{"ls"},
446446+ Long: `List saved articles with optional filtering.
447447+448448+Use query to filter by title, or use flags for more specific filtering.`,
449449+ RunE: func(cmd *cobra.Command, args []string) error {
450450+ author, _ := cmd.Flags().GetString("author")
451451+ limit, _ := cmd.Flags().GetInt("limit")
452452+453453+ var query string
454454+ if len(args) > 0 {
455455+ query = strings.Join(args, " ")
456456+ }
457457+458458+ defer c.handler.Close()
459459+ return c.handler.List(cmd.Context(), query, author, limit)
460460+ },
461461+ }
462462+ listCmd.Flags().String("author", "", "Filter by author")
463463+ listCmd.Flags().IntP("limit", "l", 0, "Limit number of results (0 = no limit)")
464464+ root.AddCommand(listCmd)
465465+466466+ viewCmd := &cobra.Command{
467467+ Use: "view <id>",
468468+ Short: "View article details and content preview",
469469+ Aliases: []string{"show"},
470470+ Args: cobra.ExactArgs(1),
471471+ RunE: func(cmd *cobra.Command, args []string) error {
472472+ if articleID, err := parseID("article", args); err != nil {
473473+ return err
474474+ } else {
475475+ defer c.handler.Close()
476476+ return c.handler.View(cmd.Context(), articleID)
477477+ }
478478+ },
479479+ }
480480+ root.AddCommand(viewCmd)
481481+482482+ removeCmd := &cobra.Command{
483483+ Use: "remove <id>",
484484+ Short: "Remove article and associated files",
485485+ Aliases: []string{"rm", "delete"},
486486+ Args: cobra.ExactArgs(1),
487487+ RunE: func(cmd *cobra.Command, args []string) error {
488488+ if articleID, err := parseID("article", args); err != nil {
489489+ return err
490490+ } else {
491491+ defer c.handler.Close()
492492+ return c.handler.Remove(cmd.Context(), articleID)
493493+ }
494494+ },
495495+ }
496496+ root.AddCommand(removeCmd)
497497+498498+ originalHelpFunc := root.HelpFunc()
499499+ root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
500500+ originalHelpFunc(cmd, args)
501501+502502+ fmt.Println()
503503+ defer c.handler.Close()
504504+ c.handler.Help()
505505+ })
506506+507507+ return root
508508+}
+131
cmd/commands_test.go
···100100 }
101101}
102102103103+func createTestArticleHandler(t *testing.T) (*handlers.ArticleHandler, func()) {
104104+ cleanup := setupCommandTest(t)
105105+ handler, err := handlers.NewArticleHandler()
106106+ if err != nil {
107107+ cleanup()
108108+ t.Fatalf("Failed to create test article handler: %v", err)
109109+ }
110110+ return handler, func() {
111111+ handler.Close()
112112+ cleanup()
113113+ }
114114+}
115115+103116func findSubcommand(commands []string, target string) bool {
104117 return slices.Contains(commands, target)
105118}
···121134 bookHandler, bookCleanup := createTestBookHandler(t)
122135 defer bookCleanup()
123136137137+ articleHandler, articleCleanup := createTestArticleHandler(t)
138138+ defer articleCleanup()
139139+124140 var _ CommandGroup = NewTaskCommand(taskHandler)
125141 var _ CommandGroup = NewMovieCommand(movieHandler)
126142 var _ CommandGroup = NewTVCommand(tvHandler)
127143 var _ CommandGroup = NewNoteCommand(noteHandler)
128144 var _ CommandGroup = NewBookCommand(bookHandler)
145145+ var _ CommandGroup = NewArticleCommand(articleHandler)
129146 })
130147131148 t.Run("Create", func(t *testing.T) {
···318335 }
319336 })
320337338338+ t.Run("ArticleCommand", func(t *testing.T) {
339339+ handler, cleanup := createTestArticleHandler(t)
340340+ defer cleanup()
341341+342342+ commands := NewArticleCommand(handler)
343343+ cmd := commands.Create()
344344+345345+ if cmd == nil {
346346+ t.Fatal("Create returned nil")
347347+ }
348348+ if cmd.Use != "article" {
349349+ t.Errorf("Expected Use to be 'article', got '%s'", cmd.Use)
350350+ }
351351+ if cmd.Short != "Manage saved articles" {
352352+ t.Errorf("Expected Short to be 'Manage saved articles', got '%s'", cmd.Short)
353353+ }
354354+ if !cmd.HasSubCommands() {
355355+ t.Error("Expected command to have subcommands")
356356+ }
357357+358358+ subcommands := cmd.Commands()
359359+ subcommandNames := make([]string, len(subcommands))
360360+ for i, subcmd := range subcommands {
361361+ subcommandNames[i] = subcmd.Use
362362+ }
363363+364364+ for _, expected := range []string{"add <url>", "list [query]", "view <id>", "remove <id>"} {
365365+ if !findSubcommand(subcommandNames, expected) {
366366+ t.Errorf("Expected subcommand '%s' not found in %v", expected, subcommandNames)
367367+ }
368368+ }
369369+ })
370370+321371 t.Run("all command groups implement Create", func(t *testing.T) {
322372 taskHandler, taskCleanup := createTestTaskHandler(t)
323373 defer taskCleanup()
···334384 bookHandler, bookCleanup := createTestBookHandler(t)
335385 defer bookCleanup()
336386387387+ articleHandler, articleCleanup := createTestArticleHandler(t)
388388+ defer articleCleanup()
389389+337390 groups := []CommandGroup{
338391 NewTaskCommand(taskHandler),
339392 NewMovieCommand(movieHandler),
340393 NewTVCommand(tvHandler),
341394 NewNoteCommand(noteHandler),
342395 NewBookCommand(bookHandler),
396396+ NewArticleCommand(articleHandler),
343397 }
344398345399 for i, group := range groups {
···546600 err := cmd.Execute()
547601 if err == nil {
548602 t.Error("expected book update command to fail with invalid status")
603603+ }
604604+ })
605605+ })
606606+607607+ t.Run("Article Commands", func(t *testing.T) {
608608+ handler, cleanup := createTestArticleHandler(t)
609609+ defer cleanup()
610610+611611+ t.Run("list command - default", func(t *testing.T) {
612612+ cmd := NewArticleCommand(handler).Create()
613613+ cmd.SetArgs([]string{"list"})
614614+ err := cmd.Execute()
615615+ if err != nil {
616616+ t.Errorf("article list command failed: %v", err)
617617+ }
618618+ })
619619+620620+ t.Run("help command", func(t *testing.T) {
621621+ cmd := NewArticleCommand(handler).Create()
622622+ cmd.SetArgs([]string{"help"})
623623+ err := cmd.Execute()
624624+ if err != nil {
625625+ t.Errorf("article help command failed: %v", err)
626626+ }
627627+ })
628628+629629+ t.Run("add command with empty args", func(t *testing.T) {
630630+ cmd := NewArticleCommand(handler).Create()
631631+ cmd.SetArgs([]string{"add"})
632632+ err := cmd.Execute()
633633+ if err == nil {
634634+ t.Error("expected article add command to fail with empty args")
635635+ }
636636+ })
637637+638638+ t.Run("add command with invalid URL", func(t *testing.T) {
639639+ cmd := NewArticleCommand(handler).Create()
640640+ cmd.SetArgs([]string{"add", "not-a-url"})
641641+ err := cmd.Execute()
642642+ if err == nil {
643643+ t.Error("expected article add command to fail with invalid URL")
644644+ }
645645+ })
646646+647647+ t.Run("view command with non-existent article ID", func(t *testing.T) {
648648+ cmd := NewArticleCommand(handler).Create()
649649+ cmd.SetArgs([]string{"view", "999"})
650650+ err := cmd.Execute()
651651+ if err == nil {
652652+ t.Error("expected article view command to fail with non-existent ID")
653653+ }
654654+ })
655655+656656+ t.Run("view command with non-numeric ID", func(t *testing.T) {
657657+ cmd := NewArticleCommand(handler).Create()
658658+ cmd.SetArgs([]string{"view", "invalid"})
659659+ err := cmd.Execute()
660660+ if err == nil {
661661+ t.Error("expected article view command to fail with non-numeric ID")
662662+ }
663663+ })
664664+665665+ t.Run("remove command with non-existent article ID", func(t *testing.T) {
666666+ cmd := NewArticleCommand(handler).Create()
667667+ cmd.SetArgs([]string{"remove", "999"})
668668+ err := cmd.Execute()
669669+ if err == nil {
670670+ t.Error("expected article remove command to fail with non-existent ID")
671671+ }
672672+ })
673673+674674+ t.Run("remove command with non-numeric ID", func(t *testing.T) {
675675+ cmd := NewArticleCommand(handler).Create()
676676+ cmd.SetArgs([]string{"remove", "invalid"})
677677+ err := cmd.Execute()
678678+ if err == nil {
679679+ t.Error("expected article remove command to fail with non-numeric ID")
549680 }
550681 })
551682 })
+6
cmd/main.go
···164164 log.Fatalf("failed to create book handler: %v", err)
165165 }
166166167167+ articleHandler, err := handlers.NewArticleHandler()
168168+ if err != nil {
169169+ log.Fatalf("failed to create article handler: %v", err)
170170+ }
171171+167172 root := rootCmd()
168173169174 coreGroups := []CommandGroup{
170175 NewTaskCommand(taskHandler),
171176 NewNoteCommand(noteHandler),
177177+ NewArticleCommand(articleHandler),
172178 }
173179174180 for _, group := range coreGroups {
+27-2
docs/cli.md
···2222Handlers are created once in `main.go` during application startup. Initialization errors prevent application launch rather than causing runtime failures.
2323Handlers persist for the application lifetime without requiring cleanup. Commands access handlers through struct fields rather than creating new instances.
24242525-### Testing Benefits
2525+### Testing
26262727`CommandGroup` structs accept handlers as constructor parameters, enabling easy dependency injection of mock handlers for testing.
2828Command logic can be tested independently of handler implementations. The interface allows mocking entire command groups for integration testing.
29293030-### Registry Pattern
3030+### Registry
31313232`main.go` uses a registry pattern to organize command groups by category. Core commands include task, note, and media functionality.
3333Management commands handle configuration, setup, and maintenance operations. The pattern provides clean separation and easy extension for new command groups.
3434+3535+## UI and Styling System
3636+3737+The application uses a structured color palette system located in `internal/ui/colors.go` for consistent terminal output styling.
3838+3939+### Color Architecture
4040+4141+The color system implements a `Key` type with 74 predefined colors from the Charm ecosystem, including warm tones (Cumin, Tang, Paprika), cool tones (Sapphire, Oceania, Zinc), and neutral grays (Pepper through Butter). Each color provides hex values via the `Hex()` method and implements Go's `color.Color` interface through `RGBA()`.
4242+4343+### Predefined Styles
4444+4545+Three core lipgloss styles handle common UI elements:
4646+4747+- `TitleColorStyle` uses color 212 with bold formatting for command titles
4848+- `SelectedColorStyle` provides white-on-212 highlighting for selected items
4949+- `HeaderColorStyle` applies color 240 with bold formatting for section headers
5050+5151+### Color Categories
5252+5353+Colors are organized into primary, secondary, and tertiary categories are accessed through `IsPrimary()`, `IsSecondary()`, and `IsTertiary()` methods on the `Key` type.
5454+5555+### Lipgloss Integration
5656+5757+The styling system integrates with the Charmbracelet lipgloss library for terminal UI rendering.
5858+Colors from the `Key` type convert to lipgloss color values through their `Hex()` method. The predefined `TitleColorStyle`, `SelectedColorStyle`, and `HeaderColorStyle` variables provide lipgloss styles that can be applied to strings with `.Render()`.