Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions cmd/recipe.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
145 changes: 145 additions & 0 deletions internal/recipe/builder.go
Original file line number Diff line number Diff line change
@@ -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
}
29 changes: 29 additions & 0 deletions internal/recipe/export.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading