diff --git a/cmd/recipe.go b/cmd/recipe.go new file mode 100644 index 0000000..cafec12 --- /dev/null +++ b/cmd/recipe.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/castai/kimchi/internal/tui" +) + +func NewRecipeCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "recipe", + Short: "Manage kimchi recipes", + } + cmd.AddCommand(NewRecipeExportCommand()) + return cmd +} + +func NewRecipeExportCommand() *cobra.Command { + var outputPath string + + cmd := &cobra.Command{ + Use: "export", + Short: "Export your current AI tool configuration as a portable recipe file", + RunE: func(cmd *cobra.Command, args []string) error { + return tui.RunExportWizard(outputPath) + }, + } + + cmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output file path (default: prompted in wizard)") + + return cmd +} diff --git a/cmd/root.go b/cmd/root.go index 6eca657..f7b1536 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -60,6 +60,7 @@ Get your API key at: https://kimchi.console.cast.ai`, root.AddCommand(NewVersionCommand()) root.AddCommand(NewCompletionCommand()) root.AddCommand(NewUpdateCommand()) + root.AddCommand(NewRecipeCommand()) return root } diff --git a/internal/recipe/builder.go b/internal/recipe/builder.go new file mode 100644 index 0000000..b862564 --- /dev/null +++ b/internal/recipe/builder.go @@ -0,0 +1,145 @@ +package recipe + +import ( + "strings" +) + +// ExportOptions carries the user's choices from the TUI wizard. +type ExportOptions struct { + Name string + Author string + Description string + UseCase string + + IncludeAgentsMD bool + IncludeSkills bool + IncludeCustomCommands bool + IncludeAgents bool + IncludeTUI bool + IncludeThemeFiles bool + IncludePluginFiles bool + IncludeToolFiles bool +} + +// Build assembles a Recipe from OpenCode assets and the user's export options. +// Secrets in provider and MCP configs are replaced with placeholder strings. +func Build(assets *OpenCodeAssets, opts ExportOptions) (*Recipe, error) { + cfg := assets.Config + + // Use the model from config as-is (e.g. "kimchi/kimi-k2.5" or "openai/gpt-4o"). + model := strField(cfg, "model") + + // Strip provider prefix for the recipe's top-level model field (human-readable slug). + displaySlug := model + if parts := strings.SplitN(model, "/", 2); len(parts) == 2 { + displaySlug = parts[1] + } + + ocCfg := &OpenCodeConfig{ + // Provider / model + Providers: mapField(cfg, "provider"), + Model: model, + SmallModel: strField(cfg, "small_model"), + DefaultAgent: strField(cfg, "default_agent"), + DisabledProviders: strSliceField(cfg, "disabled_providers"), + EnabledProviders: strSliceField(cfg, "enabled_providers"), + Plugin: strSliceField(cfg, "plugin"), + Snapshot: boolPtrField(cfg, "snapshot"), + + // Portable instruction URLs (local paths/globs are machine-specific and excluded) + Instructions: filterURLInstructions(cfg), + + // Behavior + Compaction: mapField(cfg, "compaction"), + AgentConfigs: mapField(cfg, "agent"), + MCP: mapField(cfg, "mcp"), + Permission: cfg["permission"], + Tools: mapField(cfg, "tools"), + Experimental: mapField(cfg, "experimental"), + Formatter: cfg["formatter"], + LSP: cfg["lsp"], + InlineCommands: mapField(cfg, "command"), + } + + if opts.IncludeAgentsMD { + ocCfg.AgentsMD = assets.AgentsMD + } + if opts.IncludeSkills { + ocCfg.Skills = assets.Skills + } + if opts.IncludeCustomCommands { + ocCfg.CustomCommands = assets.CustomCommands + } + if opts.IncludeAgents { + ocCfg.Agents = assets.Agents + } + if opts.IncludeTUI { + ocCfg.TUI = assets.TUI + } + if opts.IncludeThemeFiles { + ocCfg.ThemeFiles = assets.ThemeFiles + } + if opts.IncludePluginFiles { + ocCfg.PluginFiles = assets.PluginFiles + } + if opts.IncludeToolFiles { + ocCfg.ToolFiles = assets.ToolFiles + } + // Include files that are @-referenced from any selected markdown content. + if opts.IncludeAgentsMD || opts.IncludeSkills || opts.IncludeCustomCommands || opts.IncludeAgents { + ocCfg.ReferencedFiles = assets.ReferencedFiles + } + + r := &Recipe{ + Name: opts.Name, + Author: opts.Author, + Description: opts.Description, + Model: displaySlug, + UseCase: opts.UseCase, + Version: "1", + Tools: ToolsMap{ + OpenCode: ocCfg, + }, + } + + return r, nil +} + +// mapField extracts a map[string]any from cfg[key], returning nil if absent or wrong type. +func mapField(cfg map[string]any, key string) map[string]any { + v, ok := cfg[key].(map[string]any) + if !ok { + return nil + } + return v +} + +// strField extracts a string from cfg[key], returning "" if absent or wrong type. +func strField(cfg map[string]any, key string) string { + v, _ := cfg[key].(string) + return v +} + +// strSliceField extracts a []string from cfg[key], returning nil if absent or wrong type. +func strSliceField(cfg map[string]any, key string) []string { + raw, ok := cfg[key].([]any) + if !ok { + return nil + } + result := make([]string, 0, len(raw)) + for _, item := range raw { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result +} + +// boolPtrField extracts a *bool from cfg[key], returning nil if absent or wrong type. +func boolPtrField(cfg map[string]any, key string) *bool { + v, ok := cfg[key].(bool) + if !ok { + return nil + } + return &v +} diff --git a/internal/recipe/export.go b/internal/recipe/export.go new file mode 100644 index 0000000..a6c8604 --- /dev/null +++ b/internal/recipe/export.go @@ -0,0 +1,29 @@ +package recipe + +import ( + "fmt" + + "gopkg.in/yaml.v3" + + "github.com/castai/kimchi/internal/config" +) + +const fileHeader = "# Generated by kimchi recipe export\n# https://github.com/castai/kimchi\n\n" + +// WriteYAML marshals r to YAML and writes it to outputPath. +// A comment header is prepended to the file. Intermediate directories are +// created as needed. The write is atomic (temp file + rename). +func WriteYAML(outputPath string, r *Recipe) error { + data, err := yaml.Marshal(r) + if err != nil { + return fmt.Errorf("marshal recipe: %w", err) + } + + out := append([]byte(fileHeader), data...) + + if err := config.WriteFile(outputPath, out); err != nil { + return fmt.Errorf("write recipe file: %w", err) + } + + return nil +} diff --git a/internal/recipe/reader.go b/internal/recipe/reader.go new file mode 100644 index 0000000..70cc83c --- /dev/null +++ b/internal/recipe/reader.go @@ -0,0 +1,324 @@ +package recipe + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/castai/kimchi/internal/config" +) + +// OpenCodeAssets holds all data read from the local OpenCode installation. +// Missing files are silently skipped — nothing here is required. +type OpenCodeAssets struct { + Config map[string]any + TUI *TUIConfig + AgentsMD string + Skills []SkillEntry + CustomCommands []CommandEntry + Agents []AgentEntry + ThemeFiles []FileEntry + PluginFiles []FileEntry + ToolFiles []FileEntry + // ReferencedFiles holds files found by resolving @path references inside + // exported markdown content against the opencode config directory. + ReferencedFiles []FileEntry + // UnresolvedRefs lists @path references found in markdown content that + // could not be resolved within the opencode config directory — typically + // project-level paths the LLM will read at runtime. + UnresolvedRefs []string +} + +// ReadGlobalOpenCodeAssets reads all exportable assets from the user's global +// OpenCode configuration directory (~/.config/opencode/). +func ReadGlobalOpenCodeAssets() (*OpenCodeAssets, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + base := filepath.Join(homeDir, ".config", "opencode") + return readAssets( + filepath.Join(base, "opencode.json"), + filepath.Join(base, "AGENTS.md"), + base, // asset base: skills/, commands/, agents/, themes/, plugins/, tools/ + base, // ref resolution base (same for global) + ) +} + +// ReadProjectOpenCodeAssets reads exportable assets from the current working +// directory's OpenCode project config. +// +// Project layout: +// - ./opencode.json — project config +// - ./AGENTS.md — project rules +// - ./.opencode/skills/ — project skills +// - ./.opencode/commands/ — project commands +// - ./.opencode/agents/ — project agents +func ReadProjectOpenCodeAssets() (*OpenCodeAssets, error) { + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + + // Config file: ./opencode.json (project root, not inside .opencode/) + configFile := filepath.Join(cwd, "opencode.json") + if _, err := os.Stat(configFile); errors.Is(err, fs.ErrNotExist) { + // Fallback: .opencode/opencode.json + configFile = filepath.Join(cwd, ".opencode", "opencode.json") + } + + dotOpenCode := filepath.Join(cwd, ".opencode") + + return readAssets( + configFile, + filepath.Join(cwd, "AGENTS.md"), + dotOpenCode, // asset base: .opencode/skills/, .opencode/commands/, etc. + cwd, // ref resolution base (resolve @-refs from project root) + ) +} + +// ReadOpenCodeAssets is the legacy entry point kept for compatibility. +// New callers should use ReadGlobalOpenCodeAssets or ReadProjectOpenCodeAssets. +func ReadOpenCodeAssets() (*OpenCodeAssets, error) { + return ReadGlobalOpenCodeAssets() +} + +// readAssets is the shared implementation. Parameters: +// - configFilePath: path to opencode.json +// - agentsMDPath: path to AGENTS.md +// - assetBase: root directory for skills/, commands/, agents/, themes/, plugins/, tools/ +// - refBase: root directory used when resolving @path references in markdown +func readAssets(configFilePath, agentsMDPath, assetBase, refBase string) (*OpenCodeAssets, error) { + assets := &OpenCodeAssets{} + + // opencode.json + cfg, err := config.ReadJSON(configFilePath) + if err != nil { + return nil, err + } + ScrubSecrets(cfg) + assets.Config = cfg + + // tui.json — only meaningful for global scope; project scope returns nil here. + tuiPath := filepath.Join(assetBase, "tui.json") + if tuiRaw, err := config.ReadJSON(tuiPath); err == nil { + assets.TUI = parseTUIConfig(tuiRaw) + } + + // AGENTS.md + if data, err := os.ReadFile(agentsMDPath); err == nil { + assets.AgentsMD = string(data) + } + + // skills// — SKILL.md plus any extra files + skillsDir := filepath.Join(assetBase, "skills") + if entries, err := os.ReadDir(skillsDir); err == nil { + for _, e := range entries { + if !e.IsDir() { + continue + } + skillDir := filepath.Join(skillsDir, e.Name()) + data, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md")) + if err != nil { + continue + } + assets.Skills = append(assets.Skills, SkillEntry{ + Name: e.Name(), + Content: string(data), + Files: readExtraSkillFiles(skillDir), + }) + } + } + + // commands/ — supports nested subdirectories + assets.CustomCommands = readMarkdownFilesRecursive(filepath.Join(assetBase, "commands"), "") + + // agents/*.md + assets.Agents = readAgentFiles(filepath.Join(assetBase, "agents")) + + // themes/*.json, plugins/, tools/ — global-only in practice but read if present + assets.ThemeFiles = readDirFiles(filepath.Join(assetBase, "themes"), ".json") + assets.PluginFiles = readAllFiles(filepath.Join(assetBase, "plugins")) + assets.ToolFiles = readAllFiles(filepath.Join(assetBase, "tools")) + + // Resolve @path references in exported markdown against refBase. + refs := resolveAtRefs(collectMarkdownContents(assets), refBase) + assets.ReferencedFiles = refs.Resolved + assets.UnresolvedRefs = refs.Unresolved + + return assets, nil +} + +// collectMarkdownContents gathers all markdown strings from the assets so that +// @-reference scanning can run over all of them in one pass. +func collectMarkdownContents(a *OpenCodeAssets) []string { + var contents []string + if a.AgentsMD != "" { + contents = append(contents, a.AgentsMD) + } + for _, s := range a.Skills { + contents = append(contents, s.Content) + } + for _, c := range a.CustomCommands { + contents = append(contents, c.Content) + } + for _, ag := range a.Agents { + contents = append(contents, ag.Content) + } + return contents +} + +// parseTUIConfig converts a raw tui.json map into a TUIConfig struct. +func parseTUIConfig(raw map[string]any) *TUIConfig { + cfg := &TUIConfig{} + if v, ok := raw["theme"].(string); ok { + cfg.Theme = v + } + if v, ok := raw["scroll_speed"].(float64); ok { + cfg.ScrollSpeed = v + } + if v, ok := raw["scroll_acceleration"].(map[string]any); ok { + cfg.ScrollAcceleration = v + } + if v, ok := raw["diff_style"].(string); ok { + cfg.DiffStyle = v + } + if kb, ok := raw["keybinds"].(map[string]any); ok { + keybinds := make(map[string]string, len(kb)) + for k, v := range kb { + if s, ok := v.(string); ok { + keybinds[k] = s + } + } + if len(keybinds) > 0 { + cfg.Keybinds = keybinds + } + } + if cfg.Theme == "" && cfg.ScrollSpeed == 0 && cfg.ScrollAcceleration == nil && + cfg.DiffStyle == "" && len(cfg.Keybinds) == 0 { + return nil + } + return cfg +} + +// readExtraSkillFiles returns all files inside skillDir that are NOT SKILL.md. +func readExtraSkillFiles(skillDir string) []FileEntry { + var result []FileEntry + _ = filepath.WalkDir(skillDir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + rel, _ := filepath.Rel(skillDir, path) + if rel == "SKILL.md" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + result = append(result, FileEntry{ + Path: filepath.ToSlash(rel), + Content: string(data), + }) + return nil + }) + return result +} + +// readMarkdownFilesRecursive reads every *.md file under dir recursively. +// The CommandEntry name is the slash-separated relative path without .md suffix. +func readMarkdownFilesRecursive(dir string, rel string) []CommandEntry { + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return nil + } + var result []CommandEntry + for _, e := range entries { + entryRel := e.Name() + if rel != "" { + entryRel = rel + "/" + e.Name() + } + if e.IsDir() { + result = append(result, readMarkdownFilesRecursive(filepath.Join(dir, e.Name()), entryRel)...) + continue + } + if !strings.HasSuffix(e.Name(), ".md") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + result = append(result, CommandEntry{Name: strings.TrimSuffix(entryRel, ".md"), Content: string(data)}) + } + return result +} + +// readAgentFiles reads every *.md file in dir and returns AgentEntry slices. +func readAgentFiles(dir string) []AgentEntry { + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return nil + } + var result []AgentEntry + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + result = append(result, AgentEntry{Name: strings.TrimSuffix(e.Name(), ".md"), Content: string(data)}) + } + return result +} + +// readDirFiles reads all files with the given extension in dir (non-recursive). +func readDirFiles(dir, ext string) []FileEntry { + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return nil + } + var result []FileEntry + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ext) { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + result = append(result, FileEntry{Path: e.Name(), Content: string(data)}) + } + return result +} + +// readAllFiles reads all files under dir recursively (any extension). +func readAllFiles(dir string) []FileEntry { + var result []FileEntry + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + rel, _ := filepath.Rel(dir, path) + data, err := os.ReadFile(path) + if err != nil { + return nil + } + result = append(result, FileEntry{Path: filepath.ToSlash(rel), Content: string(data)}) + return nil + }) + return result +} diff --git a/internal/recipe/recipe.go b/internal/recipe/recipe.go new file mode 100644 index 0000000..241ba64 --- /dev/null +++ b/internal/recipe/recipe.go @@ -0,0 +1,101 @@ +package recipe + +// Recipe is the top-level portable snapshot of an AI tool configuration. +// Version 1 supports OpenCode only. +type Recipe struct { + Name string `yaml:"name"` + Author string `yaml:"author"` + Description string `yaml:"description,omitempty"` + Model string `yaml:"model"` + UseCase string `yaml:"use_case"` + Version string `yaml:"version"` + Tools ToolsMap `yaml:"tools"` +} + +// ToolsMap holds per-tool configuration blocks. Fields are omitted when nil. +type ToolsMap struct { + OpenCode *OpenCodeConfig `yaml:"opencode,omitempty"` +} + +// OpenCodeConfig captures the exportable OpenCode settings. +// Secrets in providers and MCP servers are replaced with placeholder strings. +type OpenCodeConfig struct { + // Provider / model (from opencode.json) + Providers map[string]any `yaml:"providers,omitempty"` + Model string `yaml:"model,omitempty"` + SmallModel string `yaml:"small_model,omitempty"` + DefaultAgent string `yaml:"default_agent,omitempty"` + DisabledProviders []string `yaml:"disabled_providers,omitempty"` + EnabledProviders []string `yaml:"enabled_providers,omitempty"` + Plugin []string `yaml:"plugin,omitempty"` + Snapshot *bool `yaml:"snapshot,omitempty"` + + // Behavior (from opencode.json) + Compaction map[string]any `yaml:"compaction,omitempty"` + AgentConfigs map[string]any `yaml:"agent,omitempty"` + MCP map[string]any `yaml:"mcp,omitempty"` + Permission any `yaml:"permission,omitempty"` + Tools map[string]any `yaml:"tools,omitempty"` + Experimental map[string]any `yaml:"experimental,omitempty"` + Formatter any `yaml:"formatter,omitempty"` + LSP any `yaml:"lsp,omitempty"` + InlineCommands map[string]any `yaml:"command,omitempty"` + + // TUI config (from tui.json) — optional, user-selectable + TUI *TUIConfig `yaml:"tui,omitempty"` + + // Portable URL entries from the opencode.json instructions field. + // Local path/glob entries are omitted — they are machine-specific. + Instructions []string `yaml:"instructions,omitempty"` + + // Files discovered by resolving @path references inside exported markdown + // content against ~/.config/opencode/. Stored so the installer can + // recreate them in the right place. + ReferencedFiles []FileEntry `yaml:"referenced_files,omitempty"` + + // File-based assets embedded into the recipe + AgentsMD string `yaml:"agents_md,omitempty"` + Skills []SkillEntry `yaml:"skills,omitempty"` + CustomCommands []CommandEntry `yaml:"custom_commands,omitempty"` + Agents []AgentEntry `yaml:"agents,omitempty"` + ThemeFiles []FileEntry `yaml:"theme_files,omitempty"` + PluginFiles []FileEntry `yaml:"plugin_files,omitempty"` + ToolFiles []FileEntry `yaml:"tool_files,omitempty"` +} + +// TUIConfig captures the exportable OpenCode TUI settings (tui.json). +type TUIConfig struct { + Theme string `yaml:"theme,omitempty"` + ScrollSpeed float64 `yaml:"scroll_speed,omitempty"` + ScrollAcceleration map[string]any `yaml:"scroll_acceleration,omitempty"` + DiffStyle string `yaml:"diff_style,omitempty"` + Keybinds map[string]string `yaml:"keybinds,omitempty"` +} + +// SkillEntry is a skill directory from ~/.config/opencode/skills//. +// Content holds SKILL.md; Files holds any additional assets (scripts, etc.). +type SkillEntry struct { + Name string `yaml:"name"` + Content string `yaml:"content"` + Files []FileEntry `yaml:"files,omitempty"` +} + +// FileEntry is a file embedded from a config subdirectory. +// Path is relative to the containing directory (e.g. skill dir, themes/, plugins/). +type FileEntry struct { + Path string `yaml:"path"` + Content string `yaml:"content"` +} + +// CommandEntry is a named *.md file from ~/.config/opencode/commands/. +// Name may include a subdirectory prefix, e.g. "gsd/gsd-add-backlog". +type CommandEntry struct { + Name string `yaml:"name"` + Content string `yaml:"content"` +} + +// AgentEntry is a named *.md file from ~/.config/opencode/agents/. +type AgentEntry struct { + Name string `yaml:"name"` + Content string `yaml:"content"` +} diff --git a/internal/recipe/refs.go b/internal/recipe/refs.go new file mode 100644 index 0000000..fefd03b --- /dev/null +++ b/internal/recipe/refs.go @@ -0,0 +1,93 @@ +package recipe + +import ( + "os" + "path/filepath" + "regexp" + "strings" +) + +// atRefPattern matches @token where the token looks like a file path: +// it must contain a '/' or a '.' to distinguish file refs from agent @mentions. +var atRefPattern = regexp.MustCompile(`@([\w.\-/]+\.[\w]+|[\w.\-]+/[\w.\-/]+)`) + +// RefsResult holds the outcome of resolving @path references in markdown content. +type RefsResult struct { + // Resolved contains files that were found inside the opencode config dir. + Resolved []FileEntry + // Unresolved contains reference strings that could not be embedded — + // either they point outside the config directory or the file does not exist + // there. These are likely project-level paths that the LLM will read at + // runtime but cannot be bundled into the recipe. + Unresolved []string +} + +// resolveAtRefs scans markdown content for @path references, attempts to +// resolve each one against baseDir, and returns a RefsResult. Only files +// strictly inside baseDir are included — references that traverse outside +// (e.g. @../../etc/passwd) are reported as unresolved. +// Duplicate paths are deduplicated. +func resolveAtRefs(contents []string, baseDir string) RefsResult { + // Ensure baseDir is absolute and clean so prefix checks are reliable. + absBase, err := filepath.Abs(baseDir) + if err != nil { + return RefsResult{} + } + // Guarantee a trailing separator so a directory named "opencode-extra" is + // not mistaken for being inside "opencode". + baseDirPrefix := absBase + string(filepath.Separator) + + seen := make(map[string]struct{}) + var res RefsResult + + for _, content := range contents { + for _, match := range atRefPattern.FindAllStringSubmatch(content, -1) { + ref := match[1] + if _, already := seen[ref]; already { + continue + } + seen[ref] = struct{}{} + + abs := filepath.Join(absBase, filepath.FromSlash(ref)) + + // Reject any reference that resolves outside the config directory. + if !strings.HasPrefix(abs, baseDirPrefix) { + res.Unresolved = append(res.Unresolved, ref) + continue + } + + data, err := os.ReadFile(abs) + if err != nil { + // File not found in config dir — likely a project-level ref. + res.Unresolved = append(res.Unresolved, ref) + continue + } + res.Resolved = append(res.Resolved, FileEntry{ + Path: ref, // keep as slash-separated relative path + Content: string(data), + }) + } + } + return res +} + +// filterURLInstructions returns only the URL entries from the raw instructions +// slice (strings that start with "http://" or "https://"). +// Local paths and glob patterns are machine-specific and not portable. +func filterURLInstructions(cfg map[string]any) []string { + raw, ok := cfg["instructions"].([]any) + if !ok { + return nil + } + var urls []string + for _, item := range raw { + s, ok := item.(string) + if !ok { + continue + } + if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") { + urls = append(urls, s) + } + } + return urls +} diff --git a/internal/recipe/scrub.go b/internal/recipe/scrub.go new file mode 100644 index 0000000..486d05d --- /dev/null +++ b/internal/recipe/scrub.go @@ -0,0 +1,98 @@ +package recipe + +import ( + "strings" +) + +// SecretPlaceholderPrefix is the unique prefix used for all secret placeholders +// in exported recipes. The installer can detect any placeholder with: +// +// strings.HasPrefix(value, recipe.SecretPlaceholderPrefix) +const SecretPlaceholderPrefix = "kimchi:secret:" + +// placeholder returns a uniquely-prefixed placeholder string for a secret. +// Example: placeholder("openai", "apiKey") → "kimchi:secret:OPENAI_APIKEY" +func placeholder(parts ...string) string { + upper := make([]string, len(parts)) + for i, p := range parts { + upper[i] = strings.ToUpper(p) + } + return SecretPlaceholderPrefix + strings.Join(upper, "_") +} + +// secretProviderKeys are option keys treated as secrets in provider configs. +var secretProviderKeys = []string{"apiKey", "api_key", "token", "secret"} + +// ScrubSecrets replaces known secret fields in provider and MCP configs with +// placeholder strings (e.g. "kimchi:secret:OPENAI_APIKEY") so the exported +// recipe is safe to share. The installer detects placeholders via +// SecretPlaceholderPrefix and prompts the user to supply real values. +func ScrubSecrets(cfg map[string]any) map[string]any { + scrubProviders(cfg) + scrubMCP(cfg) + return cfg +} + +// scrubProviders replaces secret option values for every provider entry. +func scrubProviders(cfg map[string]any) { + providers, ok := cfg["provider"].(map[string]any) + if !ok { + return + } + for name, v := range providers { + prov, ok := v.(map[string]any) + if !ok { + continue + } + opts, ok := prov["options"].(map[string]any) + if !ok { + continue + } + for _, key := range secretProviderKeys { + if _, exists := opts[key]; exists { + opts[key] = placeholder(name, key) + } + } + } +} + +// scrubMCP replaces secrets in MCP server definitions: +// - environment vars for local servers +// - HTTP headers for remote servers +// - OAuth client credentials for remote servers +func scrubMCP(cfg map[string]any) { + mcp, ok := cfg["mcp"].(map[string]any) + if !ok { + return + } + for name, v := range mcp { + server, ok := v.(map[string]any) + if !ok { + continue + } + + // Local MCP: environment variables + if env, ok := server["environment"].(map[string]any); ok { + for key := range env { + env[key] = placeholder("mcp", name, key) + } + } + + // Remote MCP: HTTP headers + if headers, ok := server["headers"].(map[string]any); ok { + for key := range headers { + headers[key] = placeholder("mcp", name, "header", key) + } + } + + // Remote MCP: OAuth credentials + if oauth, ok := server["oauth"].(map[string]any); ok { + if _, ok := oauth["clientId"]; ok { + oauth["clientId"] = placeholder("mcp", name, "oauth", "client", "id") + } + if _, ok := oauth["clientSecret"]; ok { + oauth["clientSecret"] = placeholder("mcp", name, "oauth", "client", "secret") + } + } + } +} diff --git a/internal/tools/constants.go b/internal/tools/constants.go index 0730c1c..1eb7d36 100644 --- a/internal/tools/constants.go +++ b/internal/tools/constants.go @@ -2,7 +2,9 @@ package tools const ( providerName = "kimchi" + ProviderName = providerName APIKeyEnv = "KIMCHI_API_KEY" baseURL = "https://llm.cast.ai/openai/v1" + BaseURL = baseURL anthropicBaseURL = "https://llm.cast.ai/anthropic" ) diff --git a/internal/tools/model.go b/internal/tools/model.go index a4e606d..ec647b5 100644 --- a/internal/tools/model.go +++ b/internal/tools/model.go @@ -50,3 +50,11 @@ var ( allModels = []model{MainModel, CodingModel, SubModel} ) + +// Exported accessors for use by other packages (e.g. recipe export). + +func (m model) GetToolCall() bool { return m.toolCall } +func (m model) GetReasoning() bool { return m.reasoning } +func (m model) GetContextWindow() int { return m.limits.contextWindow } +func (m model) GetMaxOutputTokens() int { return m.limits.maxOutputTokens } +func (m model) GetDisplayName() string { return m.displayName } diff --git a/internal/tui/export_wizard.go b/internal/tui/export_wizard.go new file mode 100644 index 0000000..4fec0f8 --- /dev/null +++ b/internal/tui/export_wizard.go @@ -0,0 +1,234 @@ +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/castai/kimchi/internal/config" + "github.com/castai/kimchi/internal/recipe" + "github.com/castai/kimchi/internal/tools" + "github.com/castai/kimchi/internal/tui/steps" +) + +const defaultOutputPath = "kimchi-recipe.yaml" + +// exportWizard is a standalone bubbletea model for the recipe export flow. +type exportWizard struct { + stepList []steps.Step + current int + opts recipe.ExportOptions + finished bool + aborted bool + outputPath string + selectedTool tools.ToolID + scope config.ConfigScope + + // typed references for result collection + toolStep *steps.ExportToolStep + scopeStep *steps.ExportScopeStep + metaStep *steps.ExportMetaStep + useCaseStep *steps.ExportUseCaseStep + assetsStep *steps.ExportAssetsStep + outputStep *steps.ExportOutputStep // nil when --output flag was provided + confirmStep *steps.ExportConfirmStep +} + +// newExportWizard builds the wizard. If outputPath is non-empty the output +// file step is skipped and that path is used directly. +func newExportWizard(outputPath string) *exportWizard { + toolStep := steps.NewExportToolStep() + scopeStep := steps.NewExportScopeStep() + meta := steps.NewExportMetaStep() + useCase := steps.NewExportUseCaseStep() + + w := &exportWizard{ + outputPath: outputPath, + scope: config.ScopeGlobal, // default; updated when scope step completes + toolStep: toolStep, + scopeStep: scopeStep, + metaStep: meta, + useCaseStep: useCase, + } + + // assetsStep is created lazily in collectStepResult once scope is known. + + // writeFn is called by the confirm step when the user presses Enter. + writeFn := func() ([]string, error) { + var ( + a *recipe.OpenCodeAssets + err error + ) + switch w.selectedTool { + case tools.ToolOpenCode: + switch w.scope { + case config.ScopeProject: + a, err = recipe.ReadProjectOpenCodeAssets() + default: + a, err = recipe.ReadGlobalOpenCodeAssets() + } + if err != nil { + return nil, fmt.Errorf("read opencode assets: %w", err) + } + r, err := recipe.Build(a, w.opts) + if err != nil { + return nil, fmt.Errorf("build recipe: %w", err) + } + return a.UnresolvedRefs, recipe.WriteYAML(w.outputPath, r) + default: + return nil, fmt.Errorf("unsupported tool: %s", w.selectedTool) + } + } + + // Placeholder path for the confirm step — updated later in collectStepResult. + confirmOutputPath := outputPath + if confirmOutputPath == "" { + confirmOutputPath = defaultOutputPath + } + confirm := steps.NewExportConfirmStep(confirmOutputPath, writeFn, "", "", "", nil) + w.confirmStep = confirm + + // Assets step placeholder — replaced with a scope-aware instance after the + // scope step completes. Use global scope as the initial default. + assetsStep := steps.NewExportAssetsStep(config.ScopeGlobal) + w.assetsStep = assetsStep + + stepList := []steps.Step{toolStep, scopeStep, meta, useCase, assetsStep} + if outputPath == "" { + outputStep := steps.NewExportOutputStep(defaultOutputPath) + w.outputStep = outputStep + w.outputPath = defaultOutputPath + stepList = append(stepList, outputStep) + } + stepList = append(stepList, confirm) + w.stepList = stepList + return w +} + +func (w *exportWizard) Init() tea.Cmd { + if len(w.stepList) == 0 { + return tea.Quit + } + return tea.Batch(w.stepList[0].Init(), tea.EnterAltScreen) +} + +func (w *exportWizard) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case steps.NextStepMsg: + w.collectStepResult() + if w.current >= len(w.stepList)-1 { + w.finished = true + return w, tea.Quit + } + w.current++ + return w, w.stepList[w.current].Init() + + case steps.PrevStepMsg: + if w.current > 0 { + w.current-- + return w, w.stepList[w.current].Init() + } + return w, nil + + case steps.AbortMsg: + w.aborted = true + return w, tea.Quit + } + + updatedStep, cmd := w.stepList[w.current].Update(msg) + w.stepList[w.current] = updatedStep + return w, cmd +} + +func (w *exportWizard) View() string { + if w.current >= len(w.stepList) { + return "" + } + step := w.stepList[w.current] + return steps.StepView(step.Info(), step.View()) +} + +func (w *exportWizard) collectStepResult() { + if w.current >= len(w.stepList) { + return + } + switch s := w.stepList[w.current].(type) { + case *steps.ExportToolStep: + w.selectedTool = s.SelectedTool() + + case *steps.ExportScopeStep: + w.scope = s.SelectedScope() + // Replace the assets step with a scope-aware instance and update stepList. + assetsStep := steps.NewExportAssetsStep(w.scope) + w.assetsStep = assetsStep + for i, step := range w.stepList { + if _, ok := step.(*steps.ExportAssetsStep); ok { + w.stepList[i] = assetsStep + break + } + } + + case *steps.ExportMetaStep: + w.opts.Name = s.RecipeName() + w.opts.Author = s.Author() + w.opts.Description = s.Description() + + case *steps.ExportUseCaseStep: + w.opts.UseCase = s.SelectedUseCase() + + case *steps.ExportAssetsStep: + w.opts.IncludeAgentsMD = s.IncludeAgentsMD() + w.opts.IncludeSkills = s.IncludeSkills() + w.opts.IncludeCustomCommands = s.IncludeCustomCommands() + w.opts.IncludeAgents = s.IncludeAgents() + w.opts.IncludeTUI = s.IncludeTUI() + w.opts.IncludeThemeFiles = s.IncludeThemeFiles() + w.opts.IncludePluginFiles = s.IncludePluginFiles() + w.opts.IncludeToolFiles = s.IncludeToolFiles() + if w.outputStep == nil { + w.confirmStep.SetSummary(w.opts.Name, w.opts.Author, w.opts.UseCase, w.includedLabels()) + } + + case *steps.ExportOutputStep: + w.outputPath = s.OutputPath() + w.confirmStep.SetOutputPath(w.outputPath) + w.confirmStep.SetSummary(w.opts.Name, w.opts.Author, w.opts.UseCase, w.includedLabels()) + } +} + +func (w *exportWizard) includedLabels() []string { + var labels []string + if w.opts.IncludeAgentsMD { + labels = append(labels, "AGENTS.md") + } + if w.opts.IncludeSkills { + labels = append(labels, "Skills") + } + if w.opts.IncludeCustomCommands { + labels = append(labels, "Commands") + } + if w.opts.IncludeAgents { + labels = append(labels, "Agents") + } + if w.opts.IncludeTUI { + labels = append(labels, "TUI Config") + } + if w.opts.IncludeThemeFiles { + labels = append(labels, "Custom Themes") + } + if w.opts.IncludePluginFiles { + labels = append(labels, "Plugin Files") + } + if w.opts.IncludeToolFiles { + labels = append(labels, "Custom Tools") + } + return labels +} + +// RunExportWizard launches the recipe export TUI and writes to outputPath. +func RunExportWizard(outputPath string) error { + w := newExportWizard(outputPath) + p := tea.NewProgram(w, tea.WithAltScreen()) + _, err := p.Run() + return err +} diff --git a/internal/tui/steps/export_assets.go b/internal/tui/steps/export_assets.go new file mode 100644 index 0000000..329ea96 --- /dev/null +++ b/internal/tui/steps/export_assets.go @@ -0,0 +1,306 @@ +package steps + +import ( + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/castai/kimchi/internal/config" +) + +// assetDef describes one selectable asset category. +type assetDef struct { + id string + label string + desc string + globalOnly bool // if true, hide when scope is project +} + +var assetDefs = []assetDef{ + {id: "agents_md", label: "AGENTS.md", desc: "System prompt and rules injected into every session"}, + {id: "skills", label: "Skills", desc: "Reusable on-demand instruction sets (skills//SKILL.md)"}, + {id: "custom_commands", label: "Custom Commands", desc: "Slash command templates (commands/**/*.md)"}, + {id: "agents", label: "Custom Agents", desc: "Per-agent system prompts with their own models (agents/*.md)"}, + {id: "tui", label: "TUI Config", desc: "Theme, keybinds and display settings (tui.json)", globalOnly: true}, + {id: "theme_files", label: "Custom Themes", desc: "Custom theme JSON files (themes/*.json)", globalOnly: true}, + {id: "plugin_files", label: "Plugin Files", desc: "Custom plugin source files (plugins/)", globalOnly: true}, + {id: "tool_files", label: "Custom Tools", desc: "Custom tool definitions (tools/)", globalOnly: true}, +} + +type assetItem struct { + assetDef + found bool +} + +// assetExistsForScope checks whether an asset category exists, searching the +// correct paths for the given scope. +func assetExistsForScope(kind string, scope config.ConfigScope) bool { + switch scope { + case config.ScopeProject: + return assetExistsProject(kind) + default: + return assetExistsGlobal(kind) + } +} + +func assetExistsGlobal(kind string) bool { + homeDir, err := os.UserHomeDir() + if err != nil { + return false + } + base := filepath.Join(homeDir, ".config", "opencode") + switch kind { + case "agents_md": + _, err := os.Stat(filepath.Join(base, "AGENTS.md")) + return err == nil + case "skills": + entries, err := os.ReadDir(filepath.Join(base, "skills")) + return err == nil && len(entries) > 0 + case "custom_commands": + return dirContainsMarkdown(filepath.Join(base, "commands")) + case "agents": + entries, err := os.ReadDir(filepath.Join(base, "agents")) + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { + return true + } + } + return false + case "tui": + _, err := os.Stat(filepath.Join(base, "tui.json")) + return err == nil + case "theme_files": + return dirContainsExt(filepath.Join(base, "themes"), ".json") + case "plugin_files": + return dirHasFiles(filepath.Join(base, "plugins")) + case "tool_files": + return dirHasFiles(filepath.Join(base, "tools")) + } + return false +} + +func assetExistsProject(kind string) bool { + cwd, err := os.Getwd() + if err != nil { + return false + } + dotOpenCode := filepath.Join(cwd, ".opencode") + switch kind { + case "agents_md": + _, err := os.Stat(filepath.Join(cwd, "AGENTS.md")) + return err == nil + case "skills": + entries, err := os.ReadDir(filepath.Join(dotOpenCode, "skills")) + return err == nil && len(entries) > 0 + case "custom_commands": + return dirContainsMarkdown(filepath.Join(dotOpenCode, "commands")) + case "agents": + entries, err := os.ReadDir(filepath.Join(dotOpenCode, "agents")) + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { + return true + } + } + return false + // global-only items never exist in project scope + case "tui", "theme_files", "plugin_files", "tool_files": + return false + } + return false +} + +// dirContainsMarkdown reports whether dir or any of its subdirectories +// contains at least one *.md file. +func dirContainsMarkdown(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, e := range entries { + if e.IsDir() { + if dirContainsMarkdown(filepath.Join(dir, e.Name())) { + return true + } + } else if strings.HasSuffix(e.Name(), ".md") { + return true + } + } + return false +} + +// dirContainsExt reports whether dir contains at least one file with the given extension. +func dirContainsExt(dir, ext string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ext) { + return true + } + } + return false +} + +// dirHasFiles reports whether dir exists and contains at least one file (any type). +func dirHasFiles(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() { + return true + } + } + return false +} + +type assetProbeCompleteMsg struct { + items []assetItem +} + +// ExportAssetsStep is a checkbox list that lets the user choose which +// OpenCode assets to include in the exported recipe. +type ExportAssetsStep struct { + scope config.ConfigScope + items []assetItem + selected map[string]bool + cursor int + ready bool +} + +func NewExportAssetsStep(scope config.ConfigScope) *ExportAssetsStep { + s := &ExportAssetsStep{ + scope: scope, + selected: make(map[string]bool), + } + return s +} + +func (s *ExportAssetsStep) IncludeAgentsMD() bool { return s.selected["agents_md"] } +func (s *ExportAssetsStep) IncludeSkills() bool { return s.selected["skills"] } +func (s *ExportAssetsStep) IncludeCustomCommands() bool { return s.selected["custom_commands"] } +func (s *ExportAssetsStep) IncludeAgents() bool { return s.selected["agents"] } +func (s *ExportAssetsStep) IncludeTUI() bool { return s.selected["tui"] } +func (s *ExportAssetsStep) IncludeThemeFiles() bool { return s.selected["theme_files"] } +func (s *ExportAssetsStep) IncludePluginFiles() bool { return s.selected["plugin_files"] } +func (s *ExportAssetsStep) IncludeToolFiles() bool { return s.selected["tool_files"] } + +func (s *ExportAssetsStep) Init() tea.Cmd { + scope := s.scope + return func() tea.Msg { + var items []assetItem + for _, def := range assetDefs { + if def.globalOnly && scope == config.ScopeProject { + continue + } + item := assetItem{assetDef: def} + item.found = assetExistsForScope(def.id, scope) + items = append(items, item) + } + return assetProbeCompleteMsg{items: items} + } +} + +func (s *ExportAssetsStep) Update(msg tea.Msg) (Step, tea.Cmd) { + switch msg := msg.(type) { + case assetProbeCompleteMsg: + s.items = msg.items + for _, item := range s.items { + if item.found { + s.selected[item.id] = true + } + } + s.ready = true + return s, nil + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return s, func() tea.Msg { return AbortMsg{} } + case "esc": + return s, func() tea.Msg { return PrevStepMsg{} } + case "up", "k": + if s.cursor > 0 { + s.cursor-- + } + case "down", "j": + if s.cursor < len(s.items)-1 { + s.cursor++ + } + case " ": + id := s.items[s.cursor].id + s.selected[id] = !s.selected[id] + case "enter": + return s, func() tea.Msg { return NextStepMsg{} } + } + } + return s, nil +} + +func (s *ExportAssetsStep) View() string { + var b strings.Builder + + if !s.ready { + b.WriteString(Styles.Spinner.Render("Checking for assets...")) + b.WriteString("\n") + return b.String() + } + + b.WriteString("Select which OpenCode assets to include in the recipe.\n\n") + + for i, item := range s.items { + cursor := " " + if s.cursor == i { + cursor = Styles.Cursor.Render("► ") + } + + checkbox := "[ ]" + if s.selected[item.id] { + checkbox = Styles.Selected.Render("[✓]") + } + + found := "" + if item.found { + found = Styles.Success.Render(" ✓ found") + } else { + found = Styles.Desc.Render(" (not found)") + } + + firstLine := cursor + checkbox + " " + item.label + found + if s.cursor == i { + b.WriteString(Styles.Selected.Render(firstLine)) + } else { + b.WriteString(firstLine) + } + b.WriteString("\n") + b.WriteString(" " + Styles.Desc.Render(item.desc)) + b.WriteString("\n") + } + + return b.String() +} + +func (s *ExportAssetsStep) Name() string { return "Include Assets" } + +func (s *ExportAssetsStep) Info() StepInfo { + return StepInfo{ + Name: "Include Assets", + KeyBindings: []KeyBinding{ + BindingsNavigate, + BindingsSelect, + BindingsConfirm, + BindingsBack, + BindingsQuit, + }, + } +} diff --git a/internal/tui/steps/export_confirm.go b/internal/tui/steps/export_confirm.go new file mode 100644 index 0000000..b1026fe --- /dev/null +++ b/internal/tui/steps/export_confirm.go @@ -0,0 +1,183 @@ +package steps + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +type exportConfirmState int + +const ( + exportConfirmIdle exportConfirmState = iota + exportConfirmWriting + exportConfirmDone + exportConfirmError +) + +type exportWriteCompleteMsg struct { + err error + unresolvedRefs []string +} + +// ExportConfirmStep shows a summary of what will be exported, performs the +// async write via writeFn, and shows the result. +type ExportConfirmStep struct { + outputPath string + writeFn func() ([]string, error) + state exportConfirmState + err error + spin spinner.Model + unresolvedRefs []string + + // summary fields for display + name string + author string + useCase string + included []string +} + +// NewExportConfirmStep creates the final export step. +// writeFn is called when the user confirms; it should read assets, build and +// write the recipe. This avoids importing the recipe package from the steps package. +func NewExportConfirmStep(outputPath string, writeFn func() ([]string, error), name, author, useCase string, included []string) *ExportConfirmStep { + sp := spinner.New() + sp.Spinner = spinner.Dot + return &ExportConfirmStep{ + outputPath: outputPath, + writeFn: writeFn, + state: exportConfirmIdle, + spin: sp, + name: name, + author: author, + useCase: useCase, + included: included, + } +} + +func (s *ExportConfirmStep) Init() tea.Cmd { return nil } + +func (s *ExportConfirmStep) Update(msg tea.Msg) (Step, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return s, func() tea.Msg { return AbortMsg{} } + case "esc": + if s.state == exportConfirmIdle { + return s, func() tea.Msg { return PrevStepMsg{} } + } + case "enter": + switch s.state { + case exportConfirmIdle: + s.state = exportConfirmWriting + return s, tea.Batch(s.spin.Tick, s.doWrite()) + case exportConfirmDone, exportConfirmError: + return s, func() tea.Msg { return NextStepMsg{} } + } + } + + case exportWriteCompleteMsg: + if msg.err != nil { + s.state = exportConfirmError + s.err = msg.err + } else { + s.state = exportConfirmDone + s.unresolvedRefs = msg.unresolvedRefs + } + return s, nil + + case spinner.TickMsg: + if s.state == exportConfirmWriting { + var cmd tea.Cmd + s.spin, cmd = s.spin.Update(msg) + return s, cmd + } + } + + return s, nil +} + +func (s *ExportConfirmStep) doWrite() tea.Cmd { + return func() tea.Msg { + refs, err := s.writeFn() + return exportWriteCompleteMsg{err: err, unresolvedRefs: refs} + } +} + +func (s *ExportConfirmStep) View() string { + var b strings.Builder + + switch s.state { + case exportConfirmIdle: + b.WriteString("Ready to export the following recipe:\n\n") + b.WriteString(fmt.Sprintf(" %s %s\n", Styles.Desc.Render("Name:"), s.name)) + b.WriteString(fmt.Sprintf(" %s %s\n", Styles.Desc.Render("Author:"), s.author)) + b.WriteString(fmt.Sprintf(" %s %s\n", Styles.Desc.Render("Use case:"), s.useCase)) + if len(s.included) > 0 { + b.WriteString(fmt.Sprintf(" %s %s\n", Styles.Desc.Render("Assets:"), strings.Join(s.included, ", "))) + } + b.WriteString(fmt.Sprintf("\n %s %s\n", Styles.Desc.Render("Output:"), s.outputPath)) + b.WriteString("\n") + b.WriteString(Styles.Help.Render("Press enter to export, esc to go back")) + + case exportConfirmWriting: + b.WriteString(Styles.Spinner.Render(fmt.Sprintf("%s Writing recipe...", s.spin.View()))) + + case exportConfirmDone: + b.WriteString(Styles.Success.Render("✓ Recipe exported successfully")) + b.WriteString("\n\n") + b.WriteString(fmt.Sprintf(" %s\n", s.outputPath)) + if len(s.unresolvedRefs) > 0 { + b.WriteString("\n") + b.WriteString(Styles.Warning.Render("⚠ The following @-references were not bundled (project-level or outside the OpenCode config dir):")) + b.WriteString("\n") + for _, ref := range s.unresolvedRefs { + b.WriteString(fmt.Sprintf(" %s\n", Styles.Desc.Render("@"+ref))) + } + b.WriteString(Styles.Desc.Render("These will still work at runtime — the AI will read them from the project directory.")) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(Styles.Help.Render("Press enter to exit")) + + case exportConfirmError: + b.WriteString(Styles.Error.Render(fmt.Sprintf("✗ Export failed: %v", s.err))) + b.WriteString("\n\n") + b.WriteString(Styles.Help.Render("Press enter to exit")) + } + + return b.String() +} + +// SetOutputPath updates the output path shown in the summary and used in the done message. +func (s *ExportConfirmStep) SetOutputPath(path string) { + s.outputPath = path +} + +// SetSummary updates the display fields shown in the idle state summary. +// Called by the wizard after collecting all prior step results. +func (s *ExportConfirmStep) SetSummary(name, author, useCase string, included []string) { + s.name = name + s.author = author + s.useCase = useCase + s.included = included +} + +func (s *ExportConfirmStep) Name() string { return "Export" } + +func (s *ExportConfirmStep) Info() StepInfo { + bindings := []KeyBinding{BindingsBack, BindingsQuit} + switch s.state { + case exportConfirmIdle: + bindings = []KeyBinding{BindingsConfirm, BindingsBack, BindingsQuit} + case exportConfirmDone, exportConfirmError: + bindings = []KeyBinding{BindingsConfirm} + } + return StepInfo{ + Name: "Export", + KeyBindings: bindings, + } +} diff --git a/internal/tui/steps/export_meta.go b/internal/tui/steps/export_meta.go new file mode 100644 index 0000000..44daee6 --- /dev/null +++ b/internal/tui/steps/export_meta.go @@ -0,0 +1,131 @@ +package steps + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +// ExportMetaStep collects recipe metadata: name, author, description. +type ExportMetaStep struct { + inputs [3]textinput.Model + focused int + err string +} + +func NewExportMetaStep() *ExportMetaStep { + labels := []string{"Recipe name", "Author", "Description (optional)"} + s := &ExportMetaStep{} + for i, label := range labels { + ti := textinput.New() + ti.Placeholder = label + ti.Width = 50 + s.inputs[i] = ti + } + s.inputs[0].Focus() + return s +} + +func (s *ExportMetaStep) RecipeName() string { return strings.TrimSpace(s.inputs[0].Value()) } +func (s *ExportMetaStep) Author() string { return strings.TrimSpace(s.inputs[1].Value()) } +func (s *ExportMetaStep) Description() string { return strings.TrimSpace(s.inputs[2].Value()) } + +func (s *ExportMetaStep) Init() tea.Cmd { + return textinput.Blink +} + +func (s *ExportMetaStep) Update(msg tea.Msg) (Step, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return s, func() tea.Msg { return AbortMsg{} } + case "esc": + if s.focused > 0 { + s.inputs[s.focused].Blur() + s.focused-- + s.inputs[s.focused].Focus() + return s, textinput.Blink + } + return s, func() tea.Msg { return PrevStepMsg{} } + case "tab", "down": + s.inputs[s.focused].Blur() + s.focused = (s.focused + 1) % len(s.inputs) + s.inputs[s.focused].Focus() + return s, textinput.Blink + case "shift+tab", "up": + s.inputs[s.focused].Blur() + s.focused = (s.focused - 1 + len(s.inputs)) % len(s.inputs) + s.inputs[s.focused].Focus() + return s, textinput.Blink + case "enter": + if s.focused < len(s.inputs)-1 { + // Advance to next field. + s.inputs[s.focused].Blur() + s.focused++ + s.inputs[s.focused].Focus() + return s, textinput.Blink + } + // Last field — validate required fields and advance step. + if s.RecipeName() == "" { + s.err = "Recipe name is required" + s.inputs[s.focused].Blur() + s.focused = 0 + s.inputs[s.focused].Focus() + return s, textinput.Blink + } + if s.Author() == "" { + s.err = "Author is required" + s.inputs[s.focused].Blur() + s.focused = 1 + s.inputs[s.focused].Focus() + return s, textinput.Blink + } + s.err = "" + return s, func() tea.Msg { return NextStepMsg{} } + } + } + + var cmd tea.Cmd + s.inputs[s.focused], cmd = s.inputs[s.focused].Update(msg) + return s, cmd +} + +func (s *ExportMetaStep) View() string { + var b strings.Builder + + b.WriteString("Provide metadata for the recipe file.\n\n") + + labels := []string{"Name", "Author", "Description"} + for i, label := range labels { + cursor := " " + if s.focused == i { + cursor = Styles.Cursor.Render("► ") + } + b.WriteString(cursor + Styles.Desc.Render(label+":\n")) + b.WriteString(" " + s.inputs[i].View()) + b.WriteString("\n\n") + } + + if s.err != "" { + b.WriteString(Styles.Error.Render("✗ " + s.err)) + b.WriteString("\n") + } + + return b.String() +} + +func (s *ExportMetaStep) Name() string { return "Recipe Metadata" } + +func (s *ExportMetaStep) Info() StepInfo { + return StepInfo{ + Name: "Recipe Metadata", + KeyBindings: []KeyBinding{ + {Key: "tab", Text: "next field"}, + BindingsConfirm, + BindingsBack, + BindingsQuit, + }, + } +} diff --git a/internal/tui/steps/export_output.go b/internal/tui/steps/export_output.go new file mode 100644 index 0000000..cd550d7 --- /dev/null +++ b/internal/tui/steps/export_output.go @@ -0,0 +1,72 @@ +package steps + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +// ExportOutputStep asks the user where to write the recipe file. +// The input is pre-filled with defaultPath; pressing Enter without editing accepts it. +type ExportOutputStep struct { + input textinput.Model +} + +func NewExportOutputStep(defaultPath string) *ExportOutputStep { + ti := textinput.New() + ti.Placeholder = "kimchi-recipe.yaml" + ti.SetValue(defaultPath) + ti.Width = 60 + ti.Focus() + return &ExportOutputStep{input: ti} +} + +// OutputPath returns the chosen file path (trimmed). +func (s *ExportOutputStep) OutputPath() string { + v := strings.TrimSpace(s.input.Value()) + if v == "" { + return s.input.Placeholder + } + return v +} + +func (s *ExportOutputStep) Init() tea.Cmd { return textinput.Blink } + +func (s *ExportOutputStep) Update(msg tea.Msg) (Step, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return s, func() tea.Msg { return AbortMsg{} } + case "esc": + return s, func() tea.Msg { return PrevStepMsg{} } + case "enter": + return s, func() tea.Msg { return NextStepMsg{} } + } + } + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + return s, cmd +} + +func (s *ExportOutputStep) View() string { + var b strings.Builder + b.WriteString("Where should the recipe file be saved?\n\n") + b.WriteString(Styles.Desc.Render("Output file:")) + b.WriteString("\n") + b.WriteString(s.input.View()) + b.WriteString("\n\n") + b.WriteString(Styles.Desc.Render("Press enter to accept, or type a new path.")) + b.WriteString("\n") + return b.String() +} + +func (s *ExportOutputStep) Name() string { return "Output File" } + +func (s *ExportOutputStep) Info() StepInfo { + return StepInfo{ + Name: "Output File", + KeyBindings: []KeyBinding{BindingsConfirm, BindingsBack, BindingsQuit}, + } +} diff --git a/internal/tui/steps/export_scope.go b/internal/tui/steps/export_scope.go new file mode 100644 index 0000000..a959576 --- /dev/null +++ b/internal/tui/steps/export_scope.go @@ -0,0 +1,97 @@ +package steps + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/castai/kimchi/internal/config" +) + +type exportScopeOption struct { + scope config.ConfigScope + name string + desc string +} + +var exportScopeOptions = []exportScopeOption{ + { + config.ScopeGlobal, + "Global", + "Export your global setup (~/.config/opencode/)", + }, + { + config.ScopeProject, + "Project", + "Export the current project's config (./opencode.json)", + }, +} + +// ExportScopeStep lets the user choose whether to export the global OpenCode +// config or the project-level config in the current working directory. +type ExportScopeStep struct { + selected int +} + +func NewExportScopeStep() *ExportScopeStep { + return &ExportScopeStep{} +} + +func (s *ExportScopeStep) SelectedScope() config.ConfigScope { + return exportScopeOptions[s.selected].scope +} + +func (s *ExportScopeStep) Init() tea.Cmd { return nil } + +func (s *ExportScopeStep) Update(msg tea.Msg) (Step, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return s, func() tea.Msg { return AbortMsg{} } + case "esc": + return s, func() tea.Msg { return PrevStepMsg{} } + case "up", "k": + if s.selected > 0 { + s.selected-- + } + case "down", "j": + if s.selected < len(exportScopeOptions)-1 { + s.selected++ + } + case "enter": + return s, func() tea.Msg { return NextStepMsg{} } + } + } + return s, nil +} + +func (s *ExportScopeStep) View() string { + var b strings.Builder + b.WriteString("Which OpenCode configuration do you want to export?\n\n") + + for i, opt := range exportScopeOptions { + cursor := " " + if s.selected == i { + cursor = Styles.Cursor.Render("► ") + } + radio := "○" + if s.selected == i { + radio = Styles.Selected.Render("●") + } + line := fmt.Sprintf("%s %s %-10s %s", cursor, radio, opt.name, Styles.Desc.Render(opt.desc)) + b.WriteString(line) + b.WriteString("\n") + } + return b.String() +} + +func (s *ExportScopeStep) Name() string { return "Config Scope" } + +func (s *ExportScopeStep) Info() StepInfo { + return StepInfo{ + Name: "Config Scope", + KeyBindings: []KeyBinding{BindingsNavigate, BindingsConfirm, BindingsBack, BindingsQuit}, + } +} diff --git a/internal/tui/steps/export_tool.go b/internal/tui/steps/export_tool.go new file mode 100644 index 0000000..c859840 --- /dev/null +++ b/internal/tui/steps/export_tool.go @@ -0,0 +1,124 @@ +package steps + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/castai/kimchi/internal/tools" +) + +// exportableTools lists the tool IDs that support recipe export. +// Extend this slice when more tools are supported. +var exportableTools = []tools.ToolID{ + tools.ToolOpenCode, +} + +// ExportToolStep is a radio-select step that shows only the exportable tools +// that are actually installed on the system. +type ExportToolStep struct { + available []tools.Tool + selected int +} + +func NewExportToolStep() *ExportToolStep { + var available []tools.Tool + for _, id := range exportableTools { + if t, ok := tools.ByID(id); ok && t.DetectInstalled() { + available = append(available, t) + } + } + return &ExportToolStep{available: available} +} + +// HasTools reports whether at least one exportable tool was found. +func (s *ExportToolStep) HasTools() bool { return len(s.available) > 0 } + +// SelectedTool returns the ToolID chosen by the user, or empty string if none. +func (s *ExportToolStep) SelectedTool() tools.ToolID { + if len(s.available) == 0 { + return "" + } + return s.available[s.selected].ID +} + +func (s *ExportToolStep) Init() tea.Cmd { return nil } + +func (s *ExportToolStep) Update(msg tea.Msg) (Step, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return s, func() tea.Msg { return AbortMsg{} } + case "esc": + return s, func() tea.Msg { return PrevStepMsg{} } + case "up", "k": + if s.selected > 0 { + s.selected-- + } + case "down", "j": + if s.selected < len(s.available)-1 { + s.selected++ + } + case "enter": + if len(s.available) == 0 { + return s, func() tea.Msg { return AbortMsg{} } + } + return s, func() tea.Msg { return NextStepMsg{} } + } + } + return s, nil +} + +func (s *ExportToolStep) View() string { + var b strings.Builder + + if len(s.available) == 0 { + b.WriteString(Styles.Warning.Render("No supported tools detected.")) + b.WriteString("\n\n") + b.WriteString("Recipe export currently supports: ") + names := make([]string, 0, len(exportableTools)) + for _, id := range exportableTools { + if t, ok := tools.ByID(id); ok { + names = append(names, t.Name) + } + } + b.WriteString(strings.Join(names, ", ")) + b.WriteString("\n\n") + b.WriteString(Styles.Desc.Render("Install one of the tools above and run kimchi recipe export again.")) + b.WriteString("\n") + return b.String() + } + + b.WriteString("Select the tool to export a recipe for.\n\n") + + for i, t := range s.available { + cursor := " " + if s.selected == i { + cursor = Styles.Cursor.Render("► ") + } + radio := "○" + if s.selected == i { + radio = Styles.Selected.Render("●") + } + line := fmt.Sprintf("%s %s %-12s %s", cursor, radio, t.Name, Styles.Desc.Render(t.Description)) + b.WriteString(line) + b.WriteString("\n") + } + + return b.String() +} + +func (s *ExportToolStep) Name() string { return "Select Tool" } + +func (s *ExportToolStep) Info() StepInfo { + bindings := []KeyBinding{BindingsQuit} + if len(s.available) > 0 { + bindings = []KeyBinding{BindingsNavigate, BindingsConfirm, BindingsBack, BindingsQuit} + } + return StepInfo{ + Name: "Select Tool", + KeyBindings: bindings, + } +} diff --git a/internal/tui/steps/export_usecase.go b/internal/tui/steps/export_usecase.go new file mode 100644 index 0000000..118d3c8 --- /dev/null +++ b/internal/tui/steps/export_usecase.go @@ -0,0 +1,95 @@ +package steps + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type useCaseOption struct { + value string + label string + desc string +} + +var useCaseOptions = []useCaseOption{ + {"coding", "Coding", "Focused on code generation and debugging"}, + {"research", "Research", "Deep reasoning and analysis tasks"}, + {"balanced", "Balanced", "Mix of coding and reasoning"}, + {"custom", "Custom", "Define your own use case"}, +} + +// ExportUseCaseStep is a radio-select step for the recipe's use_case tag. +type ExportUseCaseStep struct { + selected int +} + +func NewExportUseCaseStep() *ExportUseCaseStep { + return &ExportUseCaseStep{selected: 0} +} + +func (s *ExportUseCaseStep) SelectedUseCase() string { + return useCaseOptions[s.selected].value +} + +func (s *ExportUseCaseStep) Init() tea.Cmd { return nil } + +func (s *ExportUseCaseStep) Update(msg tea.Msg) (Step, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return s, func() tea.Msg { return AbortMsg{} } + case "esc": + return s, func() tea.Msg { return PrevStepMsg{} } + case "up", "k": + if s.selected > 0 { + s.selected-- + } + case "down", "j": + if s.selected < len(useCaseOptions)-1 { + s.selected++ + } + case "enter": + return s, func() tea.Msg { return NextStepMsg{} } + } + } + return s, nil +} + +func (s *ExportUseCaseStep) View() string { + var b strings.Builder + + b.WriteString("How will this recipe primarily be used?\n\n") + + for i, opt := range useCaseOptions { + cursor := " " + if s.selected == i { + cursor = Styles.Cursor.Render("► ") + } + radio := "○" + if s.selected == i { + radio = Styles.Selected.Render("●") + } + line := fmt.Sprintf("%s %s %-10s %s", cursor, radio, opt.label, Styles.Desc.Render(opt.desc)) + b.WriteString(line) + b.WriteString("\n") + } + + return b.String() +} + +func (s *ExportUseCaseStep) Name() string { return "Use Case" } + +func (s *ExportUseCaseStep) Info() StepInfo { + return StepInfo{ + Name: "Use Case", + KeyBindings: []KeyBinding{ + BindingsNavigate, + BindingsConfirm, + BindingsBack, + BindingsQuit, + }, + } +}