Merge pull request #20 from boolean-maybe/feature/standard-config

move config to standard location
This commit is contained in:
boolean-maybe 2026-01-25 14:02:59 -05:00 committed by GitHub
commit 57aa3ccfa4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 841 additions and 165 deletions

View file

@ -45,7 +45,8 @@ func PromptForProjectInit() ([]string, bool, error) {
// Returns (proceed, error).
// If proceed is false, the user canceled initialization.
func EnsureProjectInitialized(tikiSkillMdContent, dokiSkillMdContent string) (bool, error) {
if _, err := os.Stat(TaskDir); err != nil {
taskDir := GetTaskDir()
if _, err := os.Stat(taskDir); err != nil {
if !os.IsNotExist(err) {
return false, fmt.Errorf("failed to stat task directory: %w", err)
}

View file

@ -14,21 +14,6 @@ import (
"github.com/spf13/viper"
)
// Hardcoded task storage configuration
var (
TaskDir = ".doc/tiki"
DokiDir = ".doc/doki"
)
// GetDokiRoot returns the absolute path to the doki directory
func GetDokiRoot() string {
cwd, err := os.Getwd()
if err != nil {
return DokiDir // Fallback to relative path
}
return filepath.Join(cwd, DokiDir)
}
// Config holds all application configuration loaded from config.yaml
type Config struct {
// Logging configuration
@ -59,25 +44,23 @@ type Config struct {
var appConfig *Config
// LoadConfig loads configuration from config.yaml in the binary's directory
// LoadConfig loads configuration from config.yaml
// Priority order (first found wins): project config → user config → current directory (dev)
// If config.yaml doesn't exist, it uses default values
func LoadConfig() (*Config, error) {
// Reset viper to clear any previous configuration
viper.Reset()
// Get the directory where the binary is located
exePath, err := os.Executable()
if err != nil {
slog.Error("failed to get executable path", "error", err)
return nil, err
}
binaryDir := filepath.Dir(exePath)
// Configure viper to look for config.yaml in the binary's directory
// Configure viper to look for config.yaml
// Viper uses first-found priority, so project config takes precedence
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(binaryDir)
viper.AddConfigPath(".") // Also check current directory for development
// Add search paths in priority order (first added = highest priority)
projectConfigDir := filepath.Dir(GetProjectConfigFile())
viper.AddConfigPath(projectConfigDir) // Project config (highest priority)
viper.AddConfigPath(GetConfigDir()) // User config
viper.AddConfigPath(".") // Current directory (development)
// Set default values
setDefaults()
@ -85,7 +68,7 @@ func LoadConfig() (*Config, error) {
// Read the config file (if it exists)
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
slog.Debug("no config.yaml found, using defaults", "directory", binaryDir)
slog.Debug("no config.yaml found, using defaults")
} else {
slog.Error("error reading config file", "error", err)
return nil, err
@ -269,13 +252,8 @@ func GetMaxPoints() int {
func saveConfig() error {
configFile := viper.ConfigFileUsed()
if configFile == "" {
// If no config file was loaded, determine where to save it
exePath, err := os.Executable()
if err != nil {
return err
}
binaryDir := filepath.Dir(exePath)
configFile = filepath.Join(binaryDir, "config.yaml")
// If no config file was loaded, save to user config directory
configFile = GetConfigFile()
}
return viper.WriteConfigAs(configFile)

313
config/paths.go Normal file
View file

@ -0,0 +1,313 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
)
var (
// ErrNoHome indicates that the user's home directory could not be determined
ErrNoHome = errors.New("unable to determine home directory")
// ErrPathManagerInit indicates that the PathManager failed to initialize
ErrPathManagerInit = errors.New("failed to initialize path manager")
)
// PathManager manages all file system paths for tiki
type PathManager struct {
configDir string // User config directory
cacheDir string // User cache directory
projectRoot string // Current working directory
}
// newPathManager creates and initializes a new PathManager
func newPathManager() (*PathManager, error) {
configDir, err := getUserConfigDir()
if err != nil {
return nil, fmt.Errorf("get config directory: %w", err)
}
cacheDir, err := getUserCacheDir()
if err != nil {
return nil, fmt.Errorf("get cache directory: %w", err)
}
projectRoot, err := getProjectRoot()
if err != nil {
return nil, fmt.Errorf("get project root: %w", err)
}
return &PathManager{
configDir: configDir,
cacheDir: cacheDir,
projectRoot: projectRoot,
}, nil
}
// getUserConfigDir returns the platform-appropriate user config directory
func getUserConfigDir() (string, error) {
// Check XDG_CONFIG_HOME first (works on all platforms)
if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" {
return filepath.Join(xdgConfig, "tiki"), nil
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", ErrNoHome
}
switch runtime.GOOS {
case "darwin":
// macOS: prefer ~/.config/tiki if it exists, else ~/Library/Application Support/tiki
// Note: We only check for existence here; directory creation happens in EnsureDirs()
tikiConfigDir := filepath.Join(homeDir, ".config", "tiki")
// If ~/.config/tiki already exists, use it
if info, err := os.Stat(tikiConfigDir); err == nil && info.IsDir() {
return tikiConfigDir, nil
}
// If ~/.config exists (even without tiki subdir), prefer XDG-style
dotConfigDir := filepath.Join(homeDir, ".config")
if info, err := os.Stat(dotConfigDir); err == nil && info.IsDir() {
return tikiConfigDir, nil
}
// Fall back to macOS native location
return filepath.Join(homeDir, "Library", "Application Support", "tiki"), nil
case "windows":
// Windows: %APPDATA%\tiki
if appData := os.Getenv("APPDATA"); appData != "" {
return filepath.Join(appData, "tiki"), nil
}
return filepath.Join(homeDir, "AppData", "Roaming", "tiki"), nil
default:
// Linux and other Unix-like: ~/.config/tiki
return filepath.Join(homeDir, ".config", "tiki"), nil
}
}
// getUserCacheDir returns the platform-appropriate user cache directory
func getUserCacheDir() (string, error) {
// Check XDG_CACHE_HOME first (works on all platforms)
if xdgCache := os.Getenv("XDG_CACHE_HOME"); xdgCache != "" {
return filepath.Join(xdgCache, "tiki"), nil
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", ErrNoHome
}
switch runtime.GOOS {
case "darwin":
// macOS: ~/Library/Caches/tiki
return filepath.Join(homeDir, "Library", "Caches", "tiki"), nil
case "windows":
// Windows: %LOCALAPPDATA%\tiki
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
return filepath.Join(localAppData, "tiki"), nil
}
return filepath.Join(homeDir, "AppData", "Local", "tiki"), nil
default:
// Linux and other Unix-like: ~/.cache/tiki
return filepath.Join(homeDir, ".cache", "tiki"), nil
}
}
// getProjectRoot returns the current working directory
func getProjectRoot() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("get current directory: %w", err)
}
return cwd, nil
}
// ConfigDir returns the user config directory
func (pm *PathManager) ConfigDir() string {
return pm.configDir
}
// CacheDir returns the user cache directory
func (pm *PathManager) CacheDir() string {
return pm.cacheDir
}
// ConfigFile returns the path to the user config file
func (pm *PathManager) ConfigFile() string {
return filepath.Join(pm.configDir, "config.yaml")
}
// TaskDir returns the project-local task directory
func (pm *PathManager) TaskDir() string {
return filepath.Join(pm.projectRoot, ".doc", "tiki")
}
// DokiDir returns the project-local documentation directory
func (pm *PathManager) DokiDir() string {
return filepath.Join(pm.projectRoot, ".doc", "doki")
}
// ProjectConfigFile returns the path to the project-local config file
func (pm *PathManager) ProjectConfigFile() string {
return filepath.Join(pm.TaskDir(), "config.yaml")
}
// PluginSearchPaths returns directories to search for plugin files
// Search order: project config dir → user config dir
func (pm *PathManager) PluginSearchPaths() []string {
return []string{
pm.TaskDir(), // Project config directory (for project-specific plugins)
pm.configDir, // User config directory
}
}
// TemplateFile returns the path to the user's custom new.md template
func (pm *PathManager) TemplateFile() string {
return filepath.Join(pm.configDir, "new.md")
}
// EnsureDirs creates all necessary directories with appropriate permissions
func (pm *PathManager) EnsureDirs() error {
// Create user config directory
//nolint:gosec // G301: 0755 is appropriate for config directory
if err := os.MkdirAll(pm.configDir, 0755); err != nil {
return fmt.Errorf("create config directory %s: %w", pm.configDir, err)
}
// Create user cache directory (non-fatal if it fails)
//nolint:gosec // G301: 0755 is appropriate for cache directory
_ = os.MkdirAll(pm.cacheDir, 0755)
// Create project directories
//nolint:gosec // G301: 0755 is appropriate for task directory
if err := os.MkdirAll(pm.TaskDir(), 0755); err != nil {
return fmt.Errorf("create task directory %s: %w", pm.TaskDir(), err)
}
//nolint:gosec // G301: 0755 is appropriate for doki directory
if err := os.MkdirAll(pm.DokiDir(), 0755); err != nil {
return fmt.Errorf("create doki directory %s: %w", pm.DokiDir(), err)
}
return nil
}
// Package-level singleton with lazy initialization
var (
pathManager *PathManager
pathManagerOnce sync.Once
pathManagerErr error
pathManagerMu sync.RWMutex // Protects pathManager for reset operations
)
// getPathManager returns the global PathManager, initializing it on first call
func getPathManager() (*PathManager, error) {
pathManagerMu.RLock()
if pathManager != nil {
defer pathManagerMu.RUnlock()
return pathManager, pathManagerErr
}
pathManagerMu.RUnlock()
pathManagerMu.Lock()
defer pathManagerMu.Unlock()
// Double-check after acquiring write lock
if pathManager != nil {
return pathManager, pathManagerErr
}
pathManagerOnce.Do(func() {
pathManager, pathManagerErr = newPathManager()
})
return pathManager, pathManagerErr
}
// InitPaths initializes the path manager. Must be called early in application startup.
// Returns an error if path initialization fails (e.g., cannot determine home directory).
func InitPaths() error {
_, err := getPathManager()
if err != nil {
return fmt.Errorf("%w: %v", ErrPathManagerInit, err)
}
return nil
}
// ResetPathManager resets the path manager singleton for testing purposes.
// This allows tests to reinitialize paths with different environment variables.
func ResetPathManager() {
pathManagerMu.Lock()
defer pathManagerMu.Unlock()
pathManager = nil
pathManagerErr = nil
pathManagerOnce = sync.Once{}
}
// mustGetPathManager returns the global PathManager or panics if not initialized.
// Callers should ensure InitPaths() was called successfully before using accessor functions.
func mustGetPathManager() *PathManager {
pm, err := getPathManager()
if err != nil {
panic(fmt.Sprintf("path manager not initialized: %v (call InitPaths() first)", err))
}
return pm
}
// Exported accessor functions
// Note: These functions panic if InitPaths() has not been called successfully.
// The application should call InitPaths() early in main() and handle any error.
// GetConfigDir returns the user config directory
func GetConfigDir() string {
return mustGetPathManager().ConfigDir()
}
// GetCacheDir returns the user cache directory
func GetCacheDir() string {
return mustGetPathManager().CacheDir()
}
// GetConfigFile returns the path to the user config file
func GetConfigFile() string {
return mustGetPathManager().ConfigFile()
}
// GetTaskDir returns the project-local task directory
func GetTaskDir() string {
return mustGetPathManager().TaskDir()
}
// GetDokiDir returns the project-local documentation directory
func GetDokiDir() string {
return mustGetPathManager().DokiDir()
}
// GetProjectConfigFile returns the path to the project-local config file
func GetProjectConfigFile() string {
return mustGetPathManager().ProjectConfigFile()
}
// GetPluginSearchPaths returns directories to search for plugin files
func GetPluginSearchPaths() []string {
return mustGetPathManager().PluginSearchPaths()
}
// GetTemplateFile returns the path to the user's custom new.md template
func GetTemplateFile() string {
return mustGetPathManager().TemplateFile()
}
// EnsureDirs creates all necessary directories with appropriate permissions
func EnsureDirs() error {
return mustGetPathManager().EnsureDirs()
}

387
config/paths_test.go Normal file
View file

@ -0,0 +1,387 @@
package config
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestGetUserConfigDir(t *testing.T) {
tests := []struct {
name string
xdgConfig string
goos string
expectXDG bool
expectMacOS bool
}{
{
name: "XDG_CONFIG_HOME set",
xdgConfig: "/custom/config",
expectXDG: true,
},
{
name: "macOS without XDG",
xdgConfig: "",
goos: "darwin",
expectMacOS: true,
},
{
name: "Linux without XDG",
xdgConfig: "",
goos: "linux",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Save and restore environment
origXDG := os.Getenv("XDG_CONFIG_HOME")
defer func() {
if origXDG != "" {
_ = os.Setenv("XDG_CONFIG_HOME", origXDG)
} else {
_ = os.Unsetenv("XDG_CONFIG_HOME")
}
}()
if tt.xdgConfig != "" {
_ = os.Setenv("XDG_CONFIG_HOME", tt.xdgConfig)
} else {
_ = os.Unsetenv("XDG_CONFIG_HOME")
}
dir, err := getUserConfigDir()
if err != nil {
t.Fatalf("getUserConfigDir() error = %v", err)
}
if tt.expectXDG {
expected := filepath.Join(tt.xdgConfig, "tiki")
if dir != expected {
t.Errorf("getUserConfigDir() = %q, want %q", dir, expected)
}
} else if tt.expectMacOS && runtime.GOOS == "darwin" {
// On macOS, should contain "Library/Application Support/tiki" or ".config/tiki"
if !filepath.IsAbs(dir) {
t.Errorf("getUserConfigDir() returned non-absolute path: %q", dir)
}
if filepath.Base(dir) != "tiki" {
t.Errorf("getUserConfigDir() = %q, want basename 'tiki'", dir)
}
} else {
// Should be absolute and end with /tiki
if !filepath.IsAbs(dir) {
t.Errorf("getUserConfigDir() returned non-absolute path: %q", dir)
}
if filepath.Base(dir) != "tiki" {
t.Errorf("getUserConfigDir() = %q, want basename 'tiki'", dir)
}
}
})
}
}
func TestGetUserCacheDir(t *testing.T) {
tests := []struct {
name string
xdgCache string
expectXDG bool
}{
{
name: "XDG_CACHE_HOME set",
xdgCache: "/custom/cache",
expectXDG: true,
},
{
name: "without XDG",
xdgCache: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Save and restore environment
origXDG := os.Getenv("XDG_CACHE_HOME")
defer func() {
if origXDG != "" {
_ = os.Setenv("XDG_CACHE_HOME", origXDG)
} else {
_ = os.Unsetenv("XDG_CACHE_HOME")
}
}()
if tt.xdgCache != "" {
_ = os.Setenv("XDG_CACHE_HOME", tt.xdgCache)
} else {
_ = os.Unsetenv("XDG_CACHE_HOME")
}
dir, err := getUserCacheDir()
if err != nil {
t.Fatalf("getUserCacheDir() error = %v", err)
}
if tt.expectXDG {
expected := filepath.Join(tt.xdgCache, "tiki")
if dir != expected {
t.Errorf("getUserCacheDir() = %q, want %q", dir, expected)
}
} else {
// Should be absolute and end with /tiki
if !filepath.IsAbs(dir) {
t.Errorf("getUserCacheDir() returned non-absolute path: %q", dir)
}
if filepath.Base(dir) != "tiki" {
t.Errorf("getUserCacheDir() = %q, want basename 'tiki'", dir)
}
}
})
}
}
func TestGetProjectRoot(t *testing.T) {
root, err := getProjectRoot()
if err != nil {
t.Fatalf("getProjectRoot() error = %v", err)
}
if !filepath.IsAbs(root) {
t.Errorf("getProjectRoot() = %q, want absolute path", root)
}
// Verify the directory exists
if _, err := os.Stat(root); err != nil {
t.Errorf("getProjectRoot() returned path that doesn't exist: %v", err)
}
}
func TestPathManagerPaths(t *testing.T) {
pm, err := newPathManager()
if err != nil {
t.Fatalf("newPathManager() error = %v", err)
}
tests := []struct {
name string
getter func() string
want string
}{
{
name: "ConfigDir",
getter: pm.ConfigDir,
},
{
name: "CacheDir",
getter: pm.CacheDir,
},
{
name: "ConfigFile",
getter: pm.ConfigFile,
},
{
name: "TaskDir",
getter: pm.TaskDir,
},
{
name: "DokiDir",
getter: pm.DokiDir,
},
{
name: "ProjectConfigFile",
getter: pm.ProjectConfigFile,
},
{
name: "TemplateFile",
getter: pm.TemplateFile,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.getter()
if result == "" {
t.Errorf("%s() returned empty string", tt.name)
}
if !filepath.IsAbs(result) {
t.Errorf("%s() = %q, want absolute path", tt.name, result)
}
})
}
}
func TestPathManagerPluginSearchPaths(t *testing.T) {
pm, err := newPathManager()
if err != nil {
t.Fatalf("newPathManager() error = %v", err)
}
paths := pm.PluginSearchPaths()
if len(paths) != 2 {
t.Errorf("PluginSearchPaths() returned %d paths, want 2", len(paths))
}
// First should be project config dir (TaskDir)
if paths[0] != pm.TaskDir() {
t.Errorf("PluginSearchPaths()[0] = %q, want %q", paths[0], pm.TaskDir())
}
// Second should be user config dir
if paths[1] != pm.ConfigDir() {
t.Errorf("PluginSearchPaths()[1] = %q, want %q", paths[1], pm.ConfigDir())
}
// All paths should be absolute
for i, path := range paths {
if !filepath.IsAbs(path) {
t.Errorf("PluginSearchPaths()[%d] = %q, want absolute path", i, path)
}
}
}
func TestPathManagerEnsureDirs(t *testing.T) {
// Create a temporary directory for testing
tmpDir := t.TempDir()
// Create a PathManager with temporary paths
pm := &PathManager{
configDir: filepath.Join(tmpDir, "config"),
cacheDir: filepath.Join(tmpDir, "cache"),
projectRoot: tmpDir,
}
// Call EnsureDirs
if err := pm.EnsureDirs(); err != nil {
t.Fatalf("EnsureDirs() error = %v", err)
}
// Verify directories were created
dirs := []string{
pm.ConfigDir(),
pm.CacheDir(),
pm.TaskDir(),
pm.DokiDir(),
}
for _, dir := range dirs {
info, err := os.Stat(dir)
if err != nil {
t.Errorf("directory %q was not created: %v", dir, err)
continue
}
if !info.IsDir() {
t.Errorf("%q is not a directory", dir)
}
// Check permissions (should be 0755) - skip on Windows as it uses ACL-based permissions
if runtime.GOOS != "windows" {
if info.Mode().Perm() != 0755 {
t.Errorf("directory %q has permissions %o, want 0755", dir, info.Mode().Perm())
}
}
}
}
func TestGlobalAccessorFunctions(t *testing.T) {
// Test that all global accessor functions return non-empty absolute paths
tests := []struct {
name string
getter func() string
}{
{"GetConfigDir", GetConfigDir},
{"GetCacheDir", GetCacheDir},
{"GetConfigFile", GetConfigFile},
{"GetTaskDir", GetTaskDir},
{"GetDokiDir", GetDokiDir},
{"GetProjectConfigFile", GetProjectConfigFile},
{"GetTemplateFile", GetTemplateFile},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.getter()
if result == "" {
t.Errorf("%s() returned empty string", tt.name)
}
if !filepath.IsAbs(result) {
t.Errorf("%s() = %q, want absolute path", tt.name, result)
}
})
}
}
func TestGetPluginSearchPaths(t *testing.T) {
paths := GetPluginSearchPaths()
if len(paths) != 2 {
t.Errorf("GetPluginSearchPaths() returned %d paths, want 2", len(paths))
}
for i, path := range paths {
if path == "" {
t.Errorf("GetPluginSearchPaths()[%d] is empty", i)
}
if !filepath.IsAbs(path) {
t.Errorf("GetPluginSearchPaths()[%d] = %q, want absolute path", i, path)
}
}
}
func TestInitPaths(t *testing.T) {
// Reset to test initialization
ResetPathManager()
defer ResetPathManager() // Clean up after test
err := InitPaths()
if err != nil {
t.Fatalf("InitPaths() error = %v", err)
}
// After InitPaths, all accessors should work
if GetConfigDir() == "" {
t.Error("GetConfigDir() returned empty after InitPaths()")
}
if GetTaskDir() == "" {
t.Error("GetTaskDir() returned empty after InitPaths()")
}
}
func TestResetPathManager(t *testing.T) {
// Save original XDG
origXDG := os.Getenv("XDG_CONFIG_HOME")
defer func() {
if origXDG != "" {
_ = os.Setenv("XDG_CONFIG_HOME", origXDG)
} else {
_ = os.Unsetenv("XDG_CONFIG_HOME")
}
ResetPathManager() // Clean up
}()
// First initialization
ResetPathManager()
_ = os.Setenv("XDG_CONFIG_HOME", "/first/config")
if err := InitPaths(); err != nil {
t.Fatalf("first InitPaths() error = %v", err)
}
first := GetConfigDir()
expected1 := filepath.Join("/first/config", "tiki")
if first != expected1 {
t.Errorf("first GetConfigDir() = %q, want %q", first, expected1)
}
// Reset and reinitialize with different env
ResetPathManager()
_ = os.Setenv("XDG_CONFIG_HOME", "/second/config")
if err := InitPaths(); err != nil {
t.Fatalf("second InitPaths() error = %v", err)
}
second := GetConfigDir()
expected2 := filepath.Join("/second/config", "tiki")
if second != expected2 {
t.Errorf("second GetConfigDir() = %q, want %q", second, expected2)
}
// Verify they're different (reset worked)
if first == second {
t.Error("ResetPathManager() did not allow re-initialization with different config")
}
}

View file

@ -37,16 +37,16 @@ func GenerateRandomID() string {
// BootstrapSystem creates the task storage and seeds the initial tiki.
func BootstrapSystem() error {
//nolint:gosec // G301: 0755 is appropriate for task directory
if err := os.MkdirAll(TaskDir, 0755); err != nil {
return fmt.Errorf("create task directory: %w", err)
// Create all necessary directories
if err := EnsureDirs(); err != nil {
return fmt.Errorf("ensure directories: %w", err)
}
// Generate random ID for initial task
randomID := GenerateRandomID()
taskID := fmt.Sprintf("TIKI-%s", randomID)
taskFilename := fmt.Sprintf("tiki-%s.md", randomID)
taskPath := filepath.Join(TaskDir, taskFilename)
taskPath := filepath.Join(GetTaskDir(), taskFilename)
// Replace placeholder in template
taskContent := strings.Replace(initialTaskTemplate, "TIKI-XXXXXX", taskID, 1)
@ -54,13 +54,8 @@ func BootstrapSystem() error {
return fmt.Errorf("write initial task: %w", err)
}
// Create doki directory and documentation files
dokiDir := filepath.Join(".doc", "doki")
//nolint:gosec // G301: 0755 is appropriate for doki documentation directory
if err := os.MkdirAll(dokiDir, 0755); err != nil {
return fmt.Errorf("create doki directory: %w", err)
}
// Write doki documentation files
dokiDir := GetDokiDir()
indexPath := filepath.Join(dokiDir, "index.md")
if err := os.WriteFile(indexPath, []byte(dokiEntryPoint), 0644); err != nil {
return fmt.Errorf("write doki index: %w", err)

View file

@ -202,7 +202,7 @@ func (tc *TaskController) handleEditSource() bool {
// Construct the file path for this task
filename := strings.ToLower(task.ID) + ".md"
filePath := filepath.Join(config.TaskDir, filename)
filePath := filepath.Join(config.GetTaskDir(), filename)
// Suspend the tview app and open the editor
tc.navController.SuspendAndEdit(filePath)

View file

@ -1,8 +1,7 @@
package app
import (
"log/slog"
"os"
"fmt"
"github.com/rivo/tview"
@ -14,11 +13,12 @@ func NewApp() *tview.Application {
return tview.NewApplication()
}
// Run runs the tview application or terminates the process if it errors.
func Run(app *tview.Application, rootLayout *view.RootLayout) {
// Run runs the tview application.
// Returns an error if the application fails to run.
func Run(app *tview.Application, rootLayout *view.RootLayout) error {
app.SetRoot(rootLayout.GetPrimitive(), true).EnableMouse(false)
if err := app.Run(); err != nil {
slog.Error("application error", "error", err)
os.Exit(1)
return fmt.Errorf("run application: %w", err)
}
return nil
}

View file

@ -33,7 +33,7 @@ func StartBurndownHistoryBuilder(
return
}
history := store.NewTaskHistory(config.TaskDir, gitUtil)
history := store.NewTaskHistory(config.GetTaskDir(), gitUtil)
if history == nil {
return
}

View file

@ -1,17 +1,17 @@
package bootstrap
import (
"log"
"fmt"
"github.com/boolean-maybe/tiki/config"
)
// LoadConfigOrExit loads the application configuration.
// If configuration loading fails, it logs a fatal error and exits.
func LoadConfigOrExit() *config.Config {
// LoadConfig loads the application configuration.
// Returns an error if configuration loading fails.
func LoadConfig() (*config.Config, error) {
cfg, err := config.LoadConfig()
if err != nil {
log.Fatalf("failed to load configuration: %v", err)
return nil, fmt.Errorf("load configuration: %w", err)
}
return cfg
return cfg, nil
}

View file

@ -1,21 +1,19 @@
package bootstrap
import (
"fmt"
"os"
"errors"
"github.com/boolean-maybe/tiki/store/tikistore"
)
// EnsureGitRepoOrExit validates that the current directory is a git repository.
// If not, it prints an error message and exits the program.
func EnsureGitRepoOrExit() {
// ErrNotGitRepo indicates the current directory is not a git repository
var ErrNotGitRepo = errors.New("not a git repository")
// EnsureGitRepo validates that the current directory is a git repository.
// Returns ErrNotGitRepo if the current directory is not a git repository.
func EnsureGitRepo() error {
if tikistore.IsGitRepo("") {
return
return nil
}
_, err := fmt.Fprintln(os.Stderr, "Not a git repository")
if err != nil {
return
}
os.Exit(1)
return ErrNotGitRepo
}

View file

@ -45,20 +45,31 @@ type BootstrapResult struct {
// It takes the embedded AI skill content and returns all initialized components.
func Bootstrap(tikiSkillContent, dokiSkillContent string) (*BootstrapResult, error) {
// Phase 1: Pre-flight checks
EnsureGitRepoOrExit()
if err := EnsureGitRepo(); err != nil {
return nil, err
}
// Phase 2: Configuration and logging
cfg := LoadConfigOrExit()
cfg, err := LoadConfig()
if err != nil {
return nil, err
}
logLevel := InitLogging(cfg)
// Phase 3: Project initialization
proceed := EnsureProjectInitialized(tikiSkillContent, dokiSkillContent)
proceed, err := EnsureProjectInitialized(tikiSkillContent, dokiSkillContent)
if err != nil {
return nil, err
}
if !proceed {
return nil, nil // User chose not to proceed
}
// Phase 4: Store initialization
tikiStore, taskStore := InitStores()
tikiStore, taskStore, err := InitStores()
if err != nil {
return nil, err
}
// Phase 5: Model initialization
headerConfig, layoutModel := InitHeaderAndLayoutModels()

View file

@ -1,20 +1,18 @@
package bootstrap
import (
"log/slog"
"os"
"fmt"
"github.com/boolean-maybe/tiki/config"
)
// EnsureProjectInitialized ensures the project is properly initialized.
// It takes the embedded skill content for tiki and doki and returns whether to proceed.
// If initialization fails, it logs an error and exits.
func EnsureProjectInitialized(tikiSkillContent, dokiSkillContent string) (proceed bool) {
// It takes the embedded skill content for tiki and doki.
// Returns (proceed, error) where proceed indicates if the user wants to continue.
func EnsureProjectInitialized(tikiSkillContent, dokiSkillContent string) (bool, error) {
proceed, err := config.EnsureProjectInitialized(tikiSkillContent, dokiSkillContent)
if err != nil {
slog.Error("failed to initialize project", "error", err)
os.Exit(1)
return false, fmt.Errorf("initialize project: %w", err)
}
return proceed
return proceed, nil
}

View file

@ -1,21 +1,19 @@
package bootstrap
import (
"log/slog"
"os"
"fmt"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/store/tikistore"
)
// InitStores initializes the task stores or terminates the process on failure.
// Returns the tikiStore and a generic store interface reference to it.
func InitStores() (*tikistore.TikiStore, store.Store) {
tikiStore, err := tikistore.NewTikiStore(config.TaskDir)
// InitStores initializes the task stores.
// Returns the tikiStore, a generic store interface, and any error.
func InitStores() (*tikistore.TikiStore, store.Store, error) {
tikiStore, err := tikistore.NewTikiStore(config.GetTaskDir())
if err != nil {
slog.Error("failed to initialize task store", "error", err)
os.Exit(1)
return nil, nil, fmt.Errorf("initialize task store: %w", err)
}
return tikiStore, tikiStore
return tikiStore, tikiStore, nil
}

14
main.go
View file

@ -28,6 +28,12 @@ func main() {
os.Exit(0)
}
// Initialize paths early - this must succeed for the application to function
if err := config.InitPaths(); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
// Handle viewer mode (standalone markdown viewer)
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{})
if err != nil {
@ -49,7 +55,8 @@ func main() {
// Bootstrap application
result, err := bootstrap.Bootstrap(tikiSkillMdContent, dokiSkillMdContent)
if err != nil {
return
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
if result == nil {
// User chose not to proceed with project initialization
@ -63,7 +70,10 @@ func main() {
defer result.CancelFunc()
// Run application
app.Run(result.App, result.RootLayout)
if err := app.Run(result.App, result.RootLayout); err != nil {
slog.Error("application error", "error", err)
os.Exit(1)
}
// Save user preferences on shutdown
if err := config.SaveHeaderVisible(result.HeaderConfig.GetUserPreference()); err != nil {

View file

@ -3,18 +3,35 @@ package plugin
import (
"os"
"path/filepath"
"github.com/boolean-maybe/tiki/config"
)
// findPluginFile searches for the plugin file in various locations
func findPluginFile(filename string, baseDir string) string {
// List of directories to search
searchPaths := []string{
filename, // As provided (absolute or relative)
filepath.Join(".", filename), // Current directory
filepath.Join(baseDir, filename), // Binary directory
// Search order: absolute path → project config dir → user config dir
func findPluginFile(filename string) string {
// If filename is absolute, try it directly
if filepath.IsAbs(filename) {
if _, err := os.Stat(filename); err == nil {
return filename
}
return ""
}
for _, path := range searchPaths {
// Get search paths from PathManager
// Search order: project config dir → user config dir
searchPaths := config.GetPluginSearchPaths()
// Build full list of paths to check
var paths []string
paths = append(paths, filename) // Try as-is first (relative to cwd)
for _, dir := range searchPaths {
paths = append(paths, filepath.Join(dir, filename))
}
// Search for the file
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
return path
}

View file

@ -34,35 +34,24 @@ func TestFindPluginFile(t *testing.T) {
tests := []struct {
name string
filename string
baseDir string
wantPath string
wantFound bool
}{
{
name: "absolute path",
filename: testFilePath,
baseDir: "",
wantPath: testFilePath,
wantFound: true,
},
{
name: "relative path in current dir",
filename: testFile,
baseDir: "",
wantPath: testFile,
wantFound: true,
},
{
name: "file in base directory",
filename: testFile,
baseDir: currentDir,
wantPath: testFile,
wantFound: true,
},
{
name: "non-existent file",
filename: "nonexistent.yaml",
baseDir: currentDir,
wantPath: "",
wantFound: false,
},
@ -70,12 +59,12 @@ func TestFindPluginFile(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := findPluginFile(tt.filename, tt.baseDir)
got := findPluginFile(tt.filename)
if tt.wantFound {
if got == "" {
t.Errorf("findPluginFile(%q, %q) = empty, want non-empty path",
tt.filename, tt.baseDir)
t.Errorf("findPluginFile(%q) = empty, want non-empty path",
tt.filename)
}
// Verify the file exists at the returned path
if _, err := os.Stat(got); err != nil {
@ -84,8 +73,8 @@ func TestFindPluginFile(t *testing.T) {
}
} else {
if got != "" {
t.Errorf("findPluginFile(%q, %q) = %q, want empty string",
tt.filename, tt.baseDir, got)
t.Errorf("findPluginFile(%q) = %q, want empty string",
tt.filename, got)
}
}
})
@ -125,8 +114,8 @@ func TestFindPluginFile_SearchOrder(t *testing.T) {
t.Fatalf("Failed to change to temp directory: %v", err)
}
// Test that current directory is preferred over base directory
got := findPluginFile(testFile, subDir)
// Test that current directory is preferred in search order
got := findPluginFile(testFile)
if got == "" {
t.Fatal("findPluginFile returned empty path")
}

View file

@ -4,7 +4,6 @@ import (
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
@ -24,12 +23,6 @@ func loadConfiguredPlugins() []Plugin {
return nil // no plugins configured
}
// Determine base directory (where binary is, or current dir for development)
baseDir := ""
if exePath, err := os.Executable(); err == nil {
baseDir = filepath.Dir(exePath)
}
var plugins []Plugin
for i, ref := range refs {
@ -39,7 +32,7 @@ func loadConfiguredPlugins() []Plugin {
continue
}
plugin, err := loadPluginFromRef(ref, baseDir)
plugin, err := loadPluginFromRef(ref)
if err != nil {
slog.Warn("failed to load plugin", "name", ref.Name, "file", ref.File, "error", err)
continue // Skip failed plugins, continue with others
@ -110,13 +103,13 @@ func LoadPlugins() ([]Plugin, error) {
// 1. Fully inline (no file): all fields in config.yaml
// 2. File-based (file only): reference external YAML
// 3. Hybrid (file + overrides): file provides base, inline overrides
func loadPluginFromRef(ref PluginRef, baseDir string) (Plugin, error) {
func loadPluginFromRef(ref PluginRef) (Plugin, error) {
var cfg pluginFileConfig
var source string
if ref.File != "" {
// File-based or hybrid mode
pluginPath := findPluginFile(ref.File, baseDir)
pluginPath := findPluginFile(ref.File)
if pluginPath == "" {
return nil, fmt.Errorf("plugin file not found: %s", ref.File)
}

View file

@ -22,7 +22,7 @@ func TestLoadPluginFromRef_FullyInline(t *testing.T) {
View: "expanded",
}
def, err := loadPluginFromRef(ref, "")
def, err := loadPluginFromRef(ref)
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@ -71,7 +71,7 @@ func TestLoadPluginFromRef_InlineMinimal(t *testing.T) {
},
}
def, err := loadPluginFromRef(ref, "")
def, err := loadPluginFromRef(ref)
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@ -109,10 +109,10 @@ view: compact
}
ref := PluginRef{
File: "test-plugin.yaml",
File: pluginFile, // Use absolute path
}
def, err := loadPluginFromRef(ref, tmpDir)
def, err := loadPluginFromRef(ref)
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@ -155,12 +155,12 @@ view: compact
// Override view and key
ref := PluginRef{
File: "base-plugin.yaml",
File: pluginFile, // Use absolute path
View: "expanded",
Key: "H",
}
def, err := loadPluginFromRef(ref, tmpDir)
def, err := loadPluginFromRef(ref)
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@ -209,7 +209,7 @@ view: compact
// Override multiple fields
ref := PluginRef{
File: "multi-plugin.yaml",
File: pluginFile, // Use absolute path
Key: "X",
Panes: []PluginPaneConfig{
{Name: "In Progress", Filter: "status = 'in_progress'"},
@ -219,7 +219,7 @@ view: compact
Foreground: "#00ff00",
}
def, err := loadPluginFromRef(ref, tmpDir)
def, err := loadPluginFromRef(ref)
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@ -264,7 +264,7 @@ func TestLoadPluginFromRef_MissingFile(t *testing.T) {
File: "nonexistent.yaml",
}
_, err := loadPluginFromRef(ref, "")
_, err := loadPluginFromRef(ref)
if err == nil {
t.Fatal("Expected error for missing file")
}
@ -282,7 +282,7 @@ func TestLoadPluginFromRef_NoName(t *testing.T) {
},
}
_, err := loadPluginFromRef(ref, "")
_, err := loadPluginFromRef(ref)
if err == nil {
t.Fatal("Expected error for plugin without name")
}
@ -303,7 +303,7 @@ func TestPluginTypeExplicit(t *testing.T) {
Text: "some text",
}
def, err := loadPluginFromRef(ref, "")
def, err := loadPluginFromRef(ref)
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@ -329,10 +329,10 @@ url: http://example.com/resource
}
refFile := PluginRef{
File: "type-doki.yaml",
File: pluginFile, // Use absolute path
}
defFile, err := loadPluginFromRef(refFile, tmpDir)
defFile, err := loadPluginFromRef(refFile)
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@ -358,7 +358,7 @@ type: tiki
}
ref := PluginRef{
File: "type-override.yaml",
File: pluginFile, // Use absolute path
Type: "doki",
Fetcher: "internal",
Text: "override text",
@ -369,7 +369,7 @@ type: tiki
// parsePluginConfig then creates the struct.
// So this test checks mergePluginConfigs logic + parsing logic.
def, err := loadPluginFromRef(ref, tmpDir)
def, err := loadPluginFromRef(ref)
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
@ -382,7 +382,3 @@ type: tiki
t.Errorf("Expected DokiPlugin type assertion to succeed")
}
}
// Tests for parser functions moved to parser_test.go
// Tests for key parsing moved to keyparser_test.go
// Tests for merger functions moved to merger_test.go

View file

@ -81,7 +81,7 @@ func TestDokiValidation(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := loadPluginFromRef(tc.ref, "")
_, err := loadPluginFromRef(tc.ref)
if tc.wantError != "" {
if err == nil {
t.Errorf("Expected error containing '%s', got nil", tc.wantError)
@ -127,7 +127,7 @@ func TestTikiValidation(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := loadPluginFromRef(tc.ref, "")
_, err := loadPluginFromRef(tc.ref)
if tc.wantError != "" {
if err == nil {
t.Errorf("Expected error containing '%s', got nil", tc.wantError)

View file

@ -4,7 +4,6 @@ import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
@ -25,22 +24,15 @@ type templateFrontmatter struct {
Points int `yaml:"points"`
}
// loadTemplateTask reads new.md next to the executable, or falls back to embedded template.
// loadTemplateTask reads new.md from user config directory, or falls back to embedded template.
func loadTemplateTask() *taskpkg.Task {
// Try to load from binary directory first
exePath, err := os.Executable()
if err != nil {
slog.Warn("failed to get executable path for template", "error", err)
return loadEmbeddedTemplate()
}
binaryDir := filepath.Dir(exePath)
templatePath := filepath.Join(binaryDir, "new.md")
// Try to load from user config directory first
templatePath := config.GetTemplateFile()
data, err := os.ReadFile(templatePath)
if err != nil {
if os.IsNotExist(err) {
slog.Debug("new.md not found in binary dir, using embedded template")
slog.Debug("new.md not found in user config dir, using embedded template", "path", templatePath)
return loadEmbeddedTemplate()
}
slog.Warn("failed to read new.md template", "path", templatePath, "error", err)

View file

@ -79,7 +79,7 @@ func (dv *DokiView) build() {
switch dv.pluginDef.Fetcher {
case "file":
searchRoots := []string{config.GetDokiRoot()}
searchRoots := []string{config.GetDokiDir()}
provider := &loaders.FileHTTP{SearchRoots: searchRoots}
// Fetch initial content (no source context yet; rely on searchRoots)
@ -149,7 +149,7 @@ func (dv *DokiView) build() {
// Display initial content with source context (don't push to history - this is the first page)
if dv.pluginDef.Fetcher == "file" {
// Try to resolve the initial URL so subsequent relative navigation has a stable source path.
sourcePath, rerr := nav.ResolveMarkdownPath(dv.pluginDef.URL, "", []string{config.GetDokiRoot()})
sourcePath, rerr := nav.ResolveMarkdownPath(dv.pluginDef.URL, "", []string{config.GetDokiDir()})
if rerr != nil || sourcePath == "" {
sourcePath = dv.pluginDef.URL
}