tiki/config/init.go
2026-04-16 15:35:28 -04:00

271 lines
8.3 KiB
Go

package config
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
// IsProjectInitialized returns true if the project has been initialized
// (i.e., the .doc/tiki directory exists).
func IsProjectInitialized() bool {
taskDir := GetTaskDir()
info, err := os.Stat(taskDir)
if err != nil {
return false
}
return info.IsDir()
}
// InitOptions holds user choices from the init dialog.
type InitOptions struct {
AITools []string
SampleTasks bool
}
// PromptForProjectInit presents a Huh form for project initialization.
// Returns (options, proceed, error)
func PromptForProjectInit() (InitOptions, bool, error) {
var opts InitOptions
opts.SampleTasks = true // default enabled
// Create custom theme with brighter description and help text
theme := huh.ThemeCharm()
descriptionColor := lipgloss.Color("189") // Light purple/blue for description
helpKeyColor := lipgloss.Color("117") // Light blue for keys
helpDescColor := lipgloss.Color("252") // Bright gray for descriptions
theme.Focused.Description = lipgloss.NewStyle().Foreground(descriptionColor)
theme.Blurred.Description = lipgloss.NewStyle().Foreground(descriptionColor)
theme.Help.ShortKey = lipgloss.NewStyle().Foreground(helpKeyColor).Bold(true)
theme.Help.ShortDesc = lipgloss.NewStyle().Foreground(helpDescColor)
theme.Help.ShortSeparator = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
theme.Help.FullKey = lipgloss.NewStyle().Foreground(helpKeyColor).Bold(true)
theme.Help.FullDesc = lipgloss.NewStyle().Foreground(helpDescColor)
theme.Help.FullSeparator = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
// Create custom keymap with Esc bound to quit
keymap := huh.NewDefaultKeyMap()
keymap.Quit = key.NewBinding(
key.WithKeys("esc", "ctrl+c"),
key.WithHelp("esc", "quit"),
)
description := `
This will initialize your project by creating directories and sample tiki files:
- .doc/doki directory to hold your Markdown documents
- .doc/tiki directory to hold your tasks
- a sample tiki task and a document
Additionally, optional AI skills are installed if you choose to
AI skills extend your AI assistant with commands to manage tasks and documentation:
• 'tiki' skill - Create, view, update, delete task tickets (.doc/tiki/*.md)
• 'doki' skill - Create and manage documentation files (.doc/doki/*.md)
Select AI assistants to install (optional), then press Enter to continue.
Press Esc to cancel project initialization.`
aiOptions := make([]huh.Option[string], 0, len(AITools()))
for _, t := range AITools() {
aiOptions = append(aiOptions, huh.NewOption(fmt.Sprintf("%s (%s/)", t.DisplayName, t.SkillDir), t.Key))
}
form := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title("Initialize project").
Description(description).
Options(aiOptions...).
Filterable(false).
Value(&opts.AITools),
huh.NewConfirm().
Title("Create sample tasks").
Description("Seed project with example tikis (incompatible samples are skipped automatically)").
Value(&opts.SampleTasks),
),
).WithTheme(theme).
WithKeyMap(keymap).
WithProgramOptions(tea.WithAltScreen())
err := form.Run()
if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return InitOptions{}, false, nil
}
return InitOptions{}, false, fmt.Errorf("form error: %w", err)
}
return opts, true, nil
}
// EnsureProjectInitialized bootstraps the project if .doc/tiki is missing.
// Returns (proceed, error).
// If proceed is false, the user canceled initialization.
func EnsureProjectInitialized(tikiSkillMdContent, dokiSkillMdContent string) (bool, error) {
taskDir := GetTaskDir()
if _, err := os.Stat(taskDir); err != nil {
if !os.IsNotExist(err) {
return false, fmt.Errorf("failed to stat task directory: %w", err)
}
opts, proceed, err := PromptForProjectInit()
if err != nil {
return false, fmt.Errorf("failed to prompt for project initialization: %w", err)
}
if !proceed {
return false, nil
}
if err := BootstrapSystem(opts.SampleTasks); err != nil {
return false, fmt.Errorf("failed to bootstrap project: %w", err)
}
// Install selected AI skills
if len(opts.AITools) > 0 {
if err := installAISkills(opts.AITools, tikiSkillMdContent, dokiSkillMdContent); err != nil {
// Non-fatal - log warning but continue
slog.Warn("some AI skills failed to install", "error", err)
fmt.Println("You can manually copy ai/skills/tiki/SKILL.md and ai/skills/doki/SKILL.md to the appropriate directories.")
} else {
fmt.Printf("✓ Installed AI skills for: %s\n", strings.Join(opts.AITools, ", "))
}
}
return true, nil
}
return true, nil
}
// installAISkills writes the embedded SKILL.md content to selected AI tool directories.
// Returns an aggregated error if any installations fail, but continues attempting all.
func installAISkills(selectedTools []string, tikiSkillMdContent, dokiSkillMdContent string) error {
if len(tikiSkillMdContent) == 0 {
return fmt.Errorf("embedded tiki SKILL.md content is empty")
}
if len(dokiSkillMdContent) == 0 {
return fmt.Errorf("embedded doki SKILL.md content is empty")
}
type skillDef struct {
name string
content string
}
skills := []skillDef{
{"tiki", tikiSkillMdContent},
{"doki", dokiSkillMdContent},
}
skillNames := make([]string, len(skills))
for i, s := range skills {
skillNames[i] = s.name
}
var errs []error
for _, toolKey := range selectedTools {
tool, ok := LookupAITool(toolKey)
if !ok {
errs = append(errs, fmt.Errorf("unknown tool: %s", toolKey))
continue
}
for _, skill := range skills {
path := tool.SkillPath(skill.name)
dir := filepath.Dir(path)
//nolint:gosec // G301: 0755 is appropriate for user-owned skill directories
if err := os.MkdirAll(dir, 0755); err != nil {
errs = append(errs, fmt.Errorf("failed to create %s directory for %s: %w", skill.name, toolKey, err))
} else if err := os.WriteFile(path, []byte(skill.content), 0644); err != nil {
errs = append(errs, fmt.Errorf("failed to write %s SKILL.md for %s: %w", skill.name, toolKey, err))
} else {
slog.Info("installed AI skill", "tool", toolKey, "skill", skill.name, "path", path)
}
}
if tool.SettingsFile != "" {
if err := ensureSkillPermissions(tool.SettingsFile, skillNames); err != nil {
errs = append(errs, fmt.Errorf("failed to update %s settings: %w", toolKey, err))
}
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
func skillPermissionEntry(name string) string {
return fmt.Sprintf("Skill(%s)", name)
}
// ensureSkillPermissions creates or updates a settings file to include
// Skill(<name>) entries in permissions.allow for each given skill name.
// Existing permissions and other top-level keys are preserved.
func ensureSkillPermissions(settingsPath string, skillNames []string) error {
settings := make(map[string]any)
data, err := os.ReadFile(settingsPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("reading %s: %w", settingsPath, err)
}
if len(data) > 0 {
if err := json.Unmarshal(data, &settings); err != nil {
return fmt.Errorf("parsing %s: %w", settingsPath, err)
}
}
perms, _ := settings["permissions"].(map[string]any)
if perms == nil {
perms = make(map[string]any)
settings["permissions"] = perms
}
allowRaw, _ := perms["allow"].([]any)
existing := make(map[string]bool, len(allowRaw))
for _, v := range allowRaw {
if s, ok := v.(string); ok {
existing[s] = true
}
}
changed := false
for _, name := range skillNames {
entry := skillPermissionEntry(name)
if !existing[entry] {
allowRaw = append(allowRaw, entry)
changed = true
}
}
if !changed {
return nil
}
perms["allow"] = allowRaw
out, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return fmt.Errorf("marshaling settings: %w", err)
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0750); err != nil {
return fmt.Errorf("creating directory for %s: %w", settingsPath, err)
}
//nolint:gosec // G306: 0644 is appropriate for user settings files
if err := os.WriteFile(settingsPath, append(out, '\n'), 0644); err != nil {
return fmt.Errorf("writing %s: %w", settingsPath, err)
}
slog.Info("updated Claude settings", "path", settingsPath, "skills", skillNames)
return nil
}