···26 Create() *cobra.Command
27}
2829+// MovieCommand implements [CommandGroup] for movie-related commands
30type MovieCommand struct {
31 handler *handlers.MovieHandler
32}
···193 return root
194}
195196+// BookCommand implements [CommandGroup] for book-related commands
197type BookCommand struct {
198 handler *handlers.BookHandler
199}
···409410 return root
411}
412+413+// ArticleCommand implements [CommandGroup] for article-related commands
414+type ArticleCommand struct {
415+ handler *handlers.ArticleHandler
416+}
417+418+// NewArticleCommand creates a new ArticleCommand with the given handler
419+func NewArticleCommand(handler *handlers.ArticleHandler) *ArticleCommand {
420+ return &ArticleCommand{handler: handler}
421+}
422+423+func (c *ArticleCommand) Create() *cobra.Command {
424+ root := &cobra.Command{Use: "article", Short: "Manage saved articles"}
425+426+ addCmd := &cobra.Command{
427+ Use: "add <url>",
428+ Short: "Parse and save article from URL",
429+ Long: `Parse and save article content from a supported website.
430+431+The article will be parsed using domain-specific XPath rules and saved
432+as both Markdown and HTML files. Article metadata is stored in the database.`,
433+ Args: cobra.ExactArgs(1),
434+ RunE: func(cmd *cobra.Command, args []string) error {
435+436+ defer c.handler.Close()
437+ return c.handler.Add(cmd.Context(), args[0])
438+ },
439+ }
440+ root.AddCommand(addCmd)
441+442+ listCmd := &cobra.Command{
443+ Use: "list [query]",
444+ Short: "List saved articles",
445+ Aliases: []string{"ls"},
446+ Long: `List saved articles with optional filtering.
447+448+Use query to filter by title, or use flags for more specific filtering.`,
449+ RunE: func(cmd *cobra.Command, args []string) error {
450+ author, _ := cmd.Flags().GetString("author")
451+ limit, _ := cmd.Flags().GetInt("limit")
452+453+ var query string
454+ if len(args) > 0 {
455+ query = strings.Join(args, " ")
456+ }
457+458+ defer c.handler.Close()
459+ return c.handler.List(cmd.Context(), query, author, limit)
460+ },
461+ }
462+ listCmd.Flags().String("author", "", "Filter by author")
463+ listCmd.Flags().IntP("limit", "l", 0, "Limit number of results (0 = no limit)")
464+ root.AddCommand(listCmd)
465+466+ viewCmd := &cobra.Command{
467+ Use: "view <id>",
468+ Short: "View article details and content preview",
469+ Aliases: []string{"show"},
470+ Args: cobra.ExactArgs(1),
471+ RunE: func(cmd *cobra.Command, args []string) error {
472+ if articleID, err := parseID("article", args); err != nil {
473+ return err
474+ } else {
475+ defer c.handler.Close()
476+ return c.handler.View(cmd.Context(), articleID)
477+ }
478+ },
479+ }
480+ root.AddCommand(viewCmd)
481+482+ removeCmd := &cobra.Command{
483+ Use: "remove <id>",
484+ Short: "Remove article and associated files",
485+ Aliases: []string{"rm", "delete"},
486+ Args: cobra.ExactArgs(1),
487+ RunE: func(cmd *cobra.Command, args []string) error {
488+ if articleID, err := parseID("article", args); err != nil {
489+ return err
490+ } else {
491+ defer c.handler.Close()
492+ return c.handler.Remove(cmd.Context(), articleID)
493+ }
494+ },
495+ }
496+ root.AddCommand(removeCmd)
497+498+ originalHelpFunc := root.HelpFunc()
499+ root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
500+ originalHelpFunc(cmd, args)
501+502+ fmt.Println()
503+ defer c.handler.Close()
504+ c.handler.Help()
505+ })
506+507+ return root
508+}
+131
cmd/commands_test.go
···100 }
101}
1020000000000000103func findSubcommand(commands []string, target string) bool {
104 return slices.Contains(commands, target)
105}
···121 bookHandler, bookCleanup := createTestBookHandler(t)
122 defer bookCleanup()
123000124 var _ CommandGroup = NewTaskCommand(taskHandler)
125 var _ CommandGroup = NewMovieCommand(movieHandler)
126 var _ CommandGroup = NewTVCommand(tvHandler)
127 var _ CommandGroup = NewNoteCommand(noteHandler)
128 var _ CommandGroup = NewBookCommand(bookHandler)
0129 })
130131 t.Run("Create", func(t *testing.T) {
···318 }
319 })
320000000000000000000000000000000000321 t.Run("all command groups implement Create", func(t *testing.T) {
322 taskHandler, taskCleanup := createTestTaskHandler(t)
323 defer taskCleanup()
···334 bookHandler, bookCleanup := createTestBookHandler(t)
335 defer bookCleanup()
336000337 groups := []CommandGroup{
338 NewTaskCommand(taskHandler),
339 NewMovieCommand(movieHandler),
340 NewTVCommand(tvHandler),
341 NewNoteCommand(noteHandler),
342 NewBookCommand(bookHandler),
0343 }
344345 for i, group := range groups {
···546 err := cmd.Execute()
547 if err == nil {
548 t.Error("expected book update command to fail with invalid status")
00000000000000000000000000000000000000000000000000000000000000000000000000000549 }
550 })
551 })
···100 }
101}
102103+func createTestArticleHandler(t *testing.T) (*handlers.ArticleHandler, func()) {
104+ cleanup := setupCommandTest(t)
105+ handler, err := handlers.NewArticleHandler()
106+ if err != nil {
107+ cleanup()
108+ t.Fatalf("Failed to create test article handler: %v", err)
109+ }
110+ return handler, func() {
111+ handler.Close()
112+ cleanup()
113+ }
114+}
115+116func findSubcommand(commands []string, target string) bool {
117 return slices.Contains(commands, target)
118}
···134 bookHandler, bookCleanup := createTestBookHandler(t)
135 defer bookCleanup()
136137+ articleHandler, articleCleanup := createTestArticleHandler(t)
138+ defer articleCleanup()
139+140 var _ CommandGroup = NewTaskCommand(taskHandler)
141 var _ CommandGroup = NewMovieCommand(movieHandler)
142 var _ CommandGroup = NewTVCommand(tvHandler)
143 var _ CommandGroup = NewNoteCommand(noteHandler)
144 var _ CommandGroup = NewBookCommand(bookHandler)
145+ var _ CommandGroup = NewArticleCommand(articleHandler)
146 })
147148 t.Run("Create", func(t *testing.T) {
···335 }
336 })
337338+ t.Run("ArticleCommand", func(t *testing.T) {
339+ handler, cleanup := createTestArticleHandler(t)
340+ defer cleanup()
341+342+ commands := NewArticleCommand(handler)
343+ cmd := commands.Create()
344+345+ if cmd == nil {
346+ t.Fatal("Create returned nil")
347+ }
348+ if cmd.Use != "article" {
349+ t.Errorf("Expected Use to be 'article', got '%s'", cmd.Use)
350+ }
351+ if cmd.Short != "Manage saved articles" {
352+ t.Errorf("Expected Short to be 'Manage saved articles', got '%s'", cmd.Short)
353+ }
354+ if !cmd.HasSubCommands() {
355+ t.Error("Expected command to have subcommands")
356+ }
357+358+ subcommands := cmd.Commands()
359+ subcommandNames := make([]string, len(subcommands))
360+ for i, subcmd := range subcommands {
361+ subcommandNames[i] = subcmd.Use
362+ }
363+364+ for _, expected := range []string{"add <url>", "list [query]", "view <id>", "remove <id>"} {
365+ if !findSubcommand(subcommandNames, expected) {
366+ t.Errorf("Expected subcommand '%s' not found in %v", expected, subcommandNames)
367+ }
368+ }
369+ })
370+371 t.Run("all command groups implement Create", func(t *testing.T) {
372 taskHandler, taskCleanup := createTestTaskHandler(t)
373 defer taskCleanup()
···384 bookHandler, bookCleanup := createTestBookHandler(t)
385 defer bookCleanup()
386387+ articleHandler, articleCleanup := createTestArticleHandler(t)
388+ defer articleCleanup()
389+390 groups := []CommandGroup{
391 NewTaskCommand(taskHandler),
392 NewMovieCommand(movieHandler),
393 NewTVCommand(tvHandler),
394 NewNoteCommand(noteHandler),
395 NewBookCommand(bookHandler),
396+ NewArticleCommand(articleHandler),
397 }
398399 for i, group := range groups {
···600 err := cmd.Execute()
601 if err == nil {
602 t.Error("expected book update command to fail with invalid status")
603+ }
604+ })
605+ })
606+607+ t.Run("Article Commands", func(t *testing.T) {
608+ handler, cleanup := createTestArticleHandler(t)
609+ defer cleanup()
610+611+ t.Run("list command - default", func(t *testing.T) {
612+ cmd := NewArticleCommand(handler).Create()
613+ cmd.SetArgs([]string{"list"})
614+ err := cmd.Execute()
615+ if err != nil {
616+ t.Errorf("article list command failed: %v", err)
617+ }
618+ })
619+620+ t.Run("help command", func(t *testing.T) {
621+ cmd := NewArticleCommand(handler).Create()
622+ cmd.SetArgs([]string{"help"})
623+ err := cmd.Execute()
624+ if err != nil {
625+ t.Errorf("article help command failed: %v", err)
626+ }
627+ })
628+629+ t.Run("add command with empty args", func(t *testing.T) {
630+ cmd := NewArticleCommand(handler).Create()
631+ cmd.SetArgs([]string{"add"})
632+ err := cmd.Execute()
633+ if err == nil {
634+ t.Error("expected article add command to fail with empty args")
635+ }
636+ })
637+638+ t.Run("add command with invalid URL", func(t *testing.T) {
639+ cmd := NewArticleCommand(handler).Create()
640+ cmd.SetArgs([]string{"add", "not-a-url"})
641+ err := cmd.Execute()
642+ if err == nil {
643+ t.Error("expected article add command to fail with invalid URL")
644+ }
645+ })
646+647+ t.Run("view command with non-existent article ID", func(t *testing.T) {
648+ cmd := NewArticleCommand(handler).Create()
649+ cmd.SetArgs([]string{"view", "999"})
650+ err := cmd.Execute()
651+ if err == nil {
652+ t.Error("expected article view command to fail with non-existent ID")
653+ }
654+ })
655+656+ t.Run("view command with non-numeric ID", func(t *testing.T) {
657+ cmd := NewArticleCommand(handler).Create()
658+ cmd.SetArgs([]string{"view", "invalid"})
659+ err := cmd.Execute()
660+ if err == nil {
661+ t.Error("expected article view command to fail with non-numeric ID")
662+ }
663+ })
664+665+ t.Run("remove command with non-existent article ID", func(t *testing.T) {
666+ cmd := NewArticleCommand(handler).Create()
667+ cmd.SetArgs([]string{"remove", "999"})
668+ err := cmd.Execute()
669+ if err == nil {
670+ t.Error("expected article remove command to fail with non-existent ID")
671+ }
672+ })
673+674+ t.Run("remove command with non-numeric ID", func(t *testing.T) {
675+ cmd := NewArticleCommand(handler).Create()
676+ cmd.SetArgs([]string{"remove", "invalid"})
677+ err := cmd.Execute()
678+ if err == nil {
679+ t.Error("expected article remove command to fail with non-numeric ID")
680 }
681 })
682 })
+6
cmd/main.go
···164 log.Fatalf("failed to create book handler: %v", err)
165 }
16600000167 root := rootCmd()
168169 coreGroups := []CommandGroup{
170 NewTaskCommand(taskHandler),
171 NewNoteCommand(noteHandler),
0172 }
173174 for _, group := range coreGroups {
···164 log.Fatalf("failed to create book handler: %v", err)
165 }
166167+ articleHandler, err := handlers.NewArticleHandler()
168+ if err != nil {
169+ log.Fatalf("failed to create article handler: %v", err)
170+ }
171+172 root := rootCmd()
173174 coreGroups := []CommandGroup{
175 NewTaskCommand(taskHandler),
176 NewNoteCommand(noteHandler),
177+ NewArticleCommand(articleHandler),
178 }
179180 for _, group := range coreGroups {
+27-2
docs/cli.md
···22Handlers are created once in `main.go` during application startup. Initialization errors prevent application launch rather than causing runtime failures.
23Handlers persist for the application lifetime without requiring cleanup. Commands access handlers through struct fields rather than creating new instances.
2425-### Testing Benefits
2627`CommandGroup` structs accept handlers as constructor parameters, enabling easy dependency injection of mock handlers for testing.
28Command logic can be tested independently of handler implementations. The interface allows mocking entire command groups for integration testing.
2930-### Registry Pattern
3132`main.go` uses a registry pattern to organize command groups by category. Core commands include task, note, and media functionality.
33Management commands handle configuration, setup, and maintenance operations. The pattern provides clean separation and easy extension for new command groups.
0000000000000000000000000
···22Handlers are created once in `main.go` during application startup. Initialization errors prevent application launch rather than causing runtime failures.
23Handlers persist for the application lifetime without requiring cleanup. Commands access handlers through struct fields rather than creating new instances.
2425+### Testing
2627`CommandGroup` structs accept handlers as constructor parameters, enabling easy dependency injection of mock handlers for testing.
28Command logic can be tested independently of handler implementations. The interface allows mocking entire command groups for integration testing.
2930+### Registry
3132`main.go` uses a registry pattern to organize command groups by category. Core commands include task, note, and media functionality.
33Management commands handle configuration, setup, and maintenance operations. The pattern provides clean separation and easy extension for new command groups.
34+35+## UI and Styling System
36+37+The application uses a structured color palette system located in `internal/ui/colors.go` for consistent terminal output styling.
38+39+### Color Architecture
40+41+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()`.
42+43+### Predefined Styles
44+45+Three core lipgloss styles handle common UI elements:
46+47+- `TitleColorStyle` uses color 212 with bold formatting for command titles
48+- `SelectedColorStyle` provides white-on-212 highlighting for selected items
49+- `HeaderColorStyle` applies color 240 with bold formatting for section headers
50+51+### Color Categories
52+53+Colors are organized into primary, secondary, and tertiary categories are accessed through `IsPrimary()`, `IsSecondary()`, and `IsTertiary()` methods on the `Key` type.
54+55+### Lipgloss Integration
56+57+The styling system integrates with the Charmbracelet lipgloss library for terminal UI rendering.
58+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()`.