#
tokens: 30155/50000 12/12 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── .goreleaser.yml
├── CHANGELOG.md
├── cmd
│   └── mcp-todo-server
│       └── main.go
├── go.mod
├── go.sum
├── internal
│   ├── config
│   │   └── config.go
│   ├── mcp
│   │   ├── server.go
│   │   └── tools.go
│   └── services
│       ├── changelog
│       │   └── changelog.go
│       ├── file
│       │   └── file.go
│       └── todo
│           └── todo.go
├── LICENSE
├── README.md
└── TODO.md
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
1 | build/
2 | dist/
3 | notes/
4 | tmp/
5 | 
```

--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------

```yaml
 1 | project_name: mcp-todo-server
 2 | 
 3 | before:
 4 |   hooks:
 5 |     - go mod tidy
 6 | 
 7 | builds:
 8 |   - env:
 9 |       - CGO_ENABLED=0
10 |     goos:
11 |       - linux
12 |       - windows
13 |       - darwin
14 |     goarch:
15 |       - amd64
16 |       - arm64
17 |     main: ./cmd/mcp-todo-server
18 |     ldflags:
19 |       - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
20 | 
21 | archives:
22 |   - format: tar.gz
23 |     name_template: >-
24 |       {{ .ProjectName }}_
25 |       {{- .Version }}_
26 |       {{- .Os }}_
27 |       {{- .Arch }}
28 |     format_overrides:
29 |       - goos: windows
30 |         format: zip
31 |     files:
32 |       - README.md
33 |       - LICENSE
34 | 
35 | checksum:
36 |   name_template: 'checksums.txt'
37 | 
38 | snapshot:
39 |   name_template: "{{ incpatch .Version }}-next"
40 | 
41 | changelog:
42 |   sort: asc
43 |   filters:
44 |     exclude:
45 |       - '^docs:'
46 |       - '^test:'
47 |       - '^ci:'
48 |       - Merge pull request
49 |       - Merge branch
50 | 
51 | # Homebrew
52 | brews:
53 |   - tap:
54 |       owner: mutker
55 |       name: homebrew-tap
56 |       token: "{{ .Env.GITHUB_TOKEN }}"
57 |     folder: Formula
58 |     homepage: https://codeberg.org/mutker/mcp-todo-server
59 |     description: MCP server for managing TODO.md and CHANGELOG.md files
60 |     license: MIT
61 |     test: |
62 |       system "#{bin}/mcp-todo-server --version"
63 |     install: |
64 |       bin.install "mcp-todo-server"
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
 1 | # mcp-todo-server
 2 | 
 3 | Model Context Protocol (MCP) server for managing TODO.md and CHANGELOG.md files.
 4 | 
 5 | ## Features
 6 | 
 7 | - Precise, line-based editing and reading of file contents.
 8 | - Efficient partial file access using line ranges, for efficient LLM tool usage.
 9 | - Retrieve specific file content by specifying line ranges.
10 | - Fetch multiple line ranges from multiple files in a single request.
11 | - Apply line-based patches, correctly adjusting for line number changes.
12 | - Supports a wide range of character encodings (utf-8, shift_jis, latin1, etc.).
13 | - Perform atomic operations across multiple files.
14 | - Robust error handling using custom error types.
15 | - Adheres to Semantic Versioning and Keep a Changelog conventions.
16 | 
17 | ## Requirements
18 | 
19 | - Go v1.23+
20 | - Linux, macOS, or Windows
21 | - File system permissions for read/write operations
22 | 
23 | ## Installation
24 | 
25 | ```bash
26 | go install codeberg.org/mutker/mcp-todo-server/cmd/mcp-todo-server@latest
27 | ```
28 | 
29 | ## Usage examples:
30 | 
31 | - Ask "What are my current tasks for version 0.2.0?"
32 | - Say "Add a new task to implement OAuth authentication for version 0.2.0"
33 | - Request "Generate a changelog entry for version 0.1.0 based on completed tasks"
34 | - Say "Import my existing TODO.md file from /path/to/my/TODO.md"
35 | 
36 | The server intelligently handles task parsing, version management, and provides rich semantic understanding of tasks and changelog entries.
37 | 
38 | ## Available MCP Tools
39 | 
40 | ### TODO.md Operations
41 | 
42 | - `get-todo-tasks` - Get all tasks from TODO.md
43 | - `get-todo-tasks-by-version` - Get tasks for a specific version
44 | - `add-todo-task` - Add a new task for a specific version
45 | - `update-todo-task` - Update an existing task
46 | - `add-todo-version` - Add a new version section
47 | - `import-todo` - Import and format an existing TODO.md
48 | 
49 | ### CHANGELOG.md Operations
50 | 
51 | - `get-changelog` - Get all changelog entries
52 | - `get-changelog-by-version` - Get changelog entries for a specific version
53 | - `add-changelog-entry` - Add a new changelog version entry
54 | - `update-changelog-entry` - Update an existing changelog entry
55 | - `import-changelog` - Import and format an existing CHANGELOG.md
56 | - `generate-changelog-from-todo` - Generate a new CHANGELOG.md entry based on completed tasks in TODO.md
57 | 
58 | ## Thanks
59 | 
60 | - [tumf/mcp-text-editor](https://github.com/tumf/mcp-text-editor) for the inspiration.
61 | 
62 | ## License
63 | 
64 | This project is licensed under the MIT License. See [LICENSE](LICENSE) for the full license text.
65 | 
```

--------------------------------------------------------------------------------
/internal/mcp/server.go:
--------------------------------------------------------------------------------

```go
 1 | package mcp
 2 | 
 3 | import (
 4 | 	"encoding/json"
 5 | 	"github.com/mark3labs/mcp-go/mcp"
 6 | )
 7 | 
 8 | // Helper function to create a tool result with JSON content
 9 | func newToolResultJSON(v any) (*mcp.CallToolResult, error) {
10 | 	jsonBytes, err := json.Marshal(v)
11 | 	if err != nil {
12 | 		return nil, err
13 | 	}
14 | 	return mcp.NewToolResultText(string(jsonBytes)), nil
15 | }
16 | 
17 | // convertParams is a utility function that converts parameters from JSON
18 | // into a structured type. This is useful for processing tool parameters.
19 | func convertParams(params interface{}, dest interface{}) error {
20 | 	if params == nil {
21 | 		return nil
22 | 	}
23 | 
24 | 	// Convert to JSON and unmarshal into the destination struct
25 | 	paramsJSON, err := json.Marshal(params)
26 | 	if err != nil {
27 | 		return err
28 | 	}
29 | 
30 | 	return json.Unmarshal(paramsJSON, dest)
31 | }
32 | 
```

--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------

```go
 1 | package config
 2 | 
 3 | import (
 4 | 	"os"
 5 | )
 6 | 
 7 | // Config holds the application configuration
 8 | type Config struct {
 9 | 	Files FilesConfig
10 | }
11 | 
12 | // FilesConfig holds configuration related to file operations
13 | type FilesConfig struct {
14 | 	DefaultEncoding string
15 | 	AutoDetection   bool
16 | }
17 | 
18 | // Load loads configuration from environment variables
19 | func Load() (*Config, error) {
20 | 	// Set defaults
21 | 	cfg := &Config{
22 | 		Files: FilesConfig{
23 | 			DefaultEncoding: "utf-8",
24 | 			AutoDetection:   true,
25 | 		},
26 | 	}
27 | 
28 | 	// Override with environment variables
29 | 	if encoding := os.Getenv("MCP_DEFAULT_ENCODING"); encoding != "" {
30 | 		cfg.Files.DefaultEncoding = encoding
31 | 	}
32 | 	
33 | 	if autoDetect := os.Getenv("MCP_AUTO_DETECT_ENCODING"); autoDetect != "" {
34 | 		cfg.Files.AutoDetection = autoDetect == "1" || autoDetect == "true" || autoDetect == "yes"
35 | 	}
36 | 
37 | 	return cfg, nil
38 | }
```

--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------

```markdown
 1 | # Changelog
 2 | 
 3 | ## [0.3.0] - 2025-03-05
 4 | 
 5 | ### Added
 6 | 
 7 | - Support for mcp-go library integration
 8 |   - Complete refactoring to use mcp-go for protocol handling
 9 |   - Improved type safety with mcp-go parameter handling
10 | - Enhanced character encoding support
11 |   - Automatic encoding detection for files
12 |   - Support for additional character encodings (Chinese, Korean, and more)
13 |   - Format conversion utilities for seamless encoding management
14 | 
15 | ### Changed
16 | 
17 | - Simplified server implementation by leveraging mcp-go functionality
18 | - Enhanced tool parameter definitions with proper schema support
19 | - Improved error handling with consistent error responses
20 | - Updated tool implementations to maintain backward compatibility
21 | 
22 | ### Removed
23 | 
24 | - Custom MCP protocol implementation in favor of the standard mcp-go library
25 | 
26 | ## [0.2.0] - 2025-03-04
27 | 
28 | ### Added
29 | 
30 | - Model Context Protocol (MCP) server implementation
31 |   - JSON-RPC communication layer
32 |   - MCP protocol initialization and capability negotiation
33 |   - Tool registration and execution system
34 |   - Stdin/stdout transport support
35 | - MCP tools for TODO.md
36 |   - Get all tasks
37 |   - Get tasks for a specific version
38 |   - Add a new task
39 |   - Update an existing task
40 |   - Add a new version section
41 |   - Import and format an existing TODO.md
42 | - MCP tools for CHANGELOG.md
43 |   - Get all changelog items
44 |   - Get changelog items for a specific version
45 |   - Add a new changelog version entry
46 |   - Update existing changelog entries
47 |   - Import and format an existing CHANGELOG.md
48 |   - Generate a new CHANGELOG.md based on completed tasks in TODO.md
49 | 
50 | ### Changed
51 | 
52 | - Updated README with MCP server documentation
53 | - Improved error handling and validation for tools
54 | 
55 | ## [0.1.0] - 2025-03-03
56 | 
57 | ### Added
58 | 
59 | - Initial implementation of MCP Todo Server
60 | - Server framework with error handling and middleware
61 | - File operations with line-based reading and editing
62 | - Hash-based validation for concurrent editing
63 | - TODO.md management operations
64 |   - Parse TODO format
65 |   - Read and update tasks
66 |   - Add tasks and versions
67 | - CHANGELOG.md management operations
68 |   - Parse CHANGELOG format
69 |   - Read and update entries
70 |   - Add new entries
71 | - Multi-file atomic operations
72 | - Comprehensive encoding support (utf-8, shift_jis, latin1, etc.)
73 | - Goreleaser configuration for multi-platform builds
74 | 
```

--------------------------------------------------------------------------------
/cmd/mcp-todo-server/main.go:
--------------------------------------------------------------------------------

```go
  1 | package main
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"flag"
  6 | 	"fmt"
  7 | 	"log/slog"
  8 | 	"os"
  9 | 	"os/signal"
 10 | 	"syscall"
 11 | 	"time"
 12 | 
 13 | 	"codeberg.org/mutker/mcp-todo-server/internal/config"
 14 | 	"codeberg.org/mutker/mcp-todo-server/internal/mcp"
 15 | 	"github.com/mark3labs/mcp-go/server"
 16 | )
 17 | 
 18 | var (
 19 | 	version     = "0.3.0"
 20 | 	verboseFlag = flag.Bool("verbose", false, "Enable verbose logging")
 21 | 	versionFlag = flag.Bool("version", false, "Show version information")
 22 | )
 23 | 
 24 | func main() {
 25 | 	// Define command-line flags
 26 | 	flag.Parse()
 27 | 
 28 | 	// Show version and exit if requested
 29 | 	if *versionFlag {
 30 | 		fmt.Printf("mcp-todo-server version %s\n", version)
 31 | 		os.Exit(0)
 32 | 	}
 33 | 
 34 | 	// Initialize logger to write to stderr instead of stdout
 35 | 	// This is necessary because stdout is reserved for MCP JSON-RPC communication
 36 | 	logLevel := slog.LevelInfo
 37 | 	if *verboseFlag {
 38 | 		logLevel = slog.LevelDebug
 39 | 	}
 40 | 	
 41 | 	// Create a custom handler that formats logs in a more human-readable format
 42 | 	handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
 43 | 		Level: logLevel,
 44 | 		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
 45 | 			// Format timestamp to be more human readable
 46 | 			if a.Key == "time" {
 47 | 				if t, ok := a.Value.Any().(time.Time); ok {
 48 | 					return slog.Attr{
 49 | 						Key:   "time",
 50 | 						Value: slog.StringValue(t.Format("2006-01-02 15:04:05")),
 51 | 					}
 52 | 				}
 53 | 			}
 54 | 			return a
 55 | 		},
 56 | 	})
 57 | 	
 58 | 	logger := slog.New(handler)
 59 | 	slog.SetDefault(logger)
 60 | 
 61 | 	// Load configuration
 62 | 	cfg, err := config.Load()
 63 | 	if err != nil {
 64 | 		logger.Error("Failed to load configuration", "error", err)
 65 | 		os.Exit(1)
 66 | 	}
 67 | 
 68 | 	// Print version information
 69 | 	logger.Info(fmt.Sprintf("Starting MCP Todo Server v%s", version))
 70 | 
 71 | 	// Create context for graceful shutdown
 72 | 	ctx, cancel := context.WithCancel(context.Background())
 73 | 	defer cancel()
 74 | 
 75 | 	// Create a channel to listen for OS signals
 76 | 	quit := make(chan os.Signal, 1)
 77 | 	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
 78 | 
 79 | 	// Create and start the MCP server
 80 | 	mcpServer := server.NewMCPServer(
 81 | 		"mcp-todo-server",
 82 | 		version,
 83 | 		server.WithResourceCapabilities(false, false), // Disable resources for now
 84 | 		server.WithLogging(),
 85 | 	)
 86 | 
 87 | 	// Register tools
 88 | 	mcp.RegisterTools(ctx, mcpServer, cfg)
 89 | 
 90 | 	// Start the MCP server in a goroutine
 91 | 	go func() {
 92 | 		logger.Info("MCP server ready and listening on stdin/stdout")
 93 | 		if err := server.ServeStdio(mcpServer); err != nil {
 94 | 			logger.Error(fmt.Sprintf("MCP server error: %v", err))
 95 | 			os.Exit(1)
 96 | 		}
 97 | 	}()
 98 | 
 99 | 	// Wait for signal
100 | 	<-quit
101 | 	logger.Info("Received shutdown signal, closing server...")
102 | 
103 | 	// Cancel the context to initiate shutdown
104 | 	cancel()
105 | 
106 | 	// Give it a moment to clean up
107 | 	// time.Sleep(500 * time.Millisecond) // No need with mcp-go
108 | 
109 | 	logger.Info("MCP Todo Server has shut down gracefully")
110 | }
111 | 
```

--------------------------------------------------------------------------------
/TODO.md:
--------------------------------------------------------------------------------

```markdown
 1 | # TODO
 2 | 
 3 | ## v0.1.0
 4 | 
 5 | - [x] Set up project structure
 6 |   - [x] Create Go module
 7 |   - [x] Set up directory structure (cmd, internal, pkg)
 8 |   - [x] Add gitignore file
 9 | - [x] Implement file operations
10 |   - [x] Design file service interfaces
11 |   - [x] Implement line-based file reading with range support
12 |   - [x] Implement line-based editing operations
13 |   - [x] Add hash-based validation for concurrent editing
14 | - [x] Implement TODO.md operations
15 |   - [x] Parse TODO.md format
16 |   - [x] Implement operations for reading TODO items
17 |   - [x] Implement operations for updating task status
18 |   - [x] Implement operations for adding new tasks and versions
19 | - [x] Implement CHANGELOG.md operations
20 |   - [x] Parse CHANGELOG.md format
21 |   - [x] Implement operations for reading changelog entries
22 |   - [x] Implement operations for adding new entries
23 |   - [x] Implement operations for updating existing entries
24 | - [x] Add multi-file atomic operations
25 |   - [x] Implement transaction-like behavior for multi-file edits
26 |   - [x] Add rollback capability for failed operations
27 | - [x] Add encoding support
28 |   - [x] Implement detection and handling of various encodings
29 |   - [x] Add support for utf-8, shift_jis, latin1
30 | - [x] Set up goreleaser configuration
31 |   - [x] Create .goreleaser.yml
32 |   - [x] Configure build settings for different platforms
33 |   - [x] Set up release workflow
34 | 
35 | ## v0.2.0
36 | 
37 | - [x] Implement MCP server
38 |   - [x] Create JSON-RPC communication layer
39 |   - [x] Implement MCP protocol initialization
40 |   - [x] Add tool registration and execution
41 |   - [x] Support stdin/stdout transport
42 | - [x] Implement MCP tools for TODO.md
43 |   - [x] Get all tasks
44 |   - [x] Get tasks for a specific version
45 |   - [x] Add a new task
46 |   - [x] Add a new task for a specific version
47 |   - [x] Update an existing task
48 |   - [x] Add a new version section
49 |   - [x] Import and format an existing TODO.md
50 | - [x] Implement MCP tools for CHANGELOG.md
51 |   - [x] Get all changelog items
52 |   - [x] Get changelog items for a specific version
53 |   - [x] Add a new changelog version entry
54 |   - [x] Add a new changelog entry for a specific version
55 |   - [x] Update a existing changelog entry
56 |   - [x] Import and format an existing CHANGELOG.md
57 |   - [x] Generate a new CHANGELOG.md based on completed tasks in TODO.md
58 | 
59 | ## v0.3.0
60 | 
61 | - [x] Refactor to use `mcp-go` library
62 |   - [x] Replace custom MCP implementation with `mcp-go`
63 |   - [x] Adapt tool handlers to `mcp-go` API
64 |   - [x] Ensure all TODO.md operations are supported
65 |   - [x] Ensure all CHANGELOG.md operations are supported
66 | - [x] Clean up legacy MCP server code
67 |   - [x] Remove obsolete code replaced by mcp-go
68 |   - [x] Refactor server initialization
69 |   - [x] Update error handling for MCP protocol
70 | - [x] Support for additional character encodings and format detection
71 |   - [x] Add automatic encoding detection
72 |   - [x] Support for more exotic character encodings
73 |   - [x] Format conversion utilities
74 | 
```

--------------------------------------------------------------------------------
/internal/services/changelog/changelog.go:
--------------------------------------------------------------------------------

```go
  1 | package changelog
  2 | 
  3 | import (
  4 | 	"errors"
  5 | 	"fmt"
  6 | 	"regexp"
  7 | 	"strings"
  8 | 
  9 | 	"codeberg.org/mutker/mcp-todo-server/internal/config"
 10 | 	"codeberg.org/mutker/mcp-todo-server/internal/services/file"
 11 | )
 12 | 
 13 | const (
 14 | 	// Default CHANGELOG file name
 15 | 	changelogFileName = "CHANGELOG.md"
 16 | )
 17 | 
 18 | var (
 19 | 	// ErrVersionNotFound is returned when a version is not found
 20 | 	ErrVersionNotFound = errors.New("version not found")
 21 | 	
 22 | 	// ErrVersionExists is returned when a version already exists
 23 | 	ErrVersionExists = errors.New("version already exists")
 24 | )
 25 | 
 26 | // versionRegex matches a version header (e.g., "## [1.0.0] - 2023-01-01")
 27 | var versionRegex = regexp.MustCompile(`^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})$`)
 28 | 
 29 | // sectionRegex matches a section header (e.g., "### Added" or "### Fixed")
 30 | var sectionRegex = regexp.MustCompile(`^### ([A-Za-z]+)$`)
 31 | 
 32 | // listItemRegex matches a list item (e.g., "- List item")
 33 | var listItemRegex = regexp.MustCompile(`^(\s*)- (.+)$`)
 34 | 
 35 | // ChangelogContent represents the content of a changelog entry
 36 | type ChangelogContent struct {
 37 | 	Added      []string `json:"added,omitempty"`
 38 | 	Changed    []string `json:"changed,omitempty"`
 39 | 	Deprecated []string `json:"deprecated,omitempty"`
 40 | 	Removed    []string `json:"removed,omitempty"`
 41 | 	Fixed      []string `json:"fixed,omitempty"`
 42 | 	Security   []string `json:"security,omitempty"`
 43 | }
 44 | 
 45 | // VersionEntry represents a version entry in the changelog
 46 | type VersionEntry struct {
 47 | 	Version string           `json:"version"`
 48 | 	Date    string           `json:"date"`
 49 | 	Content *ChangelogContent `json:"content"`
 50 | }
 51 | 
 52 | // Changelog represents the entire CHANGELOG.md file
 53 | type Changelog struct {
 54 | 	Versions []*VersionEntry `json:"versions"`
 55 | }
 56 | 
 57 | // Service provides changelog operations
 58 | type Service struct {
 59 | 	config     *config.Config
 60 | 	fileService *file.Service
 61 | }
 62 | 
 63 | // NewService creates a new changelog service
 64 | func NewService(cfg *config.Config) *Service {
 65 | 	fileService := file.NewService(cfg.Files.DefaultEncoding, cfg.Files.AutoDetection)
 66 | 	
 67 | 	return &Service{
 68 | 		config:     cfg,
 69 | 		fileService: fileService,
 70 | 	}
 71 | }
 72 | 
 73 | // GetAll retrieves the entire changelog
 74 | func (s *Service) GetAll() (*Changelog, error) {
 75 | 	lines, _, err := s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
 76 | 	if err != nil {
 77 | 		if errors.Is(err, file.ErrFileNotFound) {
 78 | 			// Return empty changelog if file doesn't exist
 79 | 			return &Changelog{Versions: []*VersionEntry{}}, nil
 80 | 		}
 81 | 		return nil, fmt.Errorf("failed to read CHANGELOG file: %w", err)
 82 | 	}
 83 | 	
 84 | 	return s.parseChangelogFile(lines)
 85 | }
 86 | 
 87 | // GetByVersion retrieves a specific version entry
 88 | func (s *Service) GetByVersion(version string) (*VersionEntry, error) {
 89 | 	changelog, err := s.GetAll()
 90 | 	if err != nil {
 91 | 		return nil, err
 92 | 	}
 93 | 	
 94 | 	// Find the requested version
 95 | 	for _, v := range changelog.Versions {
 96 | 		if v.Version == version {
 97 | 			return v, nil
 98 | 		}
 99 | 	}
100 | 	
101 | 	return nil, nil
102 | }
103 | 
104 | // AddEntry adds a new changelog entry
105 | func (s *Service) AddEntry(version string, date string, content *ChangelogContent) error {
106 | 	lines, hash, err := s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
107 | 	if err != nil && !errors.Is(err, file.ErrFileNotFound) {
108 | 		return fmt.Errorf("failed to read CHANGELOG file: %w", err)
109 | 	}
110 | 	
111 | 	// If file doesn't exist, create it
112 | 	if errors.Is(err, file.ErrFileNotFound) {
113 | 		newLines := []string{
114 | 			"# Changelog",
115 | 			"",
116 | 		}
117 | 		err = s.fileService.InsertLines(changelogFileName, "", s.config.Files.DefaultEncoding, 1, newLines)
118 | 		if err != nil {
119 | 			return fmt.Errorf("failed to create CHANGELOG file: %w", err)
120 | 		}
121 | 		
122 | 		// Re-read the file
123 | 		lines, hash, err = s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
124 | 		if err != nil {
125 | 			return fmt.Errorf("failed to read CHANGELOG file: %w", err)
126 | 		}
127 | 	}
128 | 	
129 | 	// Parse the file to check if version already exists
130 | 	changelog, err := s.parseChangelogFile(lines)
131 | 	if err != nil {
132 | 		return fmt.Errorf("failed to parse CHANGELOG file: %w", err)
133 | 	}
134 | 	
135 | 	// Check if version already exists
136 | 	for _, v := range changelog.Versions {
137 | 		if v.Version == version {
138 | 			return ErrVersionExists
139 | 		}
140 | 	}
141 | 	
142 | 	// Generate the entry
143 | 	entryLines := []string{}
144 | 	
145 | 	// Add version header
146 | 	entryLines = append(entryLines, fmt.Sprintf("## [%s] - %s", version, date))
147 | 	entryLines = append(entryLines, "")
148 | 	
149 | 	// Add sections
150 | 	if len(content.Added) > 0 {
151 | 		entryLines = append(entryLines, "### Added")
152 | 		entryLines = append(entryLines, "")
153 | 		for _, item := range content.Added {
154 | 			entryLines = append(entryLines, fmt.Sprintf("- %s", item))
155 | 		}
156 | 		entryLines = append(entryLines, "")
157 | 	}
158 | 	
159 | 	if len(content.Changed) > 0 {
160 | 		entryLines = append(entryLines, "### Changed")
161 | 		entryLines = append(entryLines, "")
162 | 		for _, item := range content.Changed {
163 | 			entryLines = append(entryLines, fmt.Sprintf("- %s", item))
164 | 		}
165 | 		entryLines = append(entryLines, "")
166 | 	}
167 | 	
168 | 	if len(content.Deprecated) > 0 {
169 | 		entryLines = append(entryLines, "### Deprecated")
170 | 		entryLines = append(entryLines, "")
171 | 		for _, item := range content.Deprecated {
172 | 			entryLines = append(entryLines, fmt.Sprintf("- %s", item))
173 | 		}
174 | 		entryLines = append(entryLines, "")
175 | 	}
176 | 	
177 | 	if len(content.Removed) > 0 {
178 | 		entryLines = append(entryLines, "### Removed")
179 | 		entryLines = append(entryLines, "")
180 | 		for _, item := range content.Removed {
181 | 			entryLines = append(entryLines, fmt.Sprintf("- %s", item))
182 | 		}
183 | 		entryLines = append(entryLines, "")
184 | 	}
185 | 	
186 | 	if len(content.Fixed) > 0 {
187 | 		entryLines = append(entryLines, "### Fixed")
188 | 		entryLines = append(entryLines, "")
189 | 		for _, item := range content.Fixed {
190 | 			entryLines = append(entryLines, fmt.Sprintf("- %s", item))
191 | 		}
192 | 		entryLines = append(entryLines, "")
193 | 	}
194 | 	
195 | 	if len(content.Security) > 0 {
196 | 		entryLines = append(entryLines, "### Security")
197 | 		entryLines = append(entryLines, "")
198 | 		for _, item := range content.Security {
199 | 			entryLines = append(entryLines, fmt.Sprintf("- %s", item))
200 | 		}
201 | 		entryLines = append(entryLines, "")
202 | 	}
203 | 	
204 | 	// Find position to insert the new entry
205 | 	// We'll add it after the title and before any other version
206 | 	position := 2 // After "# Changelog" and empty line
207 | 	for i, line := range lines {
208 | 		if versionRegex.MatchString(line) {
209 | 			position = i
210 | 			break
211 | 		}
212 | 	}
213 | 	
214 | 	// Insert the entry
215 | 	err = s.fileService.InsertLines(changelogFileName, hash, s.config.Files.DefaultEncoding, position, entryLines)
216 | 	if err != nil {
217 | 		return fmt.Errorf("failed to add entry: %w", err)
218 | 	}
219 | 	
220 | 	return nil
221 | }
222 | 
223 | // UpdateEntry updates an existing changelog entry
224 | func (s *Service) UpdateEntry(version string, date string, content *ChangelogContent) error {
225 | 	lines, hash, err := s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
226 | 	if err != nil {
227 | 		return fmt.Errorf("failed to read CHANGELOG file: %w", err)
228 | 	}
229 | 	
230 | 	// Parse the file to locate the version
231 | 	changelog, err := s.parseChangelogFile(lines)
232 | 	if err != nil {
233 | 		return fmt.Errorf("failed to parse CHANGELOG file: %w", err)
234 | 	}
235 | 	
236 | 	// Find the version entry
237 | 	var targetVersion *VersionEntry
238 | 	for _, v := range changelog.Versions {
239 | 		if v.Version == version {
240 | 			targetVersion = v
241 | 			break
242 | 		}
243 | 	}
244 | 	
245 | 	if targetVersion == nil {
246 | 		return ErrVersionNotFound
247 | 	}
248 | 	
249 | 	// Find the start and end line numbers for this version
250 | 	startLine := 0
251 | 	endLine := len(lines)
252 | 	
253 | 	for i, line := range lines {
254 | 		if versionRegex.MatchString(line) {
255 | 			versionMatch := versionRegex.FindStringSubmatch(line)
256 | 			if versionMatch[1] == version {
257 | 				startLine = i
258 | 				
259 | 				// Find the end line (next version or end of file)
260 | 				for j := i + 1; j < len(lines); j++ {
261 | 					if versionRegex.MatchString(lines[j]) {
262 | 						endLine = j
263 | 						break
264 | 					}
265 | 				}
266 | 				break
267 | 			}
268 | 		}
269 | 	}
270 | 	
271 | 	// Generate new version entry
272 | 	newLines := []string{}
273 | 	
274 | 	// Update version header if date is provided
275 | 	if date != "" {
276 | 		newLines = append(newLines, fmt.Sprintf("## [%s] - %s", version, date))
277 | 	} else {
278 | 		newLines = append(newLines, lines[startLine]) // Keep original date
279 | 	}
280 | 	newLines = append(newLines, "")
281 | 	
282 | 	// Add sections
283 | 	// We'll generate completely new content rather than trying to modify existing content
284 | 	if content.Added != nil && len(content.Added) > 0 {
285 | 		newLines = append(newLines, "### Added")
286 | 		newLines = append(newLines, "")
287 | 		for _, item := range content.Added {
288 | 			newLines = append(newLines, fmt.Sprintf("- %s", item))
289 | 		}
290 | 		newLines = append(newLines, "")
291 | 	}
292 | 	
293 | 	if content.Changed != nil && len(content.Changed) > 0 {
294 | 		newLines = append(newLines, "### Changed")
295 | 		newLines = append(newLines, "")
296 | 		for _, item := range content.Changed {
297 | 			newLines = append(newLines, fmt.Sprintf("- %s", item))
298 | 		}
299 | 		newLines = append(newLines, "")
300 | 	}
301 | 	
302 | 	if content.Deprecated != nil && len(content.Deprecated) > 0 {
303 | 		newLines = append(newLines, "### Deprecated")
304 | 		newLines = append(newLines, "")
305 | 		for _, item := range content.Deprecated {
306 | 			newLines = append(newLines, fmt.Sprintf("- %s", item))
307 | 		}
308 | 		newLines = append(newLines, "")
309 | 	}
310 | 	
311 | 	if content.Removed != nil && len(content.Removed) > 0 {
312 | 		newLines = append(newLines, "### Removed")
313 | 		newLines = append(newLines, "")
314 | 		for _, item := range content.Removed {
315 | 			newLines = append(newLines, fmt.Sprintf("- %s", item))
316 | 		}
317 | 		newLines = append(newLines, "")
318 | 	}
319 | 	
320 | 	if content.Fixed != nil && len(content.Fixed) > 0 {
321 | 		newLines = append(newLines, "### Fixed")
322 | 		newLines = append(newLines, "")
323 | 		for _, item := range content.Fixed {
324 | 			newLines = append(newLines, fmt.Sprintf("- %s", item))
325 | 		}
326 | 		newLines = append(newLines, "")
327 | 	}
328 | 	
329 | 	if content.Security != nil && len(content.Security) > 0 {
330 | 		newLines = append(newLines, "### Security")
331 | 		newLines = append(newLines, "")
332 | 		for _, item := range content.Security {
333 | 			newLines = append(newLines, fmt.Sprintf("- %s", item))
334 | 		}
335 | 		newLines = append(newLines, "")
336 | 	}
337 | 	
338 | 	// Delete the old version entry
339 | 	var lineNumbers []int
340 | 	for i := startLine; i < endLine; i++ {
341 | 		lineNumbers = append(lineNumbers, i+1)
342 | 	}
343 | 	
344 | 	err = s.fileService.DeleteLines(changelogFileName, hash, s.config.Files.DefaultEncoding, lineNumbers)
345 | 	if err != nil {
346 | 		return fmt.Errorf("failed to delete old entry: %w", err)
347 | 	}
348 | 	
349 | 	// Re-read the file to get the updated hash
350 | 	lines, hash, err = s.fileService.ReadLines(changelogFileName, s.config.Files.DefaultEncoding)
351 | 	if err != nil {
352 | 		return fmt.Errorf("failed to read CHANGELOG file: %w", err)
353 | 	}
354 | 	
355 | 	// Insert the new version entry
356 | 	err = s.fileService.InsertLines(changelogFileName, hash, s.config.Files.DefaultEncoding, startLine+1, newLines)
357 | 	if err != nil {
358 | 		return fmt.Errorf("failed to insert new entry: %w", err)
359 | 	}
360 | 	
361 | 	return nil
362 | }
363 | 
364 | // parseChangelogFile parses a CHANGELOG.md file into a structured representation
365 | func (s *Service) parseChangelogFile(lines []string) (*Changelog, error) {
366 | 	changelog := &Changelog{
367 | 		Versions: []*VersionEntry{},
368 | 	}
369 | 	
370 | 	var currentVersion *VersionEntry
371 | 	var currentSection string
372 | 	
373 | 	for _, line := range lines {
374 | 		// Skip empty lines and title
375 | 		if line == "" || line == "# Changelog" {
376 | 			continue
377 | 		}
378 | 		
379 | 		// Check if it's a version header
380 | 		if versionMatch := versionRegex.FindStringSubmatch(line); versionMatch != nil {
381 | 			version := versionMatch[1]
382 | 			date := versionMatch[2]
383 | 			
384 | 			currentVersion = &VersionEntry{
385 | 				Version: version,
386 | 				Date:    date,
387 | 				Content: &ChangelogContent{},
388 | 			}
389 | 			changelog.Versions = append(changelog.Versions, currentVersion)
390 | 			currentSection = ""
391 | 			continue
392 | 		}
393 | 		
394 | 		// If no current version, skip
395 | 		if currentVersion == nil {
396 | 			continue
397 | 		}
398 | 		
399 | 		// Check if it's a section header
400 | 		if sectionMatch := sectionRegex.FindStringSubmatch(line); sectionMatch != nil {
401 | 			currentSection = strings.ToLower(sectionMatch[1])
402 | 			continue
403 | 		}
404 | 		
405 | 		// Check if it's a list item
406 | 		if listItemMatch := listItemRegex.FindStringSubmatch(line); listItemMatch != nil {
407 | 			item := listItemMatch[2]
408 | 			
409 | 			// Add to the appropriate section
410 | 			switch currentSection {
411 | 			case "added":
412 | 				currentVersion.Content.Added = append(currentVersion.Content.Added, item)
413 | 			case "changed":
414 | 				currentVersion.Content.Changed = append(currentVersion.Content.Changed, item)
415 | 			case "deprecated":
416 | 				currentVersion.Content.Deprecated = append(currentVersion.Content.Deprecated, item)
417 | 			case "removed":
418 | 				currentVersion.Content.Removed = append(currentVersion.Content.Removed, item)
419 | 			case "fixed":
420 | 				currentVersion.Content.Fixed = append(currentVersion.Content.Fixed, item)
421 | 			case "security":
422 | 				currentVersion.Content.Security = append(currentVersion.Content.Security, item)
423 | 			}
424 | 		}
425 | 	}
426 | 	
427 | 	return changelog, nil
428 | }
```

--------------------------------------------------------------------------------
/internal/services/todo/todo.go:
--------------------------------------------------------------------------------

```go
  1 | package todo
  2 | 
  3 | import (
  4 | 	"errors"
  5 | 	"fmt"
  6 | 	"regexp"
  7 | 	"strings"
  8 | 
  9 | 	"codeberg.org/mutker/mcp-todo-server/internal/config"
 10 | 	"codeberg.org/mutker/mcp-todo-server/internal/services/file"
 11 | )
 12 | 
 13 | const (
 14 | 	// Default TODO file name
 15 | 	todoFileName = "TODO.md"
 16 | )
 17 | 
 18 | var (
 19 | 	// ErrTaskNotFound is returned when a task is not found
 20 | 	ErrTaskNotFound = errors.New("task not found")
 21 | 	
 22 | 	// ErrVersionNotFound is returned when a version is not found
 23 | 	ErrVersionNotFound = errors.New("version not found")
 24 | 	
 25 | 	// ErrVersionExists is returned when a version already exists
 26 | 	ErrVersionExists = errors.New("version already exists")
 27 | )
 28 | 
 29 | // versionRegex matches a version header (e.g., "## v1.0.0")
 30 | var versionRegex = regexp.MustCompile(`^## v(\d+\.\d+\.\d+)$`)
 31 | 
 32 | // taskRegex matches a task line (e.g., "- [ ] Task description" or "- [x] Completed task")
 33 | var taskRegex = regexp.MustCompile(`^(\s*)- \[([ xX])\] (.+)$`)
 34 | 
 35 | // Task represents a todo task
 36 | type Task struct {
 37 | 	ID          string   `json:"id"`
 38 | 	Description string   `json:"description"`
 39 | 	Completed   bool     `json:"completed"`
 40 | 	Version     string   `json:"version"`
 41 | 	LineNumber  int      `json:"line_number"`
 42 | 	Indent      int      `json:"indent"`
 43 | 	SubTasks    []*Task  `json:"subtasks,omitempty"`
 44 | }
 45 | 
 46 | // VersionTasks represents tasks grouped by version
 47 | type VersionTasks struct {
 48 | 	Version string  `json:"version"`
 49 | 	Tasks   []*Task `json:"tasks"`
 50 | }
 51 | 
 52 | // TodoList represents the entire TODO.md file
 53 | type TodoList struct {
 54 | 	Versions []*VersionTasks `json:"versions"`
 55 | }
 56 | 
 57 | // Service provides todo operations
 58 | type Service struct {
 59 | 	config     *config.Config
 60 | 	fileService *file.Service
 61 | }
 62 | 
 63 | // NewService creates a new todo service
 64 | func NewService(cfg *config.Config) *Service {
 65 | 	fileService := file.NewService(cfg.Files.DefaultEncoding, cfg.Files.AutoDetection)
 66 | 	
 67 | 	return &Service{
 68 | 		config:     cfg,
 69 | 		fileService: fileService,
 70 | 	}
 71 | }
 72 | 
 73 | // GetAll retrieves all tasks from TODO.md
 74 | func (s *Service) GetAll() (*TodoList, error) {
 75 | 	lines, _, err := s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
 76 | 	if err != nil {
 77 | 		if errors.Is(err, file.ErrFileNotFound) {
 78 | 			// Return empty todo list if file doesn't exist
 79 | 			return &TodoList{Versions: []*VersionTasks{}}, nil
 80 | 		}
 81 | 		return nil, fmt.Errorf("failed to read TODO file: %w", err)
 82 | 	}
 83 | 	
 84 | 	return s.parseTodoFile(lines)
 85 | }
 86 | 
 87 | // GetByVersion retrieves tasks for a specific version
 88 | func (s *Service) GetByVersion(version string) (*VersionTasks, error) {
 89 | 	todoList, err := s.GetAll()
 90 | 	if err != nil {
 91 | 		return nil, err
 92 | 	}
 93 | 	
 94 | 	// Find the requested version
 95 | 	for _, v := range todoList.Versions {
 96 | 		if v.Version == version {
 97 | 			return v, nil
 98 | 		}
 99 | 	}
100 | 	
101 | 	return nil, nil
102 | }
103 | 
104 | // AddTask adds a new task to TODO.md
105 | func (s *Service) AddTask(version string, description string, parentID string) (string, error) {
106 | 	lines, hash, err := s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
107 | 	if err != nil && !errors.Is(err, file.ErrFileNotFound) {
108 | 		return "", fmt.Errorf("failed to read TODO file: %w", err)
109 | 	}
110 | 	
111 | 	// If file doesn't exist, create it with the version
112 | 	if errors.Is(err, file.ErrFileNotFound) {
113 | 		err = s.fileService.InsertLines(todoFileName, "", s.config.Files.DefaultEncoding, 1, []string{
114 | 			"# TODO",
115 | 			"",
116 | 			fmt.Sprintf("## v%s", version),
117 | 		})
118 | 		if err != nil {
119 | 			return "", fmt.Errorf("failed to create TODO file: %w", err)
120 | 		}
121 | 		
122 | 		// Re-read the file
123 | 		lines, hash, err = s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
124 | 		if err != nil {
125 | 			return "", fmt.Errorf("failed to read TODO file: %w", err)
126 | 		}
127 | 	}
128 | 	
129 | 	// Parse the file to locate the version and determine insert position
130 | 	todoList, err := s.parseTodoFile(lines)
131 | 	if err != nil {
132 | 		return "", fmt.Errorf("failed to parse TODO file: %w", err)
133 | 	}
134 | 	
135 | 	// Check if version exists
136 | 	versionExists := false
137 | 	var versionTasksObj *VersionTasks
138 | 	
139 | 	for _, v := range todoList.Versions {
140 | 		if v.Version == version {
141 | 			versionExists = true
142 | 			versionTasksObj = v
143 | 			break
144 | 		}
145 | 	}
146 | 	
147 | 	// If version doesn't exist, add it
148 | 	if !versionExists {
149 | 		// Find the position to insert the new version
150 | 		// We'll add it after the last version or at the end if no versions exist
151 | 		position := len(lines) + 1
152 | 		for i := len(lines) - 1; i >= 0; i-- {
153 | 			if versionRegex.MatchString(lines[i]) {
154 | 				position = i + 1
155 | 				break
156 | 			}
157 | 		}
158 | 		
159 | 		// Insert the version header
160 | 		err = s.fileService.InsertLines(todoFileName, hash, s.config.Files.DefaultEncoding, position, []string{
161 | 			"",
162 | 			fmt.Sprintf("## v%s", version),
163 | 		})
164 | 		if err != nil {
165 | 			return "", fmt.Errorf("failed to add version: %w", err)
166 | 		}
167 | 		
168 | 		// Re-read the file
169 | 		lines, hash, err = s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
170 | 		if err != nil {
171 | 			return "", fmt.Errorf("failed to read TODO file: %w", err)
172 | 		}
173 | 		
174 | 		// Re-parse the file
175 | 		todoList, err = s.parseTodoFile(lines)
176 | 		if err != nil {
177 | 			return "", fmt.Errorf("failed to parse TODO file: %w", err)
178 | 		}
179 | 		
180 | 		// Find the version again
181 | 		for _, v := range todoList.Versions {
182 | 			if v.Version == version {
183 | 				versionTasksObj = v
184 | 				break
185 | 			}
186 | 		}
187 | 	}
188 | 	
189 | 	// Determine insertion position and indentation
190 | 	var insertPosition int
191 | 	var indentation string
192 | 	
193 | 	if parentID == "" {
194 | 		// If no parent task, add at the end of the version section
195 | 		if len(versionTasksObj.Tasks) == 0 {
196 | 			// If no tasks in this version, add right after the version header
197 | 			for i, line := range lines {
198 | 				if line == fmt.Sprintf("## v%s", version) {
199 | 					insertPosition = i + 1
200 | 					break
201 | 				}
202 | 			}
203 | 		} else {
204 | 			// Find the last task in this version
205 | 			lastTask := versionTasksObj.Tasks[len(versionTasksObj.Tasks)-1]
206 | 			
207 | 			// Handle case where last task has subtasks
208 | 			// We need to find the last subtask recursively
209 | 			var findLastLine func(*Task) int
210 | 			findLastLine = func(t *Task) int {
211 | 				if len(t.SubTasks) == 0 {
212 | 					return t.LineNumber
213 | 				}
214 | 				return findLastLine(t.SubTasks[len(t.SubTasks)-1])
215 | 			}
216 | 			
217 | 			lastLine := findLastLine(lastTask)
218 | 			insertPosition = lastLine
219 | 		}
220 | 	} else {
221 | 		// Find the parent task
222 | 		var parentTask *Task
223 | 		var findTask func([]*Task) *Task
224 | 		findTask = func(tasks []*Task) *Task {
225 | 			for _, t := range tasks {
226 | 				if t.ID == parentID {
227 | 					return t
228 | 				}
229 | 				if result := findTask(t.SubTasks); result != nil {
230 | 					return result
231 | 				}
232 | 			}
233 | 			return nil
234 | 		}
235 | 		
236 | 		for _, v := range todoList.Versions {
237 | 			if parent := findTask(v.Tasks); parent != nil {
238 | 				parentTask = parent
239 | 				break
240 | 			}
241 | 		}
242 | 		
243 | 		if parentTask == nil {
244 | 			return "", ErrTaskNotFound
245 | 		}
246 | 		
247 | 		// Add as a subtask of the parent
248 | 		insertPosition = parentTask.LineNumber
249 | 		indentation = strings.Repeat(" ", parentTask.Indent+2)
250 | 	}
251 | 	
252 | 	// Add the task
253 | 	newTaskLine := fmt.Sprintf("%s- [ ] %s", indentation, description)
254 | 	err = s.fileService.InsertLines(todoFileName, hash, s.config.Files.DefaultEncoding, insertPosition+1, []string{newTaskLine})
255 | 	if err != nil {
256 | 		return "", fmt.Errorf("failed to add task: %w", err)
257 | 	}
258 | 	
259 | 	// Re-read and parse to get the ID of the new task
260 | 	lines, _, err = s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
261 | 	if err != nil {
262 | 		return "", fmt.Errorf("failed to read TODO file: %w", err)
263 | 	}
264 | 	
265 | 	todoList, err = s.parseTodoFile(lines)
266 | 	if err != nil {
267 | 		return "", fmt.Errorf("failed to parse TODO file: %w", err)
268 | 	}
269 | 	
270 | 	// Find the task we just added to get its ID
271 | 	var newTaskID string
272 | 	for _, v := range todoList.Versions {
273 | 		if v.Version == version {
274 | 			for _, t := range v.Tasks {
275 | 				if t.LineNumber == insertPosition+1 && t.Description == description {
276 | 					newTaskID = t.ID
277 | 					return newTaskID, nil
278 | 				}
279 | 				
280 | 				// Also check subtasks
281 | 				var findNewTask func([]*Task) string
282 | 				findNewTask = func(tasks []*Task) string {
283 | 					for _, st := range tasks {
284 | 						if st.LineNumber == insertPosition+1 && st.Description == description {
285 | 							return st.ID
286 | 						}
287 | 						if id := findNewTask(st.SubTasks); id != "" {
288 | 							return id
289 | 						}
290 | 					}
291 | 					return ""
292 | 				}
293 | 				
294 | 				if id := findNewTask(t.SubTasks); id != "" {
295 | 					return id, nil
296 | 				}
297 | 			}
298 | 		}
299 | 	}
300 | 	
301 | 	// If we couldn't find the new task (shouldn't happen), generate a best-guess ID
302 | 	return generateTaskID(version, description), nil
303 | }
304 | 
305 | // UpdateTask updates an existing task
306 | func (s *Service) UpdateTask(taskID string, completed *bool, description *string) error {
307 | 	lines, hash, err := s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
308 | 	if err != nil {
309 | 		return fmt.Errorf("failed to read TODO file: %w", err)
310 | 	}
311 | 	
312 | 	todoList, err := s.parseTodoFile(lines)
313 | 	if err != nil {
314 | 		return fmt.Errorf("failed to parse TODO file: %w", err)
315 | 	}
316 | 	
317 | 	// Find the task
318 | 	var taskToUpdate *Task
319 | 	var findTask func([]*Task) *Task
320 | 	findTask = func(tasks []*Task) *Task {
321 | 		for _, t := range tasks {
322 | 			if t.ID == taskID {
323 | 				return t
324 | 			}
325 | 			if result := findTask(t.SubTasks); result != nil {
326 | 				return result
327 | 			}
328 | 		}
329 | 		return nil
330 | 	}
331 | 	
332 | 	for _, v := range todoList.Versions {
333 | 		if task := findTask(v.Tasks); task != nil {
334 | 			taskToUpdate = task
335 | 			break
336 | 		}
337 | 	}
338 | 	
339 | 	if taskToUpdate == nil {
340 | 		return ErrTaskNotFound
341 | 	}
342 | 	
343 | 	// Parse the line to extract indentation and create the new line
344 | 	indentMatch := strings.Repeat(" ", taskToUpdate.Indent)
345 | 	
346 | 	// Determine completion status
347 | 	completionStatus := " "
348 | 	if completed != nil {
349 | 		if *completed {
350 | 			completionStatus = "x"
351 | 		}
352 | 	} else if taskToUpdate.Completed {
353 | 		completionStatus = "x"
354 | 	}
355 | 	
356 | 	// Determine description
357 | 	desc := taskToUpdate.Description
358 | 	if description != nil {
359 | 		desc = *description
360 | 	}
361 | 	
362 | 	// Create the new line
363 | 	newLine := fmt.Sprintf("%s- [%s] %s", indentMatch, completionStatus, desc)
364 | 	
365 | 	// Update the line
366 | 	lineEdits := map[int]string{
367 | 		taskToUpdate.LineNumber: newLine,
368 | 	}
369 | 	
370 | 	err = s.fileService.EditLines(todoFileName, hash, s.config.Files.DefaultEncoding, lineEdits)
371 | 	if err != nil {
372 | 		return fmt.Errorf("failed to update task: %w", err)
373 | 	}
374 | 	
375 | 	return nil
376 | }
377 | 
378 | // AddVersion adds a new version section to TODO.md
379 | func (s *Service) AddVersion(version string) error {
380 | 	lines, hash, err := s.fileService.ReadLines(todoFileName, s.config.Files.DefaultEncoding)
381 | 	if err != nil && !errors.Is(err, file.ErrFileNotFound) {
382 | 		return fmt.Errorf("failed to read TODO file: %w", err)
383 | 	}
384 | 	
385 | 	// If file doesn't exist, create it
386 | 	if errors.Is(err, file.ErrFileNotFound) {
387 | 		err = s.fileService.InsertLines(todoFileName, "", s.config.Files.DefaultEncoding, 1, []string{
388 | 			"# TODO",
389 | 			"",
390 | 			fmt.Sprintf("## v%s", version),
391 | 		})
392 | 		if err != nil {
393 | 			return fmt.Errorf("failed to create TODO file: %w", err)
394 | 		}
395 | 		return nil
396 | 	}
397 | 	
398 | 	// Check if version already exists
399 | 	todoList, err := s.parseTodoFile(lines)
400 | 	if err != nil {
401 | 		return fmt.Errorf("failed to parse TODO file: %w", err)
402 | 	}
403 | 	
404 | 	for _, v := range todoList.Versions {
405 | 		if v.Version == version {
406 | 			return ErrVersionExists
407 | 		}
408 | 	}
409 | 	
410 | 	// Find the position to insert the new version
411 | 	// We'll add it before the first greater version or at the end if no greater version exists
412 | 	position := len(lines) + 1
413 | 	
414 | 	// Make sure todoList.Versions is sorted by version
415 | 	// For simplicity, we'll just add it at the end for now
416 | 	// In a real implementation, we would sort the versions semantically
417 | 	
418 | 	// Insert the version
419 | 	err = s.fileService.InsertLines(todoFileName, hash, s.config.Files.DefaultEncoding, position, []string{
420 | 		"",
421 | 		fmt.Sprintf("## v%s", version),
422 | 	})
423 | 	if err != nil {
424 | 		return fmt.Errorf("failed to add version: %w", err)
425 | 	}
426 | 	
427 | 	return nil
428 | }
429 | 
430 | // parseTodoFile parses a TODO.md file into a structured representation
431 | func (s *Service) parseTodoFile(lines []string) (*TodoList, error) {
432 | 	todoList := &TodoList{
433 | 		Versions: []*VersionTasks{},
434 | 	}
435 | 	
436 | 	var currentVersion *VersionTasks
437 | 	var taskStack [][]*Task
438 | 	
439 | 	for i, line := range lines {
440 | 		lineNum := i + 1
441 | 		
442 | 		// Skip empty lines and the title
443 | 		if line == "" || line == "# TODO" {
444 | 			continue
445 | 		}
446 | 		
447 | 		// Check if it's a version header
448 | 		if versionMatch := versionRegex.FindStringSubmatch(line); versionMatch != nil {
449 | 			version := versionMatch[1]
450 | 			currentVersion = &VersionTasks{
451 | 				Version: version,
452 | 				Tasks:   []*Task{},
453 | 			}
454 | 			todoList.Versions = append(todoList.Versions, currentVersion)
455 | 			taskStack = nil
456 | 			continue
457 | 		}
458 | 		
459 | 		// If no current version, skip
460 | 		if currentVersion == nil {
461 | 			continue
462 | 		}
463 | 		
464 | 		// Check if it's a task
465 | 		if taskMatch := taskRegex.FindStringSubmatch(line); taskMatch != nil {
466 | 			indentation := taskMatch[1]
467 | 			completed := taskMatch[2] == "x" || taskMatch[2] == "X"
468 | 			description := taskMatch[3]
469 | 			indent := len(indentation)
470 | 			
471 | 			task := &Task{
472 | 				ID:          generateTaskID(currentVersion.Version, description),
473 | 				Description: description,
474 | 				Completed:   completed,
475 | 				Version:     currentVersion.Version,
476 | 				LineNumber:  lineNum,
477 | 				Indent:      indent,
478 | 				SubTasks:    []*Task{},
479 | 			}
480 | 			
481 | 			// Handle task hierarchy based on indentation
482 | 			if indent == 0 {
483 | 				// Top-level task
484 | 				currentVersion.Tasks = append(currentVersion.Tasks, task)
485 | 				taskStack = [][]*Task{{task}}
486 | 			} else {
487 | 				// Find the parent task based on indentation
488 | 				level := indent / 2
489 | 				
490 | 				// Ensure the stack has enough levels
491 | 				for len(taskStack) <= level {
492 | 					taskStack = append(taskStack, []*Task{})
493 | 				}
494 | 				
495 | 				// Add the task to its level
496 | 				taskStack[level] = append(taskStack[level], task)
497 | 				
498 | 				// Add as a subtask to the parent
499 | 				if level > 0 {
500 | 					parentLevel := level - 1
501 | 					parentTasks := taskStack[parentLevel]
502 | 					if len(parentTasks) > 0 {
503 | 						parent := parentTasks[len(parentTasks)-1]
504 | 						parent.SubTasks = append(parent.SubTasks, task)
505 | 					}
506 | 				}
507 | 			}
508 | 		}
509 | 	}
510 | 	
511 | 	return todoList, nil
512 | }
513 | 
514 | // generateTaskID generates a unique ID for a task
515 | func generateTaskID(version string, description string) string {
516 | 	// Generate a simple ID based on the version and description
517 | 	// In a real implementation, you might want to use something more robust
518 | 	cleanDesc := strings.ToLower(description)
519 | 	cleanDesc = strings.Replace(cleanDesc, " ", "-", -1)
520 | 	cleanDesc = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(cleanDesc, "")
521 | 	
522 | 	if len(cleanDesc) > 20 {
523 | 		cleanDesc = cleanDesc[:20]
524 | 	}
525 | 	
526 | 	return fmt.Sprintf("%s-%s", version, cleanDesc)
527 | }
```

--------------------------------------------------------------------------------
/internal/services/file/file.go:
--------------------------------------------------------------------------------

```go
  1 | package file
  2 | 
  3 | import (
  4 | 	"crypto/sha256"
  5 | 	"encoding/hex"
  6 | 	"errors"
  7 | 	"fmt"
  8 | 	"io"
  9 | 	"os"
 10 | 	"path/filepath"
 11 | 	"strings"
 12 | 
 13 | 	"golang.org/x/net/html/charset"
 14 | 	"golang.org/x/text/encoding"
 15 | 	"golang.org/x/text/encoding/charmap"
 16 | 	"golang.org/x/text/encoding/japanese"
 17 | 	"golang.org/x/text/encoding/korean"
 18 | 	"golang.org/x/text/encoding/simplifiedchinese"
 19 | 	"golang.org/x/text/encoding/traditionalchinese"
 20 | 	"golang.org/x/text/encoding/unicode"
 21 | )
 22 | 
 23 | var (
 24 | 	// ErrFileNotFound is returned when a file does not exist
 25 | 	ErrFileNotFound = errors.New("file not found")
 26 | 	
 27 | 	// ErrContentChanged is returned when the file content has changed since it was last read
 28 | 	ErrContentChanged = errors.New("file content has changed")
 29 | 	
 30 | 	// ErrInvalidRange is returned when an invalid range is specified
 31 | 	ErrInvalidRange = errors.New("invalid range specified")
 32 | 	
 33 | 	// ErrInvalidEncoding is returned when an unsupported encoding is specified
 34 | 	ErrInvalidEncoding = errors.New("invalid encoding specified")
 35 | )
 36 | 
 37 | // LineRange represents a range of lines in a file
 38 | type LineRange struct {
 39 | 	Start int
 40 | 	End   int
 41 | }
 42 | 
 43 | // Service provides file operation methods
 44 | type Service struct {
 45 | 	defaultEncoding      string
 46 | 	autoDetectEncoding   bool
 47 | }
 48 | 
 49 | // NewService creates a new file service
 50 | func NewService(defaultEncoding string, autoDetect bool) *Service {
 51 | 	if defaultEncoding == "" {
 52 | 		defaultEncoding = "utf-8"
 53 | 	}
 54 | 	
 55 | 	return &Service{
 56 | 		defaultEncoding:    defaultEncoding,
 57 | 		autoDetectEncoding: autoDetect,
 58 | 	}
 59 | }
 60 | 
 61 | // ReadLines reads lines from a file with optional range specification
 62 | func (s *Service) ReadLines(filePath string, encName string, ranges ...LineRange) ([]string, string, error) {
 63 | 	// Validate file existence
 64 | 	if _, err := os.Stat(filePath); os.IsNotExist(err) {
 65 | 		return nil, "", ErrFileNotFound
 66 | 	}
 67 | 	
 68 | 	// Handle automatic encoding detection
 69 | 	var enc encoding.Encoding
 70 | 	var err error
 71 | 	
 72 | 	if strings.ToLower(encName) == "auto" {
 73 | 		// Attempt to detect the encoding
 74 | 		enc, encName, err = s.DetectEncoding(filePath)
 75 | 		if err != nil {
 76 | 			// Fall back to default encoding on detection failure
 77 | 			enc, err = s.getEncoding(s.defaultEncoding)
 78 | 			if err != nil {
 79 | 				return nil, "", err
 80 | 			}
 81 | 		}
 82 | 	} else {
 83 | 		// Get the encoding specified by the user
 84 | 		enc, err = s.getEncoding(encName)
 85 | 		if err != nil {
 86 | 			return nil, "", err
 87 | 		}
 88 | 	}
 89 | 	
 90 | 	// Open the file
 91 | 	file, err := os.Open(filePath)
 92 | 	if err != nil {
 93 | 		return nil, "", fmt.Errorf("failed to open file: %w", err)
 94 | 	}
 95 | 	defer file.Close()
 96 | 	
 97 | 	// Read the entire file and calculate its hash
 98 | 	content, err := io.ReadAll(file)
 99 | 	if err != nil {
100 | 		return nil, "", fmt.Errorf("failed to read file: %w", err)
101 | 	}
102 | 	
103 | 	// Calculate hash of the content
104 | 	hash := sha256.Sum256(content)
105 | 	hashStr := hex.EncodeToString(hash[:])
106 | 	
107 | 	// Decode content if needed
108 | 	var decodedContent []byte
109 | 	if enc != nil {
110 | 		decoder := enc.NewDecoder()
111 | 		decodedContent, err = decoder.Bytes(content)
112 | 		if err != nil {
113 | 			return nil, "", fmt.Errorf("failed to decode content: %w", err)
114 | 		}
115 | 	} else {
116 | 		decodedContent = content
117 | 	}
118 | 	
119 | 	// Split into lines
120 | 	lines := strings.Split(string(decodedContent), "\n")
121 | 	
122 | 	// If no ranges provided, return all lines
123 | 	if len(ranges) == 0 {
124 | 		return lines, hashStr, nil
125 | 	}
126 | 	
127 | 	// Process each range
128 | 	var result []string
129 | 	for _, r := range ranges {
130 | 		// Validate range
131 | 		if r.Start < 0 || r.End > len(lines) || r.Start > r.End {
132 | 			return nil, "", ErrInvalidRange
133 | 		}
134 | 		
135 | 		// Add lines from this range
136 | 		result = append(result, lines[r.Start-1:r.End]...)
137 | 	}
138 | 	
139 | 	return result, hashStr, nil
140 | }
141 | 
142 | // EditLines edits lines in a file with hash validation
143 | func (s *Service) EditLines(filePath string, oldHash string, encName string, lineEdits map[int]string) error {
144 | 	// Validate file existence
145 | 	if _, err := os.Stat(filePath); os.IsNotExist(err) {
146 | 		return ErrFileNotFound
147 | 	}
148 | 	
149 | 	// Handle automatic encoding detection
150 | 	var enc encoding.Encoding
151 | 	var err error
152 | 	
153 | 	if strings.ToLower(encName) == "auto" {
154 | 		// Attempt to detect the encoding
155 | 		enc, encName, err = s.DetectEncoding(filePath)
156 | 		if err != nil {
157 | 			// Fall back to default encoding on detection failure
158 | 			enc, err = s.getEncoding(s.defaultEncoding)
159 | 			if err != nil {
160 | 				return err
161 | 			}
162 | 		}
163 | 	} else {
164 | 		// Get the encoding specified by the user
165 | 		enc, err = s.getEncoding(encName)
166 | 		if err != nil {
167 | 			return err
168 | 		}
169 | 	}
170 | 	
171 | 	// Read current file content
172 | 	file, err := os.ReadFile(filePath)
173 | 	if err != nil {
174 | 		return fmt.Errorf("failed to read file: %w", err)
175 | 	}
176 | 	
177 | 	// Verify hash if provided
178 | 	if oldHash != "" {
179 | 		hash := sha256.Sum256(file)
180 | 		currentHash := hex.EncodeToString(hash[:])
181 | 		
182 | 		if currentHash != oldHash {
183 | 			return ErrContentChanged
184 | 		}
185 | 	}
186 | 	
187 | 	// Decode content if needed
188 | 	var decodedContent []byte
189 | 	if enc != nil {
190 | 		decoder := enc.NewDecoder()
191 | 		decodedContent, err = decoder.Bytes(file)
192 | 		if err != nil {
193 | 			return fmt.Errorf("failed to decode content: %w", err)
194 | 		}
195 | 	} else {
196 | 		decodedContent = file
197 | 	}
198 | 	
199 | 	// Split into lines
200 | 	lines := strings.Split(string(decodedContent), "\n")
201 | 	
202 | 	// Apply the edits
203 | 	for lineNum, newContent := range lineEdits {
204 | 		if lineNum < 1 || lineNum > len(lines) {
205 | 			return ErrInvalidRange
206 | 		}
207 | 		
208 | 		lines[lineNum-1] = newContent
209 | 	}
210 | 	
211 | 	// Combine back into a single string
212 | 	newContent := strings.Join(lines, "\n")
213 | 	
214 | 	// Encode the content if needed
215 | 	var finalContent []byte
216 | 	if enc != nil {
217 | 		encoder := enc.NewEncoder()
218 | 		finalContent, err = encoder.Bytes([]byte(newContent))
219 | 		if err != nil {
220 | 			return fmt.Errorf("failed to encode content: %w", err)
221 | 		}
222 | 	} else {
223 | 		finalContent = []byte(newContent)
224 | 	}
225 | 	
226 | 	// Create the directory if it doesn't exist
227 | 	dir := filepath.Dir(filePath)
228 | 	if _, err := os.Stat(dir); os.IsNotExist(err) {
229 | 		if err := os.MkdirAll(dir, 0755); err != nil {
230 | 			return fmt.Errorf("failed to create directory: %w", err)
231 | 		}
232 | 	}
233 | 	
234 | 	// Write the file
235 | 	err = os.WriteFile(filePath, finalContent, 0644)
236 | 	if err != nil {
237 | 		return fmt.Errorf("failed to write file: %w", err)
238 | 	}
239 | 	
240 | 	return nil
241 | }
242 | 
243 | // AppendLines appends lines to a file
244 | func (s *Service) AppendLines(filePath string, encName string, lines []string) error {
245 | 	// Check if file exists for encoding detection
246 | 	fileExists := false
247 | 	if _, err := os.Stat(filePath); err == nil {
248 | 		fileExists = true
249 | 	}
250 | 	
251 | 	// Handle automatic encoding detection or get the specified encoding
252 | 	var enc encoding.Encoding
253 | 	var err error
254 | 	
255 | 	if strings.ToLower(encName) == "auto" {
256 | 		if fileExists {
257 | 			// Attempt to detect the encoding for existing files
258 | 			enc, encName, err = s.DetectEncoding(filePath)
259 | 			if err != nil {
260 | 				// Fall back to default encoding on detection failure
261 | 				enc, err = s.getEncoding(s.defaultEncoding)
262 | 				if err != nil {
263 | 					return err
264 | 				}
265 | 			}
266 | 		} else {
267 | 			// For new files, use the default encoding
268 | 			enc, err = s.getEncoding(s.defaultEncoding)
269 | 			if err != nil {
270 | 				return err
271 | 			}
272 | 		}
273 | 	} else {
274 | 		// Get the encoding specified by the user
275 | 		enc, err = s.getEncoding(encName)
276 | 		if err != nil {
277 | 			return err
278 | 		}
279 | 	}
280 | 	
281 | 	// Convert lines to a single string
282 | 	newContent := strings.Join(lines, "\n")
283 | 	
284 | 	// Add a newline if the file exists and doesn't end with one
285 | 	if _, err := os.Stat(filePath); err == nil {
286 | 		content, err := os.ReadFile(filePath)
287 | 		if err != nil {
288 | 			return fmt.Errorf("failed to read file: %w", err)
289 | 		}
290 | 		
291 | 		if len(content) > 0 && content[len(content)-1] != '\n' {
292 | 			newContent = "\n" + newContent
293 | 		}
294 | 	}
295 | 	
296 | 	// Encode the content if needed
297 | 	var finalContent []byte
298 | 	if enc != nil {
299 | 		encoder := enc.NewEncoder()
300 | 		finalContent, err = encoder.Bytes([]byte(newContent))
301 | 		if err != nil {
302 | 			return fmt.Errorf("failed to encode content: %w", err)
303 | 		}
304 | 	} else {
305 | 		finalContent = []byte(newContent)
306 | 	}
307 | 	
308 | 	// Create the directory if it doesn't exist
309 | 	dir := filepath.Dir(filePath)
310 | 	if _, err := os.Stat(dir); os.IsNotExist(err) {
311 | 		if err := os.MkdirAll(dir, 0755); err != nil {
312 | 			return fmt.Errorf("failed to create directory: %w", err)
313 | 		}
314 | 	}
315 | 	
316 | 	// Open file in append mode
317 | 	f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
318 | 	if err != nil {
319 | 		return fmt.Errorf("failed to open file: %w", err)
320 | 	}
321 | 	defer f.Close()
322 | 	
323 | 	// Write the content
324 | 	if _, err := f.Write(finalContent); err != nil {
325 | 		return fmt.Errorf("failed to append to file: %w", err)
326 | 	}
327 | 	
328 | 	return nil
329 | }
330 | 
331 | // DeleteLines deletes lines from a file with hash validation
332 | func (s *Service) DeleteLines(filePath string, oldHash string, encName string, lineNumbers []int) error {
333 | 	// Validate file existence
334 | 	if _, err := os.Stat(filePath); os.IsNotExist(err) {
335 | 		return ErrFileNotFound
336 | 	}
337 | 	
338 | 	// Handle automatic encoding detection
339 | 	var enc encoding.Encoding
340 | 	var err error
341 | 	
342 | 	if strings.ToLower(encName) == "auto" {
343 | 		// Attempt to detect the encoding
344 | 		enc, encName, err = s.DetectEncoding(filePath)
345 | 		if err != nil {
346 | 			// Fall back to default encoding on detection failure
347 | 			enc, err = s.getEncoding(s.defaultEncoding)
348 | 			if err != nil {
349 | 				return err
350 | 			}
351 | 		}
352 | 	} else {
353 | 		// Get the encoding specified by the user
354 | 		enc, err = s.getEncoding(encName)
355 | 		if err != nil {
356 | 			return err
357 | 		}
358 | 	}
359 | 	
360 | 	// Read current file content
361 | 	file, err := os.ReadFile(filePath)
362 | 	if err != nil {
363 | 		return fmt.Errorf("failed to read file: %w", err)
364 | 	}
365 | 	
366 | 	// Verify hash if provided
367 | 	if oldHash != "" {
368 | 		hash := sha256.Sum256(file)
369 | 		currentHash := hex.EncodeToString(hash[:])
370 | 		
371 | 		if currentHash != oldHash {
372 | 			return ErrContentChanged
373 | 		}
374 | 	}
375 | 	
376 | 	// Decode content if needed
377 | 	var decodedContent []byte
378 | 	if enc != nil {
379 | 		decoder := enc.NewDecoder()
380 | 		decodedContent, err = decoder.Bytes(file)
381 | 		if err != nil {
382 | 			return fmt.Errorf("failed to decode content: %w", err)
383 | 		}
384 | 	} else {
385 | 		decodedContent = file
386 | 	}
387 | 	
388 | 	// Split into lines
389 | 	lines := strings.Split(string(decodedContent), "\n")
390 | 	
391 | 	// Create a map of lines to delete for O(1) lookup
392 | 	toDelete := make(map[int]bool)
393 | 	for _, lineNum := range lineNumbers {
394 | 		if lineNum < 1 || lineNum > len(lines) {
395 | 			return ErrInvalidRange
396 | 		}
397 | 		toDelete[lineNum-1] = true
398 | 	}
399 | 	
400 | 	// Create a new slice without the deleted lines
401 | 	var newLines []string
402 | 	for i, line := range lines {
403 | 		if !toDelete[i] {
404 | 			newLines = append(newLines, line)
405 | 		}
406 | 	}
407 | 	
408 | 	// Combine back into a single string
409 | 	newContent := strings.Join(newLines, "\n")
410 | 	
411 | 	// Encode the content if needed
412 | 	var finalContent []byte
413 | 	if enc != nil {
414 | 		encoder := enc.NewEncoder()
415 | 		finalContent, err = encoder.Bytes([]byte(newContent))
416 | 		if err != nil {
417 | 			return fmt.Errorf("failed to encode content: %w", err)
418 | 		}
419 | 	} else {
420 | 		finalContent = []byte(newContent)
421 | 	}
422 | 	
423 | 	// Write the file
424 | 	err = os.WriteFile(filePath, finalContent, 0644)
425 | 	if err != nil {
426 | 		return fmt.Errorf("failed to write file: %w", err)
427 | 	}
428 | 	
429 | 	return nil
430 | }
431 | 
432 | // InsertLines inserts lines at a specific position with hash validation
433 | func (s *Service) InsertLines(filePath string, oldHash string, encName string, position int, newLines []string) error {
434 | 	// Validate file existence
435 | 	isNewFile := false
436 | 	if _, err := os.Stat(filePath); os.IsNotExist(err) {
437 | 		isNewFile = true
438 | 		
439 | 		// If this is a new file and position is not 1, return an error
440 | 		if position != 1 {
441 | 			return ErrInvalidRange
442 | 		}
443 | 	}
444 | 	
445 | 	// Handle automatic encoding detection or get the specified encoding
446 | 	var enc encoding.Encoding
447 | 	var err error
448 | 	
449 | 	if strings.ToLower(encName) == "auto" {
450 | 		if !isNewFile {
451 | 			// Attempt to detect the encoding for existing files
452 | 			enc, encName, err = s.DetectEncoding(filePath)
453 | 			if err != nil {
454 | 				// Fall back to default encoding on detection failure
455 | 				enc, err = s.getEncoding(s.defaultEncoding)
456 | 				if err != nil {
457 | 					return err
458 | 				}
459 | 			}
460 | 		} else {
461 | 			// For new files, use the default encoding
462 | 			enc, err = s.getEncoding(s.defaultEncoding)
463 | 			if err != nil {
464 | 				return err
465 | 			}
466 | 		}
467 | 	} else {
468 | 		// Get the encoding specified by the user
469 | 		enc, err = s.getEncoding(encName)
470 | 		if err != nil {
471 | 			return err
472 | 		}
473 | 	}
474 | 	
475 | 	var lines []string
476 | 	
477 | 	if !isNewFile {
478 | 		// Read current file content
479 | 		file, err := os.ReadFile(filePath)
480 | 		if err != nil {
481 | 			return fmt.Errorf("failed to read file: %w", err)
482 | 		}
483 | 		
484 | 		// Verify hash if provided
485 | 		if oldHash != "" {
486 | 			hash := sha256.Sum256(file)
487 | 			currentHash := hex.EncodeToString(hash[:])
488 | 			
489 | 			if currentHash != oldHash {
490 | 				return ErrContentChanged
491 | 			}
492 | 		}
493 | 		
494 | 		// Decode content if needed
495 | 		var decodedContent []byte
496 | 		if enc != nil {
497 | 			decoder := enc.NewDecoder()
498 | 			decodedContent, err = decoder.Bytes(file)
499 | 			if err != nil {
500 | 				return fmt.Errorf("failed to decode content: %w", err)
501 | 			}
502 | 		} else {
503 | 			decodedContent = file
504 | 		}
505 | 		
506 | 		// Split into lines
507 | 		lines = strings.Split(string(decodedContent), "\n")
508 | 		
509 | 		// Validate position
510 | 		if position < 1 || position > len(lines)+1 {
511 | 			return ErrInvalidRange
512 | 		}
513 | 	} else {
514 | 		lines = []string{}
515 | 	}
516 | 	
517 | 	// Insert the new lines
518 | 	var resultLines []string
519 | 	
520 | 	if position == 1 {
521 | 		resultLines = append(newLines, lines...)
522 | 	} else if position == len(lines)+1 {
523 | 		resultLines = append(lines, newLines...)
524 | 	} else {
525 | 		resultLines = append(resultLines, lines[:position-1]...)
526 | 		resultLines = append(resultLines, newLines...)
527 | 		resultLines = append(resultLines, lines[position-1:]...)
528 | 	}
529 | 	
530 | 	// Combine back into a single string
531 | 	newContent := strings.Join(resultLines, "\n")
532 | 	
533 | 	// Encode the content if needed
534 | 	var finalContent []byte
535 | 	if enc != nil {
536 | 		encoder := enc.NewEncoder()
537 | 		finalContent, err = encoder.Bytes([]byte(newContent))
538 | 		if err != nil {
539 | 			return fmt.Errorf("failed to encode content: %w", err)
540 | 		}
541 | 	} else {
542 | 		finalContent = []byte(newContent)
543 | 	}
544 | 	
545 | 	// Create the directory if it doesn't exist
546 | 	dir := filepath.Dir(filePath)
547 | 	if _, err := os.Stat(dir); os.IsNotExist(err) {
548 | 		if err := os.MkdirAll(dir, 0755); err != nil {
549 | 			return fmt.Errorf("failed to create directory: %w", err)
550 | 		}
551 | 	}
552 | 	
553 | 	// Write the file
554 | 	err = os.WriteFile(filePath, finalContent, 0644)
555 | 	if err != nil {
556 | 		return fmt.Errorf("failed to write file: %w", err)
557 | 	}
558 | 	
559 | 	return nil
560 | }
561 | 
562 | // DetectEncoding attempts to detect the encoding of a file
563 | func (s *Service) DetectEncoding(filePath string) (encoding.Encoding, string, error) {
564 | 	// Open the file
565 | 	file, err := os.Open(filePath)
566 | 	if err != nil {
567 | 		return nil, "", fmt.Errorf("failed to open file: %w", err)
568 | 	}
569 | 	defer file.Close()
570 | 	
571 | 	// Read a small portion of the file for detection
572 | 	var buf [1024]byte
573 | 	n, err := file.Read(buf[:])
574 | 	if err != nil && err != io.EOF {
575 | 		return nil, "", fmt.Errorf("failed to read file: %w", err)
576 | 	}
577 | 	
578 | 	// Reset the file pointer to the beginning
579 | 	_, err = file.Seek(0, 0)
580 | 	if err != nil {
581 | 		return nil, "", fmt.Errorf("failed to reset file position: %w", err)
582 | 	}
583 | 	
584 | 	// Detect encoding
585 | 	e, name, _ := charset.DetermineEncoding(buf[:n], "")
586 | 	
587 | 	// Normalize name to our standard format
588 | 	normalizedName := s.normalizeEncodingName(name)
589 | 	
590 | 	return e, normalizedName, nil
591 | }
592 | 
593 | // normalizeEncodingName normalizes encoding names to our standard format
594 | func (s *Service) normalizeEncodingName(name string) string {
595 | 	name = strings.ToLower(name)
596 | 	switch name {
597 | 	case "utf-8", "utf8":
598 | 		return "utf-8"
599 | 	case "windows-1252", "cp1252":
600 | 		return "windows-1252"
601 | 	case "iso-8859-1":
602 | 		return "latin1"
603 | 	case "shift_jis", "shift-jis", "shiftjis":
604 | 		return "shift_jis"
605 | 	case "gbk", "gb18030", "gb2312":
606 | 		return "gbk"
607 | 	case "big5", "big5-hkscs":
608 | 		return "big5"
609 | 	case "euc-jp":
610 | 		return "euc-jp"
611 | 	case "euc-kr":
612 | 		return "euc-kr"
613 | 	default:
614 | 		return name
615 | 	}
616 | }
617 | 
618 | // GetEncoding returns the appropriate encoding based on the provided name
619 | // If name is "auto", it will attempt to detect the encoding
620 | func (s *Service) getEncoding(encName string) (encoding.Encoding, error) {
621 | 	if encName == "" {
622 | 		encName = s.defaultEncoding
623 | 	}
624 | 	
625 | 	switch strings.ToLower(encName) {
626 | 	case "utf-8", "utf8":
627 | 		return nil, nil // No encoding/decoding needed for UTF-8
628 | 	case "utf-16", "utf16":
629 | 		return unicode.UTF16(unicode.LittleEndian, unicode.UseBOM), nil
630 | 	case "utf-16be", "utf16be":
631 | 		return unicode.UTF16(unicode.BigEndian, unicode.UseBOM), nil
632 | 	case "utf-16le", "utf16le":
633 | 		return unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM), nil
634 | 	
635 | 	// Japanese encodings
636 | 	case "shift_jis", "shift-jis", "shiftjis":
637 | 		return japanese.ShiftJIS, nil
638 | 	case "euc-jp":
639 | 		return japanese.EUCJP, nil
640 | 	case "iso-2022-jp":
641 | 		return japanese.ISO2022JP, nil
642 | 	
643 | 	// Chinese encodings
644 | 	case "gbk", "gb18030", "gb2312":
645 | 		return simplifiedchinese.GBK, nil
646 | 	case "big5", "big5-hkscs":
647 | 		return traditionalchinese.Big5, nil
648 | 	
649 | 	// Korean encodings
650 | 	case "euc-kr":
651 | 		return korean.EUCKR, nil
652 | 	
653 | 	// Western encodings
654 | 	case "iso-8859-1", "latin1":
655 | 		return charmap.ISO8859_1, nil
656 | 	case "iso-8859-2", "latin2":
657 | 		return charmap.ISO8859_2, nil
658 | 	case "iso-8859-3", "latin3":
659 | 		return charmap.ISO8859_3, nil
660 | 	case "iso-8859-4", "latin4":
661 | 		return charmap.ISO8859_4, nil
662 | 	case "iso-8859-5":
663 | 		return charmap.ISO8859_5, nil
664 | 	case "iso-8859-6":
665 | 		return charmap.ISO8859_6, nil
666 | 	case "iso-8859-7":
667 | 		return charmap.ISO8859_7, nil
668 | 	case "iso-8859-8":
669 | 		return charmap.ISO8859_8, nil
670 | 	case "iso-8859-9", "latin5":
671 | 		return charmap.ISO8859_9, nil
672 | 	case "iso-8859-10", "latin6":
673 | 		return charmap.ISO8859_10, nil
674 | 	case "iso-8859-13", "latin7":
675 | 		return charmap.ISO8859_13, nil
676 | 	case "iso-8859-14", "latin8":
677 | 		return charmap.ISO8859_14, nil
678 | 	case "iso-8859-15", "latin9":
679 | 		return charmap.ISO8859_15, nil
680 | 	case "iso-8859-16":
681 | 		return charmap.ISO8859_16, nil
682 | 	
683 | 	// Windows encodings
684 | 	case "windows-1250":
685 | 		return charmap.Windows1250, nil
686 | 	case "windows-1251":
687 | 		return charmap.Windows1251, nil
688 | 	case "windows-1252", "cp1252":
689 | 		return charmap.Windows1252, nil
690 | 	case "windows-1253":
691 | 		return charmap.Windows1253, nil
692 | 	case "windows-1254":
693 | 		return charmap.Windows1254, nil
694 | 	case "windows-1255":
695 | 		return charmap.Windows1255, nil
696 | 	case "windows-1256":
697 | 		return charmap.Windows1256, nil
698 | 	case "windows-1257":
699 | 		return charmap.Windows1257, nil
700 | 	case "windows-1258":
701 | 		return charmap.Windows1258, nil
702 | 	
703 | 	default:
704 | 		return nil, ErrInvalidEncoding
705 | 	}
706 | }
```

--------------------------------------------------------------------------------
/internal/mcp/tools.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"fmt"
  7 | 	"os"
  8 | 	"path/filepath"
  9 | 	"strings"
 10 | 	"time"
 11 | 
 12 | 	"codeberg.org/mutker/mcp-todo-server/internal/config"
 13 | 	"codeberg.org/mutker/mcp-todo-server/internal/services/changelog"
 14 | 	"codeberg.org/mutker/mcp-todo-server/internal/services/todo"
 15 | 	"github.com/mark3labs/mcp-go/mcp"
 16 | 	"github.com/mark3labs/mcp-go/server"
 17 | )
 18 | 
 19 | // This function is now defined in server.go
 20 | 
 21 | // registerTodoTools registers TODO.md related tools
 22 | func RegisterTools(ctx context.Context, s *server.MCPServer, cfg *config.Config) {
 23 | 	todoService := todo.NewService(cfg)
 24 | 	changelogService := changelog.NewService(cfg)
 25 | 
 26 | 	// Get all tasks
 27 | 	s.AddTool(mcp.NewTool("get-todo-tasks",
 28 | 		mcp.WithDescription("Get all tasks from TODO.md"),
 29 | 	),
 30 | 		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 31 | 			todos, err := todoService.GetAll()
 32 | 			if err != nil {
 33 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to get todos: %w", err)), nil
 34 | 			}
 35 | 			result, err := newToolResultJSON(todos)
 36 | 			return result, err
 37 | 		},
 38 | 	)
 39 | 
 40 | 	// Get tasks for a specific version
 41 | 	s.AddTool(mcp.NewTool("get-todo-tasks-by-version",
 42 | 		mcp.WithDescription("Get tasks for a specific version from TODO.md"),
 43 | 		mcp.WithString("version", 
 44 | 			mcp.PropertyOption(mcp.Required()),
 45 | 			mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
 46 | 		),
 47 | 	),
 48 | 		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 49 | 			version, ok := request.Params.Arguments["version"].(string)
 50 | 			if !ok {
 51 | 				return mcp.NewToolResultError("version parameter must be a string"), nil
 52 | 			}
 53 | 
 54 | 			todos, err := todoService.GetByVersion(version)
 55 | 			if err != nil {
 56 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to get todos for version %s: %w", version, err)), nil
 57 | 			}
 58 | 			if todos == nil {
 59 | 				return mcp.NewToolResultError(fmt.Sprintf("version not found: %s", version)), nil
 60 | 			}
 61 | 			result, err := newToolResultJSON(todos)
 62 | 			return result, err
 63 | 		},
 64 | 	)
 65 | 
 66 | 	// Add a new task
 67 | 	s.AddTool(mcp.NewTool("add-todo-task",
 68 | 		mcp.WithDescription("Add a new task to TODO.md"),
 69 | 		mcp.WithString("version",
 70 | 			mcp.PropertyOption(mcp.Required()),
 71 | 			mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
 72 | 		),
 73 | 		mcp.WithString("description",
 74 | 			mcp.PropertyOption(mcp.Required()),
 75 | 			mcp.PropertyOption(mcp.Description("Task description")),
 76 | 		),
 77 | 		mcp.WithString("parent_id",
 78 | 			mcp.PropertyOption(mcp.Description("ID of parent task (for subtasks)")),
 79 | 		),
 80 | 	),
 81 | 		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 82 | 			version, ok := request.Params.Arguments["version"].(string)
 83 | 			if !ok {
 84 | 				return mcp.NewToolResultError("version parameter must be a string"), nil
 85 | 			}
 86 | 
 87 | 			description, ok := request.Params.Arguments["description"].(string)
 88 | 			if !ok {
 89 | 				return mcp.NewToolResultError("description parameter must be a string"), nil
 90 | 			}
 91 | 
 92 | 			var parentID string
 93 | 			if parent, ok := request.Params.Arguments["parent_id"].(string); ok {
 94 | 				parentID = parent
 95 | 			}
 96 | 
 97 | 			taskID, err := todoService.AddTask(version, description, parentID)
 98 | 			if err != nil {
 99 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to add task: %w", err)), nil
100 | 			}
101 | 
102 | 			result, err := newToolResultJSON(map[string]string{"id": taskID})
103 | 			return result, err
104 | 		},
105 | 	)
106 | 
107 | 	// Update an existing task
108 | 	toolSchema := map[string]interface{}{
109 | 		"type": "object",
110 | 		"properties": map[string]interface{}{
111 | 			"id": map[string]interface{}{
112 | 				"type":        "string",
113 | 				"description": "Task ID",
114 | 			},
115 | 			"completed": map[string]interface{}{
116 | 				"type":        "boolean",
117 | 				"description": "Task completion status",
118 | 			},
119 | 			"description": map[string]interface{}{
120 | 				"type":        "string",
121 | 				"description": "Updated task description",
122 | 			},
123 | 		},
124 | 		"required": []string{"id"},
125 | 	}
126 | 	schemaBytes, _ := json.Marshal(toolSchema)
127 | 	s.AddTool(mcp.NewToolWithRawSchema("update-todo-task", 
128 | 		"Update an existing task in TODO.md", 
129 | 		json.RawMessage(schemaBytes)),
130 | 		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
131 | 			taskID, ok := request.Params.Arguments["id"].(string)
132 | 			if !ok {
133 | 				return mcp.NewToolResultError("id parameter must be a string"), nil
134 | 			}
135 | 
136 | 			var completed *bool
137 | 			if c, ok := request.Params.Arguments["completed"].(bool); ok {
138 | 				completed = &c
139 | 			}
140 | 
141 | 			var description *string
142 | 			if d, ok := request.Params.Arguments["description"].(string); ok {
143 | 				description = &d
144 | 			}
145 | 
146 | 			if completed == nil && description == nil {
147 | 				return mcp.NewToolResultError("at least one of completed or description must be provided"), nil
148 | 			}
149 | 
150 | 			err := todoService.UpdateTask(taskID, completed, description)
151 | 			if err != nil {
152 | 				if err == todo.ErrTaskNotFound {
153 | 					return mcp.NewToolResultError(fmt.Sprintf("task not found: %s", taskID)), nil
154 | 				}
155 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to update task: %w", err)), nil
156 | 			}
157 | 
158 | 			result, err := newToolResultJSON(map[string]bool{"success": true})
159 | 			return result, err
160 | 		},
161 | 	)
162 | 
163 | 	// Add a new version section
164 | 	s.AddTool(mcp.NewTool("add-todo-version",
165 | 		mcp.WithDescription("Add a new version section to TODO.md"),
166 | 		mcp.WithString("version",
167 | 			mcp.PropertyOption(mcp.Required()),
168 | 			mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
169 | 		),
170 | 	),
171 | 		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
172 | 			version, ok := request.Params.Arguments["version"].(string)
173 | 			if !ok {
174 | 				return mcp.NewToolResultError("version parameter must be a string"), nil
175 | 			}
176 | 
177 | 			err := todoService.AddVersion(version)
178 | 			if err != nil {
179 | 				if err == todo.ErrVersionExists {
180 | 					return mcp.NewToolResultError(fmt.Sprintf("version already exists: %s", version)), nil
181 | 				}
182 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to add version: %w", err)), nil
183 | 			}
184 | 
185 | 			result, err := newToolResultJSON(map[string]bool{"success": true})
186 | 			return result, err
187 | 		},
188 | 	)
189 | 
190 | 	// Import and format an existing TODO.md
191 | 	s.AddTool(mcp.NewTool("import-todo",
192 | 		mcp.WithDescription("Import and format an existing TODO.md file"),
193 | 		mcp.WithString("source_path",
194 | 			mcp.PropertyOption(mcp.Required()),
195 | 			mcp.PropertyOption(mcp.Description("Path to the source TODO.md file")),
196 | 		),
197 | 	),
198 | 		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
199 | 			sourcePath, ok := request.Params.Arguments["source_path"].(string)
200 | 			if !ok {
201 | 				return mcp.NewToolResultError("source_path parameter must be a string"), nil
202 | 			}
203 | 
204 | 			// Create an absolute path if a relative path is provided
205 | 			if !filepath.IsAbs(sourcePath) {
206 | 				cwd, err := os.Getwd()
207 | 				if err != nil {
208 | 					return mcp.NewToolResultError(fmt.Sprintf("failed to get current working directory: %w", err)), nil
209 | 				}
210 | 				sourcePath = filepath.Join(cwd, sourcePath)
211 | 			}
212 | 
213 | 			// Read the source file
214 | 			content, err := os.ReadFile(sourcePath)
215 | 			if err != nil {
216 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to read source file: %w", err)), nil
217 | 			}
218 | 
219 | 			// Split into lines
220 | 			lines := strings.Split(string(content), "\n")
221 | 
222 | 			// Parse the content
223 | 			todoList, err := todoService.GetAll()
224 | 			if err != nil {
225 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to load existing TODO file: %w", err)), nil
226 | 			}
227 | 
228 | 			// Only proceed if the current TODO.md is empty
229 | 			if len(todoList.Versions) > 0 {
230 | 				return mcp.NewToolResultError("TODO.md already exists and has content, cannot import"), nil
231 | 			}
232 | 
233 | 			// Process each line
234 | 			var currentVersion string
235 | 			var tasks []struct {
236 | 				Version     string
237 | 				Description string
238 | 				Completed   bool
239 | 			}
240 | 
241 | 			for _, line := range lines {
242 | 				// Skip empty lines and title
243 | 				if line == "" || line == "# TODO" {
244 | 					continue
245 | 				}
246 | 
247 | 				// Check for version headers
248 | 				if strings.HasPrefix(line, "## ") {
249 | 					versionStr := strings.TrimPrefix(line, "## ")
250 | 					// Handle both v1.0.0 and 1.0.0 formats
251 | 					if strings.HasPrefix(versionStr, "v") {
252 | 						currentVersion = strings.TrimPrefix(versionStr, "v")
253 | 					} else {
254 | 						currentVersion = versionStr
255 | 					}
256 | 
257 | 					if currentVersion != "" {
258 | 						// Add the version
259 | 						err := todoService.AddVersion(currentVersion)
260 | 						if err != nil && err != todo.ErrVersionExists {
261 | 							return mcp.NewToolResultError(fmt.Sprintf("failed to add version %s: %w", currentVersion, err)), nil
262 | 						}
263 | 					}
264 | 					continue
265 | 				}
266 | 
267 | 				// Check for task lines
268 | 				if strings.Contains(line, "- [ ]") || strings.Contains(line, "- [x]") {
269 | 					completed := strings.Contains(line, "- [x]")
270 | 
271 | 					var description string
272 | 					if completed {
273 | 						description = strings.TrimSpace(strings.Split(line, "- [x]")[1])
274 | 					} else {
275 | 						description = strings.TrimSpace(strings.Split(line, "- [ ]")[1])
276 | 					}
277 | 
278 | 					if currentVersion != "" && description != "" {
279 | 						tasks = append(tasks, struct {
280 | 							Version     string
281 | 							Description string
282 | 							Completed   bool
283 | 						}{
284 | 							Version:     currentVersion,
285 | 							Description: description,
286 | 							Completed:   completed,
287 | 						})
288 | 					}
289 | 				}
290 | 			}
291 | 
292 | 			// Add all tasks
293 | 			for _, t := range tasks {
294 | 				taskID, err := todoService.AddTask(t.Version, t.Description, "")
295 | 				if err != nil {
296 | 					return mcp.NewToolResultError(fmt.Sprintf("failed to add task: %w", err)), nil
297 | 				}
298 | 
299 | 				if t.Completed {
300 | 					completed := true
301 | 					err = todoService.UpdateTask(taskID, &completed, nil)
302 | 					if err != nil {
303 | 						return mcp.NewToolResultError(fmt.Sprintf("failed to mark task as completed: %w", err)), nil
304 | 					}
305 | 				}
306 | 			}
307 | 
308 | 			result, err := newToolResultJSON(map[string]interface{}{
309 | 				"success":        true,
310 | 				"versions_added": len(todoList.Versions),
311 | 				"tasks_added":    len(tasks),
312 | 			})
313 | 			return result, err
314 | 		},
315 | 	)
316 | 
317 | 	// Get all changelog items
318 | 	s.AddTool(mcp.NewTool("get-changelog",
319 | 		mcp.WithDescription("Get all changelog entries"),
320 | 	),
321 | 		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
322 | 			changelogEntries, err := changelogService.GetAll()
323 | 			if err != nil {
324 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to get changelog entries: %w", err)), nil
325 | 			}
326 | 			result, err := newToolResultJSON(changelogEntries)
327 | 			return result, err
328 | 		},
329 | 	)
330 | 
331 | 	// Get changelog items for a specific version
332 | 	s.AddTool(mcp.NewTool("get-changelog-by-version",
333 | 		mcp.WithDescription("Get changelog entries for a specific version"),
334 | 		mcp.WithString("version",
335 | 			mcp.PropertyOption(mcp.Required()),
336 | 			mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
337 | 		),
338 | 	),
339 | 		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
340 | 			version, ok := request.Params.Arguments["version"].(string)
341 | 			if !ok {
342 | 				return mcp.NewToolResultError("version parameter must be a string"), nil
343 | 			}
344 | 
345 | 			entry, err := changelogService.GetByVersion(version)
346 | 			if err != nil {
347 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to get changelog entry for version %s: %w", version, err)), nil
348 | 			}
349 | 			if entry == nil {
350 | 				return mcp.NewToolResultError(fmt.Sprintf("version not found: %s", version)), nil
351 | 			}
352 | 			result, err := newToolResultJSON(entry)
353 | 			return result, err
354 | 		},
355 | 	)
356 | 
357 | 	// Add a new changelog version entry - using raw schema
358 | 	changelogSchema := map[string]interface{}{
359 | 		"type": "object",
360 | 		"properties": map[string]interface{}{
361 | 			"version": map[string]interface{}{
362 | 				"type":        "string",
363 | 				"description": "Version string (e.g., '1.0.0')",
364 | 			},
365 | 			"date": map[string]interface{}{
366 | 				"type":        "string",
367 | 				"description": "Release date (YYYY-MM-DD format)",
368 | 			},
369 | 			"added": map[string]interface{}{
370 | 				"type":        "array",
371 | 				"description": "List of new features added",
372 | 				"items": map[string]interface{}{
373 | 					"type": "string",
374 | 				},
375 | 			},
376 | 			"changed": map[string]interface{}{
377 | 				"type":        "array",
378 | 				"description": "List of changes to existing functionality",
379 | 				"items": map[string]interface{}{
380 | 					"type": "string",
381 | 				},
382 | 			},
383 | 			"deprecated": map[string]interface{}{
384 | 				"type":        "array",
385 | 				"description": "List of deprecated features",
386 | 				"items": map[string]interface{}{
387 | 					"type": "string",
388 | 				},
389 | 			},
390 | 			"removed": map[string]interface{}{
391 | 				"type":        "array",
392 | 				"description": "List of removed features",
393 | 				"items": map[string]interface{}{
394 | 					"type": "string",
395 | 				},
396 | 			},
397 | 			"fixed": map[string]interface{}{
398 | 				"type":        "array",
399 | 				"description": "List of bug fixes",
400 | 				"items": map[string]interface{}{
401 | 					"type": "string",
402 | 				},
403 | 			},
404 | 			"security": map[string]interface{}{
405 | 				"type":        "array",
406 | 				"description": "List of security fixes",
407 | 				"items": map[string]interface{}{
408 | 					"type": "string",
409 | 				},
410 | 			},
411 | 		},
412 | 		"required": []string{"version"},
413 | 	}
414 | 	changelogSchemaBytes, _ := json.Marshal(changelogSchema)
415 | 	s.AddTool(mcp.NewToolWithRawSchema("add-changelog-entry", 
416 | 		"Add a new changelog version entry", 
417 | 		json.RawMessage(changelogSchemaBytes)),
418 | 		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
419 | 			version, ok := request.Params.Arguments["version"].(string)
420 | 			if !ok {
421 | 				return mcp.NewToolResultError("version parameter must be a string"), nil
422 | 			}
423 | 
424 | 			date, ok := request.Params.Arguments["date"].(string)
425 | 			if !ok {
426 | 				// Default to today's date
427 | 				date = time.Now().Format("2006-01-02")
428 | 			}
429 | 
430 | 			// Process content sections
431 | 			content := &changelog.ChangelogContent{}
432 | 
433 | 			if added, ok := getStringArray(request.Params.Arguments, "added"); ok {
434 | 				content.Added = added
435 | 			}
436 | 			if changed, ok := getStringArray(request.Params.Arguments, "changed"); ok {
437 | 				content.Changed = changed
438 | 			}
439 | 			if deprecated, ok := getStringArray(request.Params.Arguments, "deprecated"); ok {
440 | 				content.Deprecated = deprecated
441 | 			}
442 | 			if removed, ok := getStringArray(request.Params.Arguments, "removed"); ok {
443 | 				content.Removed = removed
444 | 			}
445 | 			if fixed, ok := getStringArray(request.Params.Arguments, "fixed"); ok {
446 | 				content.Fixed = fixed
447 | 			}
448 | 			if security, ok := getStringArray(request.Params.Arguments, "security"); ok {
449 | 				content.Security = security
450 | 			}
451 | 
452 | 			err := changelogService.AddEntry(version, date, content)
453 | 			if err != nil {
454 | 				if err == changelog.ErrVersionExists {
455 | 					return mcp.NewToolResultError(fmt.Sprintf("version already exists: %s", version)), nil
456 | 				}
457 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to add changelog entry: %w", err)), nil
458 | 			}
459 | 
460 | 			result, err := newToolResultJSON(map[string]bool{"success": true})
461 | 			return result, err
462 | 		},
463 | 	)
464 | 
465 | 	// Update existing changelog entry - using raw schema
466 | 	updateChangelogSchema := map[string]interface{}{
467 | 		"type": "object",
468 | 		"properties": map[string]interface{}{
469 | 			"version": map[string]interface{}{
470 | 				"type":        "string",
471 | 				"description": "Version string (e.g., '1.0.0')",
472 | 			},
473 | 			"date": map[string]interface{}{
474 | 				"type":        "string",
475 | 				"description": "Updated release date (YYYY-MM-DD format)",
476 | 			},
477 | 			"added": map[string]interface{}{
478 | 				"type":        "array",
479 | 				"description": "Updated list of new features added",
480 | 				"items": map[string]interface{}{
481 | 					"type": "string",
482 | 				},
483 | 			},
484 | 			"changed": map[string]interface{}{
485 | 				"type":        "array",
486 | 				"description": "Updated list of changes to existing functionality",
487 | 				"items": map[string]interface{}{
488 | 					"type": "string",
489 | 				},
490 | 			},
491 | 			"deprecated": map[string]interface{}{
492 | 				"type":        "array",
493 | 				"description": "Updated list of deprecated features",
494 | 				"items": map[string]interface{}{
495 | 					"type": "string",
496 | 				},
497 | 			},
498 | 			"removed": map[string]interface{}{
499 | 				"type":        "array",
500 | 				"description": "Updated list of removed features",
501 | 				"items": map[string]interface{}{
502 | 					"type": "string",
503 | 				},
504 | 			},
505 | 			"fixed": map[string]interface{}{
506 | 				"type":        "array",
507 | 				"description": "Updated list of bug fixes",
508 | 				"items": map[string]interface{}{
509 | 					"type": "string",
510 | 				},
511 | 			},
512 | 			"security": map[string]interface{}{
513 | 				"type":        "array",
514 | 				"description": "Updated list of security fixes",
515 | 				"items": map[string]interface{}{
516 | 					"type": "string",
517 | 				},
518 | 			},
519 | 		},
520 | 		"required": []string{"version"},
521 | 	}
522 | 	updateChangelogSchemaBytes, _ := json.Marshal(updateChangelogSchema)
523 | 	s.AddTool(mcp.NewToolWithRawSchema("update-changelog-entry", 
524 | 		"Update an existing changelog entry", 
525 | 		json.RawMessage(updateChangelogSchemaBytes)),
526 | 		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
527 | 			version, ok := request.Params.Arguments["version"].(string)
528 | 			if !ok {
529 | 				return mcp.NewToolResultError("version parameter must be a string"), nil
530 | 			}
531 | 
532 | 			date, ok := request.Params.Arguments["date"].(string)
533 | 			if !ok {
534 | 				date = ""
535 | 			}
536 | 
537 | 			// Process content sections
538 | 			content := &changelog.ChangelogContent{}
539 | 
540 | 			if added, ok := getStringArray(request.Params.Arguments, "added"); ok {
541 | 				content.Added = added
542 | 			}
543 | 			if changed, ok := getStringArray(request.Params.Arguments, "changed"); ok {
544 | 				content.Changed = changed
545 | 			}
546 | 			if deprecated, ok := getStringArray(request.Params.Arguments, "deprecated"); ok {
547 | 				content.Deprecated = deprecated
548 | 			}
549 | 			if removed, ok := getStringArray(request.Params.Arguments, "removed"); ok {
550 | 				content.Removed = removed
551 | 			}
552 | 			if fixed, ok := getStringArray(request.Params.Arguments, "fixed"); ok {
553 | 				content.Fixed = fixed
554 | 			}
555 | 			if security, ok := getStringArray(request.Params.Arguments, "security"); ok {
556 | 				content.Security = security
557 | 			}
558 | 
559 | 			err := changelogService.UpdateEntry(version, date, content)
560 | 			if err != nil {
561 | 				if err == changelog.ErrVersionNotFound {
562 | 					return mcp.NewToolResultError(fmt.Sprintf("version not found: %s", version)), nil
563 | 				}
564 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to update changelog entry: %w", err)), nil
565 | 			}
566 | 			result, err := newToolResultJSON(map[string]bool{"success": true})
567 | 			return result, err
568 | 		},
569 | 	)
570 | 
571 | 	// Import and format an existing CHANGELOG.md
572 | 	s.AddTool(mcp.NewTool("import-changelog",
573 | 		mcp.WithDescription("Import and format an existing CHANGELOG.md file"),
574 | 		mcp.WithString("source_path",
575 | 			mcp.PropertyOption(mcp.Required()),
576 | 			mcp.PropertyOption(mcp.Description("Path to the source CHANGELOG.md file")),
577 | 		),
578 | 	),
579 | 		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
580 | 			sourcePath, ok := request.Params.Arguments["source_path"].(string)
581 | 			if !ok {
582 | 				return mcp.NewToolResultError("source_path parameter must be a string"), nil
583 | 			}
584 | 
585 | 			// Create an absolute path if a relative path is provided
586 | 			if !filepath.IsAbs(sourcePath) {
587 | 				cwd, err := os.Getwd()
588 | 				if err != nil {
589 | 					return mcp.NewToolResultError(fmt.Sprintf("failed to get current working directory: %w", err)), nil
590 | 				}
591 | 				sourcePath = filepath.Join(cwd, sourcePath)
592 | 			}
593 | 
594 | 			// Read the source file
595 | 			content, err := os.ReadFile(sourcePath)
596 | 			if err != nil {
597 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to read source file: %w", err)), nil
598 | 			}
599 | 
600 | 			// Split into lines
601 | 			lines := strings.Split(string(content), "\n")
602 | 
603 | 			// Parse the content
604 | 			changelogEntries, err := changelogService.GetAll()
605 | 			if err != nil {
606 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to load existing CHANGELOG file: %w", err)), nil
607 | 			}
608 | 
609 | 			// Only proceed if the current CHANGELOG.md is empty
610 | 			if len(changelogEntries.Versions) > 0 {
611 | 				return mcp.NewToolResultError("CHANGELOG.md already exists and has content, cannot import"), nil
612 | 			}
613 | 
614 | 			// Process the content
615 | 			var currentVersion string
616 | 			var currentDate string
617 | 			var currentSection string
618 | 			var entriesAdded int
619 | 
620 | 			var added []string
621 | 			var changed []string
622 | 			var deprecated []string
623 | 			var removed []string
624 | 			var fixed []string
625 | 			var security []string
626 | 
627 | 			for _, line := range lines {
628 | 				// Skip empty lines and title
629 | 				if line == "" || line == "# Changelog" {
630 | 					continue
631 | 				}
632 | 
633 | 				// Check for version headers
634 | 				if strings.HasPrefix(line, "## [") && strings.Contains(line, "] - ") {
635 | 					// Save the previous version if exists
636 | 					if currentVersion != "" {
637 | 						content := &changelog.ChangelogContent{
638 | 							Added:      added,
639 | 							Changed:    changed,
640 | 							Deprecated: deprecated,
641 | 							Removed:    removed,
642 | 							Fixed:      fixed,
643 | 							Security:   security,
644 | 						}
645 | 
646 | 						err := changelogService.AddEntry(currentVersion, currentDate, content)
647 | 						if err != nil && err != changelog.ErrVersionExists {
648 | 							return mcp.NewToolResultError(fmt.Sprintf("failed to add changelog entry: %w", err)), nil
649 | 						}
650 | 						entriesAdded++
651 | 
652 | 						// Reset for next version
653 | 						added = nil
654 | 						changed = nil
655 | 						deprecated = nil
656 | 						removed = nil
657 | 						fixed = nil
658 | 						security = nil
659 | 					}
660 | 
661 | 					// Parse version and date
662 | 					parts := strings.Split(line, "] - ")
663 | 					versionStr := strings.TrimPrefix(parts[0], "## [")
664 | 					currentVersion = versionStr
665 | 					currentDate = parts[1]
666 | 					currentSection = ""
667 | 					continue
668 | 				}
669 | 
670 | 				// Check for section headers
671 | 				if strings.HasPrefix(line, "### ") {
672 | 					currentSection = strings.ToLower(strings.TrimPrefix(line, "### "))
673 | 					continue
674 | 				}
675 | 
676 | 				// Process list items
677 | 				if strings.HasPrefix(line, "- ") && currentSection != "" {
678 | 					item := strings.TrimPrefix(line, "- ")
679 | 
680 | 					switch currentSection {
681 | 					case "added":
682 | 						added = append(added, item)
683 | 					case "changed":
684 | 						changed = append(changed, item)
685 | 					case "deprecated":
686 | 						deprecated = append(deprecated, item)
687 | 					case "removed":
688 | 						removed = append(removed, item)
689 | 					case "fixed":
690 | 						fixed = append(fixed, item)
691 | 					case "security":
692 | 						security = append(security, item)
693 | 					}
694 | 				}
695 | 			}
696 | 
697 | 			// Add the last version if exists
698 | 			if currentVersion != "" {
699 | 				content := &changelog.ChangelogContent{
700 | 					Added:      added,
701 | 					Changed:    changed,
702 | 					Deprecated: deprecated,
703 | 					Removed:    removed,
704 | 					Fixed:      fixed,
705 | 					Security:   security,
706 | 				}
707 | 
708 | 				err := changelogService.AddEntry(currentVersion, currentDate, content)
709 | 				if err != nil && err != changelog.ErrVersionExists {
710 | 					return mcp.NewToolResultError(fmt.Sprintf("failed to add changelog entry: %w", err)), nil
711 | 				}
712 | 				entriesAdded++
713 | 			}
714 | 			result, err := newToolResultJSON(map[string]interface{}{
715 | 				"success":       true,
716 | 				"entries_added": entriesAdded,
717 | 			})
718 | 			return result, err
719 | 		},
720 | 	)
721 | 
722 | 	// Generate a new CHANGELOG.md based on completed tasks in TODO.md
723 | 	s.AddTool(mcp.NewTool("generate-changelog-from-todo",
724 | 		mcp.WithDescription("Generate a new CHANGELOG.md entry based on completed tasks in TODO.md"),
725 | 		mcp.WithString("version",
726 | 			mcp.PropertyOption(mcp.Required()),
727 | 			mcp.PropertyOption(mcp.Description("Version string (e.g., '1.0.0')")),
728 | 		),
729 | 		mcp.WithString("date",
730 | 			mcp.PropertyOption(mcp.Description("Release date (YYYY-MM-DD format)")),
731 | 		),
732 | 	),
733 | 		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
734 | 			version, ok := request.Params.Arguments["version"].(string)
735 | 			if !ok {
736 | 				return mcp.NewToolResultError("version parameter must be a string"), nil
737 | 			}
738 | 
739 | 			date, ok := request.Params.Arguments["date"].(string)
740 | 			if !ok {
741 | 				// Default to today's date
742 | 				date = time.Now().Format("2006-01-02")
743 | 			}
744 | 
745 | 			// Get tasks for the specified version
746 | 			versionTasks, err := todoService.GetByVersion(version)
747 | 			if err != nil {
748 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to get tasks for version %s: %w", version, err)), nil
749 | 			}
750 | 
751 | 			if versionTasks == nil {
752 | 				return mcp.NewToolResultError(fmt.Sprintf("version not found in TODO.md: %s", version)), nil
753 | 			}
754 | 
755 | 			// Check if this version already exists in CHANGELOG
756 | 			existingEntry, err := changelogService.GetByVersion(version)
757 | 			if err != nil {
758 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to check if changelog entry exists: %w", err)), nil
759 | 			}
760 | 
761 | 			if existingEntry != nil {
762 | 				return mcp.NewToolResultError(fmt.Sprintf("changelog entry already exists for version %s", version)), nil
763 | 			}
764 | 
765 | 			// Categorize completed tasks
766 | 			var added []string
767 | 			var changed []string
768 | 			var fixed []string
769 | 
770 | 			// Helper function to process tasks recursively
771 | 			var processTasks func(tasks []*todo.Task)
772 | 			processTasks = func(tasks []*todo.Task) {
773 | 				for _, task := range tasks {
774 | 					if task.Completed {
775 | 						// Categorize based on common prefixes/keywords
776 | 						desc := task.Description
777 | 						lowerDesc := strings.ToLower(desc)
778 | 
779 | 						if strings.HasPrefix(lowerDesc, "add") ||
780 | 							strings.HasPrefix(lowerDesc, "implement") ||
781 | 							strings.HasPrefix(lowerDesc, "create") {
782 | 							added = append(added, desc)
783 | 						} else if strings.HasPrefix(lowerDesc, "update") ||
784 | 							strings.HasPrefix(lowerDesc, "change") ||
785 | 							strings.HasPrefix(lowerDesc, "modify") ||
786 | 							strings.HasPrefix(lowerDesc, "enhance") ||
787 | 							strings.HasPrefix(lowerDesc, "improve") {
788 | 							changed = append(changed, desc)
789 | 						} else if strings.HasPrefix(lowerDesc, "fix") ||
790 | 							strings.HasPrefix(lowerDesc, "correct") ||
791 | 							strings.HasPrefix(lowerDesc, "resolve") {
792 | 							fixed = append(fixed, desc)
793 | 						} else {
794 | 							// Default to Added if cannot categorize
795 | 							added = append(added, desc)
796 | 						}
797 | 					}
798 | 
799 | 					// Process subtasks
800 | 					if len(task.SubTasks) > 0 {
801 | 						processTasks(task.SubTasks)
802 | 					}
803 | 				}
804 | 			}
805 | 
806 | 			processTasks(versionTasks.Tasks)
807 | 
808 | 			// Create changelog entry
809 | 			content := &changelog.ChangelogContent{
810 | 				Added:   added,
811 | 				Changed: changed,
812 | 				Fixed:   fixed,
813 | 			}
814 | 
815 | 			err = changelogService.AddEntry(version, date, content)
816 | 			if err != nil {
817 | 				return mcp.NewToolResultError(fmt.Sprintf("failed to add changelog entry: %w", err)), nil
818 | 			}
819 | 
820 | 			result, err := newToolResultJSON(map[string]interface{}{
821 | 				"success":       true,
822 | 				"added_items":   len(added),
823 | 				"changed_items": len(changed),
824 | 				"fixed_items":   len(fixed),
825 | 			})
826 | 			return result, err
827 | 		},
828 | 	)
829 | }
830 | 
831 | // Helper function to extract string arrays from the arguments
832 | func getStringArray(args map[string]any, key string) ([]string, bool) {
833 | 	if val, ok := args[key]; ok {
834 | 		if arr, ok := val.([]interface{}); ok {
835 | 			result := make([]string, 0, len(arr))
836 | 			for _, item := range arr {
837 | 				if strItem, ok := item.(string); ok {
838 | 					result = append(result, strItem)
839 | 				}
840 | 			}
841 | 			return result, true
842 | 		} else if arrStr, ok := val.([]string); ok {
843 | 			return arrStr, true
844 | 		}
845 | 	}
846 | 	return nil, false
847 | }
848 | 
```