diff --git a/COOKBOOK_AND_RECIPES_GUIDE.md b/COOKBOOK_AND_RECIPES_GUIDE.md new file mode 100644 index 0000000..c1be149 --- /dev/null +++ b/COOKBOOK_AND_RECIPES_GUIDE.md @@ -0,0 +1,705 @@ +# Kimchi Cookbook and Recipes - User Guide + +## Overview + +The **Cookbook and Recipes** feature in Kimchi allows you to: + +- **Export** your current AI tool configuration as a portable, versioned recipe +- **Share** configurations with your team via Git-based cookbooks +- **Install** pre-configured recipes from cookbooks +- **Manage** multiple AI tool setups with version control and upgrade capabilities +- **Fork** and customize existing recipes +- **Backup and restore** tool configurations + +--- + +## Table of Contents + +1. [Installing Kimchi from This Branch](#installing-kimchi-from-this-branch) +2. [Core Concepts](#core-concepts) +3. [Quick Start](#quick-start) +4. [Managing Cookbooks](#managing-cookbooks) +5. [Working with Recipes](#working-with-recipes) +6. [Recipe Installation](#recipe-installation) +7. [Recipe Export and Publishing](#recipe-export-and-publishing) +8. [Upgrading and Maintenance](#upgrading-and-maintenance) +9. [Backup and Restore](#backup-and-restore) +10. [Troubleshooting](#troubleshooting) + +--- + +## Installing Kimchi from This Branch + +Since this feature is on a development branch (`LLM-1212-add-kimchi-cookbook`), you need to build and install directly from source. + +### Prerequisites + +- Go 1.23 or later installed +- Git configured with your credentials +- Cast AI API key (get at https://kimchi.console.cast.ai) + +> **Important:** Before running any `kimchi` command, set the following environment variable to prevent auto-update from replacing this pre-release binary with the latest official release (which does not include cookbook and recipe commands): +> +> ```bash +> export KIMCHI_NO_AUTO_UPDATE=1 +> ``` + +### Build from Source + +```bash +# Clone the repository +git clone https://github.com/castai/kimchi.git +cd kimchi + +# Checkout the cookbook branch +git checkout LLM-1212-add-kimchi-cookbook + +# Build the binary +make build + +# Install to /usr/local/bin (may require sudo) +make install + +# Or install to ~/.local/bin +mkdir -p ~/.local/bin +cp bin/kimchi ~/.local/bin/ +``` + +### Verify Installation + +```bash +kimchi version +kimchi --help +``` + +--- + +## Core Concepts + +### What is a Recipe? + +A **recipe** is a YAML file that captures a complete AI tool configuration including: + +- Provider settings and model configurations +- Agent configurations +- MCP (Model Context Protocol) server settings +- Custom skills and commands +- Theme files and plugins +- Referenced documentation files + +Example recipe structure: +```yaml +name: my-awesome-config +version: 0.1.0 +cookbook: my-team-cookbook +author: johndoe +description: Optimized OpenCode config for Go development +tags: ["go", "backend", "microservices"] +model: kimi-k2.5 +use_case: coding +tools: + opencode: + providers: + kimchi: + name: Kimchi by Cast AI + options: + apiKey: kimchi:secret:KIMCHI_APIKEY + baseURL: https://llm.cast.ai/openai/v1 + model: kimchi/kimi-k2.5 + # ... more configuration +``` + +### What is a Cookbook? + +A **cookbook** is a Git repository that stores and versions recipes. It provides: + +- Centralized recipe storage +- Version control via Git tags +- Team collaboration +- Recipe discovery and search + +Cookbook structure: +``` +my-cookbook/ +├── .kimchi/ +│ └── cookbook.yaml # Cookbook metadata +├── recipes/ +│ ├── python-debugger/ +│ │ └── recipe.yaml # A recipe +│ ├── go-backend/ +│ │ └── recipe.yaml +│ └── react-frontend/ +│ └── recipe.yaml +└── README.md +``` + +--- + +## Quick Start + +### 1. List Available Recipes + +See what recipes are available from registered cookbooks: + +```bash +kimchi recipe list +``` + +Output example: +``` +NAME VERSION COOKBOOK AUTHOR TOOLS INSTALLED +---- ------- -------- ------ ----- --------- +python-debugger 1.2.0 castai-default team opencode ✓ +go-backend 0.5.0 castai-default johndoe opencode +react-frontend 2.1.0 castai-default janedoe opencode +``` + +### 2. Search for Recipes + +Find recipes by name, description, or tags: + +```bash +kimchi recipe search python +kimchi recipe search backend +``` + +### 3. View Recipe Details + +```bash +kimchi recipe info python-debugger +kimchi recipe info castai-default/python-debugger +``` + +### 4. Install a Recipe + +Install a recipe interactively: + +```bash +kimchi recipe install python-debugger +``` + +Or preview without applying: + +```bash +kimchi recipe install python-debugger --no-apply +``` + +### 5. Export Your Current Configuration + +Save your current setup as a recipe: + +```bash +kimchi recipe export +``` + +--- + +## Managing Cookbooks + +### View Registered Cookbooks + +```bash +kimchi cookbook list +``` + +### Add a Cookbook + +Register a cookbook from a Git URL: + +```bash +# Add from a GitHub repository +kimchi cookbook add https://github.com/organization/team-cookbook.git + +# Add with custom name +kimchi cookbook add https://github.com/organization/team-cookbook.git --name team-recipes +``` + +Cookbooks are cloned to `~/.kimchi/cookbooks//` + +### Create a New Cookbook + +Scaffold and register a new cookbook: + +```bash +# Create cookbook and push to remote (remote must already exist) +kimchi cookbook create https://github.com/myorg/my-cookbook.git +``` + +This creates: +- `recipes/` directory for storing recipes +- `.kimchi/cookbook.yaml` metadata file +- `README.md` with basic documentation + +### Update Cookbooks + +Pull the latest changes from all registered cookbooks: + +```bash +# Update all cookbooks +kimchi cookbook update + +# Update specific cookbook +kimchi cookbook update my-team-cookbook +``` + +### Default Cookbook + +Kimchi automatically includes a default cookbook (currently pointing to `https://github.com/castai/kimchi-cookbook.git`). This is stored in `~/.kimchi/cookbooks/kimchi-cookbook/`. + +To disable the default cookbook, set: +```bash +export KIMCHI_DEFAULT_COOKBOOK_URL="" +``` + +--- + +## Working with Recipes + +### Recipe Naming Conventions + +Recipes can be referenced in multiple ways: + +```bash +# By name (searches all cookbooks) +kimchi recipe install python-debugger + +# By name with version +kimchi recipe install python-debugger@1.2.0 + +# By cookbook/name +kimchi recipe install castai-default/python-debugger + +# From local file +kimchi recipe install ./my-recipe.yaml +``` + +### Recipe Versions + +Recipes follow semantic versioning (semver): +- `1.2.3` - Major.Minor.Patch +- Major: Breaking changes +- Minor: New features (backwards compatible) +- Patch: Bug fixes (backwards compatible) + +### Recipe Metadata Fields + +| Field | Description | +|-------|-------------| +| `name` | Unique recipe identifier | +| `version` | Semantic version (e.g., 0.1.0) | +| `cookbook` | Target cookbook for publishing | +| `author` | Recipe author | +| `description` | Short description | +| `tags` | Array of searchable tags | +| `model` | Primary model used | +| `use_case` | Intended use case | +| `created_at` | Creation timestamp | +| `updated_at` | Last update timestamp | + +--- + +## Recipe Installation + +### Interactive Installation + +The recommended way to install recipes: + +```bash +kimchi recipe install [source] +``` + +The wizard will guide you through: +1. **Recipe Source** - Enter a recipe name, version, or file path +2. **Recipe Preview** - Review what the recipe contains +3. **Select Assets** - Choose which assets to install +4. **Auth** *(if no API key stored)* - Authenticate with Cast AI +5. **Enter Secrets** *(if required)* - Enter third-party API keys +6. **Installing** - Apply the configuration + +### Headless Installation + +For automated/scripted installations: + +```bash +# Using --yes flag (when available via upgrade) +kimchi recipe upgrade --yes +``` + +### Installation Sources + +```bash +# From registered cookbook +kimchi recipe install python-debugger + +# Specific version +kimchi recipe install python-debugger@1.2.0 + +# From file +kimchi recipe install ./custom-config.yaml + +# From URL +kimchi recipe install https://example.com/recipes/custom.yaml +``` + +### List Installed Recipes + +```bash +kimchi recipe list --installed +``` + +Output: +``` +NAME VERSION COOKBOOK TOOL PINNED INSTALLED AT +---- ------- -------- ---- ------ ------------ +python-debugger 1.2.0 castai-default opencode ✓ 2026-04-08 10:30 +``` + +### Pinning Recipes + +Prevent a recipe from being upgraded: + +```bash +kimchi recipe pin python-debugger +``` + +Unpin to allow upgrades: + +```bash +kimchi recipe unpin python-debugger +``` + +--- + +## Recipe Export and Publishing + +### Export Your Configuration + +Save your current AI tool setup as a recipe: + +```bash +# Interactive export wizard +kimchi recipe export + +# Specify output file +kimchi recipe export -o my-config.yaml + +# Quick export with name +kimchi recipe export --name my-go-config --tag go --tag backend + +# Dry run (preview only) +kimchi recipe export --dry-run +``` + +The export wizard guides you through: +1. **Select Tool** - Which tool configuration to export +2. **Config Scope** - What configuration to include +3. **Recipe Metadata** - Name, description, tags +4. **Export Use Case** - Intended use case for the recipe +5. **Include Assets** - Select additional files (skills, commands, etc.) +6. **Output** *(if not provided via `-o`)* - Choose save location + +### Fork a Recipe + +Create your own customizable copy of an existing recipe: + +```bash +# Fork by name +kimchi recipe fork python-debugger + +# Fork with new name +kimchi recipe fork python-debugger --name my-python-debugger + +# Fork to specific file +kimchi recipe fork python-debugger -o ./my-fork.yaml +``` + +Forked recipes: +- Start at version `0.1.0` +- Have no cookbook assigned (resolved on first push) +- Include `forked_from` metadata tracking the origin + +### Push a Recipe to Cookbook + +Publish your recipe to a cookbook: + +```bash +# Push with automatic version bump selection +kimchi recipe push my-recipe.yaml + +# Bump patch version (1.2.3 → 1.2.4) +kimchi recipe push my-recipe.yaml --patch + +# Bump minor version (1.2.3 → 1.3.0) +kimchi recipe push my-recipe.yaml --minor + +# Bump major version (1.2.3 → 2.0.0) +kimchi recipe push my-recipe.yaml --major + +# Metadata-only push (no version bump) +kimchi recipe push my-recipe.yaml --meta + +# Push to specific cookbook +kimchi recipe push my-recipe.yaml --cookbook my-team-cookbook + +# Preview push without applying +kimchi recipe push my-recipe.yaml --dry-run +``` + +**Version Bump Guidelines:** +- `--patch` - Bug fixes, documentation updates +- `--minor` - New features, backwards compatible +- `--major` - Breaking changes +- `--meta` - Only metadata changed (description, tags) + +**Fork & PR Workflow:** +If you don't have write access to the cookbook, Kimchi will: +1. Authenticate via GitHub device flow +2. Fork the repository +3. Push your changes +4. Open a pull request + +--- + +## Upgrading and Maintenance + +### Upgrade All Recipes + +Check for and install updates: + +```bash +# Update cookbooks and upgrade recipes (interactive) +kimchi upgrade + +# Dry run - see what would be updated +kimchi upgrade --dry-run +``` + +### Upgrade Specific Recipe + +```bash +kimchi recipe upgrade python-debugger +``` + +### Update Cookbooks Only + +Pull latest recipe definitions: + +```bash +kimchi update +``` + +Or specifically: + +```bash +kimchi cookbook update +``` + +### Automatic Updates + +Kimchi automatically updates cookbooks once per day. To disable: + +```bash +export KIMCHI_NO_AUTO_UPDATE=1 +``` + +--- + +## Backup and Restore + +Kimchi automatically creates backups before installing recipes. + +### Automatic Backups + +Before each recipe installation, Kimchi backs up the current tool configuration to: +- `~/.kimchi/backups/` (for all tools) + +### Restore from Backup + +Open the interactive restore wizard: + +```bash +kimchi recipe restore +``` + +The wizard shows: +- **Baseline** - State before first recipe installation +- **Recipe backups** - Named backups from recipe installs +- Timestamps for each backup + +Select a backup to restore, then confirm the operation. + +### Backup Contents + +Backups include: +- Full tool configuration files +- Custom skills and commands +- Theme files +- Plugin configurations +- Referenced documentation + +--- + +## Troubleshooting + +### Common Issues + +#### Recipe Not Found + +```bash +# Make sure cookbooks are up to date +kimchi cookbook update + +# Search for the recipe +kimchi recipe search + +# List all available recipes +kimchi recipe list +``` + +#### Push Fails - Permission Denied + +Kimchi will automatically fork and create a PR if you don't have write access. Make sure: +- GitHub CLI (`gh`) is installed, OR +- You're prepared to authenticate via browser + +#### Version Conflict + +```bash +# Check current recipe version +kimchi recipe info + +# Use --patch, --minor, or --major to bump +kimchi recipe push --patch +``` + +#### Installation Conflicts + +Kimchi automatically backs up and removes existing config files before installing, so conflicts are handled transparently. If something goes wrong: +1. Use `--no-apply` to preview what would be installed +2. Use `kimchi recipe restore` to go back to the baseline or a previous state + +### Getting Help + +```bash +# General help +kimchi --help + +# Command-specific help +kimchi recipe --help +kimchi recipe install --help +kimchi cookbook --help + +# Enable debug output +kimchi --debug recipe install +``` + +### Configuration Locations + +| File/Directory | Purpose | +|----------------|---------| +| `~/.kimchi/cookbooks.json` | Registered cookbooks list | +| `~/.kimchi/cookbooks/` | Cloned cookbook repositories | +| `~/.kimchi/installed/` | Installed recipe metadata | +| `~/.kimchi/backups/` | Tool configuration backups | + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `KIMCHI_API_KEY` | Cast AI API key | +| `KIMCHI_DEFAULT_COOKBOOK_URL` | URL for default cookbook (set to empty to disable) | +| `KIMCHI_NO_AUTO_UPDATE` | Set to `1` to disable automatic updates | + +--- + +## Example Workflows + +### Team Setup Workflow + +```bash +# 1. Add team cookbook +kimchi cookbook add https://github.com/myorg/team-cookbook.git + +# 2. List available team recipes +kimchi recipe list --cookbook team-cookbook + +# 3. Install team standard config +kimchi recipe install team-standard + +# 4. Pin important recipes +kimchi recipe pin team-standard +``` + +### Recipe Development Workflow + +```bash +# 1. Start with a good base +kimchi recipe fork team-standard --name my-custom-config + +# 2. Modify the YAML as needed +vim my-custom-config.yaml + +# 3. Test locally +kimchi recipe install ./my-custom-config.yaml --no-apply + +# 4. Create cookbook if needed +kimchi cookbook create https://github.com/myorg/personal-cookbook.git + +# 5. Push initial version +kimchi recipe push ./my-custom-config.yaml --minor + +# 6. Make updates and push patches +kimchi recipe push ./my-custom-config.yaml --patch +``` + +### Migration Workflow + +```bash +# 1. Export current configuration +kimchi recipe export -o legacy-config.yaml + +# 2. Test on new machine +# (On new machine) +kimchi recipe install ./legacy-config.yaml + +# 3. Iterate and publish +kimchi recipe fork legacy-config.yaml --name production-config +kimchi recipe push production-config.yaml --cookbook team-cookbook --minor +``` + +--- + +## Summary of Commands + +| Command | Description | +|---------|-------------| +| `kimchi cookbook list` | List registered cookbooks | +| `kimchi cookbook add ` | Add a cookbook | +| `kimchi cookbook create ` | Create and scaffold new cookbook | +| `kimchi cookbook update [name]` | Update cookbook(s) | +| `kimchi recipe list` | List available recipes | +| `kimchi recipe list --installed` | List installed recipes | +| `kimchi recipe search ` | Search recipes | +| `kimchi recipe info ` | Show recipe details | +| `kimchi recipe install ` | Install a recipe | +| `kimchi recipe export` | Export current config | +| `kimchi recipe fork ` | Fork a recipe | +| `kimchi recipe push ` | Push recipe to cookbook | +| `kimchi recipe upgrade [name]` | Upgrade recipe(s) | +| `kimchi recipe pin ` | Pin a recipe version | +| `kimchi recipe unpin ` | Unpin a recipe | +| `kimchi recipe restore` | Restore from backup | +| `kimchi update` | Update cookbooks and kimchi | +| `kimchi upgrade` | Update cookbooks and upgrade recipes | + +--- + +## Feedback and Support + +This feature is currently in development on the `LLM-1212-add-kimchi-cookbook` branch. For issues or feedback: + +1. Check existing issues on the GitHub repository +2. Create a new issue with the `cookbook` label +3. Include the output of `kimchi version` and `kimchi --debug` if applicable + +Happy cooking! 🥬 diff --git a/cmd/auth.go b/cmd/auth.go new file mode 100644 index 0000000..e2bfc59 --- /dev/null +++ b/cmd/auth.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/castai/kimchi/internal/tui" +) + +func NewAuthCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Manage Cast AI authentication", + } + cmd.AddCommand(NewAuthLoginCommand()) + return cmd +} + +func NewAuthLoginCommand() *cobra.Command { + return &cobra.Command{ + Use: "login", + Short: "Authenticate with Cast AI and save your API key", + RunE: func(cmd *cobra.Command, args []string) error { + return tui.RunAuthWizard() + }, + } +} diff --git a/cmd/cookbook.go b/cmd/cookbook.go new file mode 100644 index 0000000..57df13e --- /dev/null +++ b/cmd/cookbook.go @@ -0,0 +1,184 @@ +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/castai/kimchi/internal/cookbook" +) + +func NewCookbookCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "cookbook", + Short: "Manage recipe cookbooks", + } + cmd.AddCommand(NewCookbookAddCommand()) + cmd.AddCommand(NewCookbookCreateCommand()) + cmd.AddCommand(NewCookbookListCommand()) + cmd.AddCommand(NewCookbookUpdateCommand()) + return cmd +} + +func NewCookbookAddCommand() *cobra.Command { + var name string + + cmd := &cobra.Command{ + Use: "add ", + Short: "Clone and register a cookbook from a git URL", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + url := args[0] + if name == "" { + name = cookbook.NameFromURL(url) + } + + cloneBase, err := cookbook.DefaultCloneDir() + if err != nil { + return err + } + destDir := filepath.Join(cloneBase, name) + + if cookbook.IsRepo(destDir) { + fmt.Fprintf(cmd.OutOrStdout(), "Directory %s already exists, skipping clone.\n", destDir) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s into %s…\n", url, destDir) + if err := cookbook.Clone(url, destDir); err != nil { + return fmt.Errorf("clone cookbook: %w", err) + } + } + + cb := cookbook.Cookbook{Name: name, URL: url, Path: destDir} + if err := cookbook.Add(cb); err != nil { + return fmt.Errorf("register cookbook: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ Cookbook %q registered (%s)\n", name, destDir) + return nil + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Override the cookbook name (defaults to repo name)") + return cmd +} + +func NewCookbookCreateCommand() *cobra.Command { + var name string + + cmd := &cobra.Command{ + Use: "create ", + Short: "Scaffold a new cookbook and register it", + Long: `Create scaffolds a minimal cookbook structure in a new local directory, +pushes it to the given remote URL, and registers it. + +The remote repository must already exist (empty or otherwise).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + url := args[0] + if name == "" { + name = cookbook.NameFromURL(url) + } + + cloneBase, err := cookbook.DefaultCloneDir() + if err != nil { + return err + } + destDir := filepath.Join(cloneBase, name) + + if cookbook.IsRepo(destDir) { + fmt.Fprintf(cmd.OutOrStdout(), "Directory %s already exists, skipping clone.\n", destDir) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s into %s…\n", url, destDir) + if err := cookbook.Clone(url, destDir); err != nil { + return fmt.Errorf("clone remote: %w", err) + } + } + + fmt.Fprintln(cmd.OutOrStdout(), "Scaffolding cookbook structure…") + if err := cookbook.Scaffold(destDir, name); err != nil { + return fmt.Errorf("scaffold: %w", err) + } + + cb := cookbook.Cookbook{Name: name, URL: url, Path: destDir} + if err := cookbook.Add(cb); err != nil { + return fmt.Errorf("register cookbook: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ Cookbook %q created and registered (%s)\n", name, destDir) + return nil + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Override the cookbook name (defaults to repo name)") + return cmd +} + +func NewCookbookListCommand() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List registered cookbooks", + RunE: func(cmd *cobra.Command, args []string) error { + cookbooks, err := cookbook.Load() + if err != nil { + return err + } + if len(cookbooks) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No cookbooks registered. Use `kimchi cookbook add ` to add one.") + return nil + } + w := cmd.OutOrStdout() + fmt.Fprintf(w, "%-20s %-50s %s\n", "NAME", "URL", "PATH") + fmt.Fprintf(w, "%-20s %-50s %s\n", "----", "---", "----") + for _, cb := range cookbooks { + name := cb.Name + if cookbook.IsDefault(cb.Name) { + name += " (default)" + } + fmt.Fprintf(w, "%-20s %-50s %s\n", name, cb.URL, cb.Path) + } + return nil + }, + } +} + +func NewCookbookUpdateCommand() *cobra.Command { + var name string + + cmd := &cobra.Command{ + Use: "update [name]", + Short: "Pull latest changes for registered cookbooks", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + name = args[0] + } + + cookbooks, err := cookbook.Load() + if err != nil { + return err + } + + updated := 0 + for _, cb := range cookbooks { + if name != "" && cb.Name != name { + continue + } + fmt.Fprintf(cmd.OutOrStdout(), "Updating %s…\n", cb.Name) + if err := cookbook.Pull(cb.Path); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), " warning: %v\n", err) + continue + } + fmt.Fprintf(cmd.OutOrStdout(), " ✓ %s updated\n", cb.Name) + updated++ + } + + if updated == 0 && name != "" { + return fmt.Errorf("cookbook %q not found", name) + } + return nil + }, + } + + return cmd +} diff --git a/cmd/recipe.go b/cmd/recipe.go new file mode 100644 index 0000000..a01ebac --- /dev/null +++ b/cmd/recipe.go @@ -0,0 +1,503 @@ +package cmd + +import ( + "fmt" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/castai/kimchi/internal/recipe" + "github.com/castai/kimchi/internal/tui" +) + +func NewRecipeCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "recipe", + Short: "Manage kimchi recipes", + } + cmd.AddCommand(NewRecipeExportCommand()) + cmd.AddCommand(NewRecipeInstallCommand()) + cmd.AddCommand(NewRecipePushCommand()) + cmd.AddCommand(NewRecipeForkCommand()) + cmd.AddCommand(NewRecipeListCommand()) + cmd.AddCommand(NewRecipeSearchCommand()) + cmd.AddCommand(NewRecipeInfoCommand()) + cmd.AddCommand(NewRecipeUpgradeCommand()) + cmd.AddCommand(NewRecipePinCommand()) + cmd.AddCommand(NewRecipeUnpinCommand()) + cmd.AddCommand(NewRecipeRestoreCommand()) + return cmd +} + +// ── install ────────────────────────────────────────────────────────────────── + +func NewRecipeInstallCommand() *cobra.Command { + var noApply bool + + cmd := &cobra.Command{ + Use: "install [source]", + Short: "Install a recipe", + Long: `Install a recipe from a local file or a registered cookbook. + +Examples: + kimchi recipe install ./my-recipe.yaml local file + kimchi recipe install python-debugger by name + kimchi recipe install python-debugger@1.2.0 specific version + kimchi recipe install alice/python-debugger cookbook/name`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + source := "" + if len(args) > 0 { + source = args[0] + } + return tui.RunInstallWizard(tui.InstallWizardOptions{ + Source: source, + NoApply: noApply, + }) + }, + } + + cmd.Flags().BoolVar(&noApply, "no-apply", false, "Preview the recipe without writing any files") + return cmd +} + +// ── export ─────────────────────────────────────────────────────────────────── + +func NewRecipeExportCommand() *cobra.Command { + var outputPath string + var name string + var tags []string + var dryRun bool + + cmd := &cobra.Command{ + Use: "export", + Short: "Export your current AI tool configuration as a recipe", + RunE: func(cmd *cobra.Command, args []string) error { + return tui.RunExportWizard(tui.ExportWizardOptions{ + OutputPath: outputPath, + Name: name, + Tags: tags, + DryRun: dryRun, + }) + }, + } + + cmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output file path (default: prompted in wizard)") + cmd.Flags().StringVar(&name, "name", "", "Recipe name (skips the name prompt)") + cmd.Flags().StringArrayVar(&tags, "tag", nil, "Tag to add to the recipe (repeatable)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print the recipe to stdout without writing a file") + return cmd +} + +// ── push ───────────────────────────────────────────────────────────────────── + +func NewRecipePushCommand() *cobra.Command { + var ( + cookbookName string + patch bool + minor bool + major bool + meta bool + dryRun bool + ) + + cmd := &cobra.Command{ + Use: "push ", + Short: "Publish a recipe to its cookbook", + Long: `Push commits a recipe to its target cookbook and creates a version tag. + +If you do not have write access to the cookbook's remote, kimchi will +authenticate with GitHub via device flow, fork the repo, and open a pull request. + +Version bump flags: + --patch 1.2.3 → 1.2.4 (backwards-compatible bug fixes) + --minor 1.2.3 → 1.3.0 (new functionality) + --major 1.2.3 → 2.0.0 (breaking changes) + --meta metadata-only change, no version bump required`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + count := 0 + for _, b := range []bool{patch, minor, major} { + if b { + count++ + } + } + if count > 1 { + return fmt.Errorf("specify at most one of --patch, --minor, --major") + } + + return recipe.Push(recipe.PushOptions{ + File: args[0], + CookbookName: cookbookName, + Patch: patch, + Minor: minor, + Major: major, + Meta: meta, + DryRun: dryRun, + }, func(msg string) { + fmt.Fprintln(cmd.OutOrStdout(), msg) + }) + }, + } + + cmd.Flags().StringVar(&cookbookName, "cookbook", "", "Cookbook to push to (default: from recipe yaml or auto-selected)") + cmd.Flags().BoolVar(&patch, "patch", false, "Bump patch version") + cmd.Flags().BoolVar(&minor, "minor", false, "Bump minor version") + cmd.Flags().BoolVar(&major, "major", false, "Bump major version") + cmd.Flags().BoolVar(&meta, "meta", false, "Metadata-only push (no version bump required)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be pushed without making changes") + return cmd +} + +// ── fork ───────────────────────────────────────────────────────────────────── + +func NewRecipeForkCommand() *cobra.Command { + var ( + newName string + outputPath string + ) + + cmd := &cobra.Command{ + Use: "fork ", + Short: "Fork a recipe to create your own customisable copy", + Long: `Fork copies a recipe and marks it as forked from the original. +The forked copy starts at version 0.1.0 and has no cookbook set +(the cookbook will be resolved on first push). + +Source may be a file path, recipe name, cookbook/name, or name@version.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + outPath, err := recipe.Fork(recipe.ForkOptions{ + Source: args[0], + NewName: newName, + OutputPath: outputPath, + }) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "✓ Forked recipe written to %s\n", outPath) + return nil + }, + } + + cmd.Flags().StringVar(&newName, "name", "", "Override the recipe name in the fork") + cmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output file path (default: .yaml)") + return cmd +} + +// ── list ───────────────────────────────────────────────────────────────────── + +func NewRecipeListCommand() *cobra.Command { + var cookbookFilter string + var installed bool + + cmd := &cobra.Command{ + Use: "list", + Short: "List available recipes from registered cookbooks", + RunE: func(cmd *cobra.Command, args []string) error { + if installed { + return runRecipeListInstalled(cmd, cookbookFilter) + } + + refs, err := recipe.ListAll() + if err != nil { + return err + } + if len(refs) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No recipes found. Use `kimchi cookbook add ` to register a cookbook.") + return nil + } + + // Build a set of installed recipe names for the INSTALLED column. + installedSet := map[string]bool{} + if list, err := recipe.LoadInstalled(); err == nil { + for _, r := range list { + installedSet[r.Name] = true + } + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tVERSION\tCOOKBOOK\tAUTHOR\tTOOLS\tINSTALLED") + fmt.Fprintln(w, "----\t-------\t--------\t------\t-----\t---------") + for _, r := range refs { + if cookbookFilter != "" && r.Cookbook != cookbookFilter { + continue + } + installed := "" + if installedSet[r.Name] { + installed = "✓" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", r.Name, r.Version, r.Cookbook, r.Author, r.Tools, installed) + } + return w.Flush() + }, + } + + cmd.Flags().StringVar(&cookbookFilter, "cookbook", "", "Filter by cookbook name") + cmd.Flags().BoolVar(&installed, "installed", false, "Show only installed recipes") + return cmd +} + +func runRecipeListInstalled(cmd *cobra.Command, cookbookFilter string) error { + list, err := recipe.LoadInstalled() + if err != nil { + return err + } + if len(list) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No recipes installed.") + return nil + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tVERSION\tCOOKBOOK\tTOOL\tPINNED\tINSTALLED AT") + fmt.Fprintln(w, "----\t-------\t--------\t----\t------\t------------") + for _, r := range list { + if cookbookFilter != "" && r.Cookbook != cookbookFilter { + continue + } + pinned := "" + if r.Pinned { + pinned = "✓" + } + tool := string(r.Tool) + if tool == "" { + tool = "unknown" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + r.Name, r.Version, r.Cookbook, tool, pinned, + r.InstalledAt.Format("2006-01-02 15:04"), + ) + } + return w.Flush() +} + +// ── search ─────────────────────────────────────────────────────────────────── + +func NewRecipeSearchCommand() *cobra.Command { + return &cobra.Command{ + Use: "search ", + Short: "Search recipes by name, description, or tag", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + refs, err := recipe.Search(args[0]) + if err != nil { + return err + } + if len(refs) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No recipes found matching %q\n", args[0]) + return nil + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tVERSION\tCOOKBOOK\tAUTHOR\tTOOLS") + fmt.Fprintln(w, "----\t-------\t--------\t------\t-----") + for _, r := range refs { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Name, r.Version, r.Cookbook, r.Author, r.Tools) + } + return w.Flush() + }, + } +} + +// ── info ───────────────────────────────────────────────────────────────────── + +func NewRecipeInfoCommand() *cobra.Command { + return &cobra.Command{ + Use: "info ", + Short: "Show details about a recipe", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + r, err := recipe.ResolveSource(args[0]) + if err != nil { + return err + } + + w := cmd.OutOrStdout() + fmt.Fprintf(w, "Name: %s\n", r.Name) + fmt.Fprintf(w, "Version: %s\n", r.Version) + if r.Author != "" { + fmt.Fprintf(w, "Author: %s\n", r.Author) + } + if r.Cookbook != "" { + fmt.Fprintf(w, "Cookbook: %s\n", r.Cookbook) + } + if tools := strings.Join(r.Tools.SupportedToolNames(), ", "); tools != "" { + fmt.Fprintf(w, "Tools: %s\n", tools) + } + if r.Description != "" { + fmt.Fprintf(w, "Description: %s\n", r.Description) + } + if len(r.Tags) > 0 { + fmt.Fprintf(w, "Tags: %s\n", strings.Join(r.Tags, ", ")) + } + if r.UseCase != "" { + fmt.Fprintf(w, "Use case: %s\n", r.UseCase) + } + if r.ForkedFrom != nil { + fmt.Fprintf(w, "Forked from: %s/%s@%s\n", r.ForkedFrom.Author, r.ForkedFrom.Cookbook, r.ForkedFrom.Version) + } + if r.CreatedAt != "" { + fmt.Fprintf(w, "Created: %s\n", r.CreatedAt) + } + if r.UpdatedAt != "" { + fmt.Fprintf(w, "Updated: %s\n", r.UpdatedAt) + } + return nil + }, + } +} + +// ── upgrade ────────────────────────────────────────────────────────────────── + +func NewRecipeUpgradeCommand() *cobra.Command { + var ( + cookbookFilter string + dryRun bool + yes bool + ) + + cmd := &cobra.Command{ + Use: "upgrade [name]", + Short: "Upgrade installed recipes to their latest cookbook versions", + Long: `Upgrade checks all installed (non-pinned) recipes for newer versions +in their cookbooks and offers to reinstall them. + +When a recipe name is provided only that recipe is checked. + +With --yes, recipes are upgraded non-interactively: existing files are +overwritten and recipes requiring external secrets are skipped with a warning.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + installed, err := recipe.LoadInstalled() + if err != nil { + return err + } + if len(installed) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No installed recipes found.") + return nil + } + + type upgrade struct { + installed recipe.InstalledRecipe + latest recipe.RecipeRef + } + + var upgrades []upgrade + for _, inst := range installed { + if len(args) > 0 && inst.Name != args[0] { + continue + } + if cookbookFilter != "" && inst.Cookbook != cookbookFilter { + continue + } + if inst.Pinned { + continue + } + source := inst.Name + if inst.Cookbook != "" { + source = inst.Cookbook + "/" + inst.Name + } + ref, err := recipe.FindRecipe(source) + if err != nil { + continue + } + if recipe.CompareVersions(ref.Version, inst.Version) > 0 { + upgrades = append(upgrades, upgrade{installed: inst, latest: *ref}) + } + } + + if len(upgrades) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "All recipes are up to date.") + return nil + } + + w := cmd.OutOrStdout() + fmt.Fprintln(w, "Available upgrades:") + for _, u := range upgrades { + fmt.Fprintf(w, " %-30s %s → %s\n", u.installed.Name, u.installed.Version, u.latest.Version) + } + + if dryRun { + return nil + } + + if yes { + for _, u := range upgrades { + fmt.Fprintf(w, "\nUpgrading %s@%s…\n", u.latest.Name, u.latest.Version) + err := recipe.InstallHeadless(recipe.HeadlessInstallOptions{ + Source: u.latest.Path, + }) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), " skipped: %v\n", err) + } else { + fmt.Fprintf(w, " ✓ %s upgraded to %s\n", u.latest.Name, u.latest.Version) + } + } + return nil + } + + for _, u := range upgrades { + fmt.Fprintf(w, "\nInstalling %s@%s…\n", u.latest.Name, u.latest.Version) + if err := tui.RunInstallWizard(tui.InstallWizardOptions{ + Source: u.latest.Path, + }); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), " error: %v\n", err) + } + } + return nil + }, + } + + cmd.Flags().StringVar(&cookbookFilter, "cookbook", "", "Limit upgrades to a specific cookbook") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show available upgrades without installing") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Upgrade all non-interactively, overwriting existing files") + return cmd +} + +// ── pin / unpin ─────────────────────────────────────────────────────────────── + +func NewRecipePinCommand() *cobra.Command { + return &cobra.Command{ + Use: "pin ", + Short: "Pin a recipe to its current version (prevents upgrade)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := recipe.Pin(args[0]); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "✓ %s pinned\n", args[0]) + return nil + }, + } +} + +func NewRecipeUnpinCommand() *cobra.Command { + return &cobra.Command{ + Use: "unpin ", + Short: "Unpin a recipe so it can be upgraded", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := recipe.Unpin(args[0]); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "✓ %s unpinned\n", args[0]) + return nil + }, + } +} + +// ── restore ─────────────────────────────────────────────────────────────────── + +func NewRecipeRestoreCommand() *cobra.Command { + return &cobra.Command{ + Use: "restore", + Short: "Restore a tool config from a previous backup", + Long: `Open an interactive picker to browse all backup slots created by kimchi +and restore one of them. The baseline slot captures the state before the +first recipe was ever installed for a tool.`, + RunE: func(cmd *cobra.Command, args []string) error { + return tui.RunRestoreWizard() + }, + } +} diff --git a/cmd/root.go b/cmd/root.go index 6eca657..74bf827 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,7 +8,9 @@ import ( "github.com/spf13/cobra" "k8s.io/klog/v2" + "github.com/castai/kimchi/internal/cookbook" "github.com/castai/kimchi/internal/tui" + "github.com/castai/kimchi/internal/update" "github.com/castai/kimchi/internal/version" ) @@ -42,13 +44,24 @@ Get your API key at: https://kimchi.console.cast.ai`, SilenceUsage: true, SilenceErrors: true, Version: version.String(), - PersistentPreRun: func(cmd *cobra.Command, args []string) { + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { if debug { klog.SetLogger(klog.LoggerWithValues(klog.Background(), "debug", true)) } if verbose { klog.SetLogger(klog.LoggerWithValues(klog.Background(), "verbose", true)) } + // Skip auto-update for commands that manage cookbooks/upgrades themselves. + switch cmd.CommandPath() { + case "kimchi cookbook update", "kimchi cookbook add", + "kimchi upgrade", "kimchi update": + return nil + } + if err := cookbook.AutoUpdateIfStale(cmd.OutOrStdout(), cmd.ErrOrStderr()); err != nil { + return err + } + update.AutoSelfUpdateIfNeeded(cmd.Context(), version.Version, cmd.OutOrStdout(), cmd.ErrOrStderr()) + return nil }, RunE: runConfigure, } @@ -60,6 +73,10 @@ Get your API key at: https://kimchi.console.cast.ai`, root.AddCommand(NewVersionCommand()) root.AddCommand(NewCompletionCommand()) root.AddCommand(NewUpdateCommand()) + root.AddCommand(NewUpgradeCommand()) + root.AddCommand(NewAuthCommand()) + root.AddCommand(NewCookbookCommand()) + root.AddCommand(NewRecipeCommand()) return root } diff --git a/cmd/update.go b/cmd/update.go index ceba252..3728c76 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -1,129 +1,59 @@ package cmd import ( - "bufio" "fmt" - "os" - "strings" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" + "github.com/castai/kimchi/internal/cookbook" "github.com/castai/kimchi/internal/update" "github.com/castai/kimchi/internal/version" ) -type updateDoneMsg struct{ err error } - -type updateModel struct { - spinner spinner.Model - version string - applyFn tea.Cmd - err error - done bool -} - -func (m updateModel) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, m.applyFn) -} - -func (m updateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case updateDoneMsg: - m.done = true - m.err = msg.err - return m, tea.Quit - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } - return m, nil -} - -func (m updateModel) View() string { - if m.done { - return "" - } - return m.spinner.View() + " Updating to v" + m.version + "...\n" -} - +// NewUpdateCommand returns `kimchi update`, which mirrors `brew update`: +// 1. Pull the latest commits for all registered cookbooks. +// 2. Check for a new kimchi release and apply it if one is available. +// +// Both steps also run automatically in the background on every invocation +// (respecting KIMCHI_NO_AUTO_UPDATE and a 24h cooldown). func NewUpdateCommand() *cobra.Command { - var ( - force bool - dryRun bool - ) - - cmd := &cobra.Command{ + return &cobra.Command{ Use: "update", - Short: "Update kimchi to the latest version", - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client := update.NewGitHubClient() - - res, err := update.Check(ctx, client, version.Version, update.WithSkipCache()) - if err != nil { - return fmt.Errorf("check for updates: %w", err) - } + Short: "Update cookbooks and kimchi itself", + Long: `Update pulls the latest recipe commits from all registered cookbooks +and upgrades the kimchi binary if a newer release is available. - if !res.LatestVersion.GreaterThan(&res.CurrentVersion) { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Already up to date (%s)\n", res.CurrentVersion.String()) - return nil - } +Both operations also run automatically in the background once per day. +Set KIMCHI_NO_AUTO_UPDATE=1 to disable all automatic updates.`, + RunE: func(cmd *cobra.Command, args []string) error { + w := cmd.OutOrStdout() - execPath, err := update.ResolveExecutablePath() + // ── 1. Pull all cookbooks ──────────────────────────────────────────── + cookbooks, err := cookbook.Load() if err != nil { - return err - } - - // Fail fast before downloading if we can't write to the executable. - if err := update.CheckPermissions(execPath); err != nil { - return err - } - - if dryRun { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Update available: %s → %s\n", res.CurrentVersion.String(), res.LatestVersion.String()) - return nil - } - - if !force { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Update available: %s → %s\nContinue? [Y/n]: ", res.CurrentVersion.String(), res.LatestVersion.String()) - reader := bufio.NewReader(os.Stdin) - answer, _ := reader.ReadString('\n') - answer = strings.TrimSpace(strings.ToLower(answer)) - if answer != "" && answer != "y" && answer != "yes" { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Update cancelled.") - return nil + return fmt.Errorf("load cookbooks: %w", err) + } + + if len(cookbooks) == 0 { + fmt.Fprintln(w, "No cookbooks registered. Use `kimchi cookbook add ` to add one.") + } else { + fmt.Fprintln(w, "==> Updating cookbooks…") + for _, cb := range cookbooks { + fmt.Fprintf(w, " %s… ", cb.Name) + if err := cookbook.Pull(cb.Path); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: %v\n", err) + } else { + fmt.Fprintln(w, "✓") + } } + _ = cookbook.TouchAutoUpdateStamp() // reset the auto-update cooldown } - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) - m := updateModel{ - spinner: s, - version: res.LatestVersion.String(), - applyFn: func() tea.Msg { - return updateDoneMsg{err: update.Apply(ctx, client, res.LatestTag, update.WithExecutablePath(execPath))} - }, - } - finalModel, err := tea.NewProgram(m).Run() - if err != nil { - return fmt.Errorf("run update: %w", err) - } - if fm, ok := finalModel.(updateModel); ok && fm.err != nil { - return fm.err - } + // ── 2. Self-update the binary ──────────────────────────────────────── + fmt.Fprintln(w, "==> Checking for kimchi updates…") + update.AutoSelfUpdateIfNeeded(cmd.Context(), version.Version, w, cmd.ErrOrStderr()) - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "✓ Successfully updated to", res.LatestVersion.String()) return nil }, } - - cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt") - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be updated without making changes") - - return cmd } diff --git a/cmd/upgrade.go b/cmd/upgrade.go new file mode 100644 index 0000000..d12f0f8 --- /dev/null +++ b/cmd/upgrade.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/castai/kimchi/internal/cookbook" + "github.com/castai/kimchi/internal/recipe" +) + +// NewUpgradeCommand returns the top-level `kimchi upgrade` command. +// It is the equivalent of `brew update && brew upgrade`: +// 1. Pull the latest changes for all registered cookbooks. +// 2. Upgrade all non-pinned installed recipes to their latest versions. +func NewUpgradeCommand() *cobra.Command { + var dryRun bool + + cmd := &cobra.Command{ + Use: "upgrade", + Short: "Update cookbooks and upgrade all installed recipes", + Long: `Upgrade is a one-shot equivalent of: + + kimchi cookbook update # pull latest recipes from all cookbooks + kimchi recipe upgrade --yes # reinstall any recipes that have newer versions + +Recipes that require interactive secret entry are skipped with a warning. +Pinned recipes are never upgraded.`, + RunE: func(cmd *cobra.Command, args []string) error { + w := cmd.OutOrStdout() + + // ── Step 1: update all cookbooks ──────────────────────────────────── + cookbooks, err := cookbook.Load() + if err != nil { + return fmt.Errorf("load cookbooks: %w", err) + } + + if len(cookbooks) == 0 { + fmt.Fprintln(w, "No cookbooks registered. Use `kimchi cookbook add ` to add one.") + } else { + fmt.Fprintln(w, "Updating cookbooks…") + for _, cb := range cookbooks { + fmt.Fprintf(w, " %s… ", cb.Name) + if dryRun { + fmt.Fprintln(w, "(dry-run, skipped)") + continue + } + if err := cookbook.Pull(cb.Path); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: %v\n", err) + } else { + fmt.Fprintln(w, "✓") + } + } + } + + // ── Step 2: find upgradeable recipes ──────────────────────────────── + installed, err := recipe.LoadInstalled() + if err != nil { + return fmt.Errorf("load installed recipes: %w", err) + } + if len(installed) == 0 { + fmt.Fprintln(w, "No installed recipes found.") + return nil + } + + type upgradeCandidate struct { + installed recipe.InstalledRecipe + latest recipe.RecipeRef + } + + var candidates []upgradeCandidate + for _, inst := range installed { + if inst.Pinned { + continue + } + source := inst.Name + if inst.Cookbook != "" { + source = inst.Cookbook + "/" + inst.Name + } + ref, err := recipe.FindRecipe(source) + if err != nil { + continue + } + if recipe.CompareVersions(ref.Version, inst.Version) > 0 { + candidates = append(candidates, upgradeCandidate{installed: inst, latest: *ref}) + } + } + + if len(candidates) == 0 { + fmt.Fprintln(w, "All recipes are up to date.") + return nil + } + + fmt.Fprintln(w, "\nAvailable recipe upgrades:") + for _, c := range candidates { + fmt.Fprintf(w, " %-30s %s → %s\n", c.installed.Name, c.installed.Version, c.latest.Version) + } + + if dryRun { + return nil + } + + // ── Step 3: upgrade each recipe non-interactively ─────────────────── + fmt.Fprintln(w, "\nUpgrading recipes…") + for _, c := range candidates { + fmt.Fprintf(w, " %s@%s… ", c.latest.Name, c.latest.Version) + err := recipe.InstallHeadless(recipe.HeadlessInstallOptions{ + Source: c.latest.Path, + }) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "skipped: %v\n", err) + } else { + fmt.Fprintln(w, "✓") + } + } + return nil + }, + } + + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be updated without making changes") + return cmd +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 027e797..2b44980 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -68,6 +68,23 @@ func SetAPIKey(key string) error { return Save(cfg) } +func GetGitHubToken() (string, error) { + cfg, err := Load() + if err != nil { + return "", err + } + return cfg.GitHubToken, nil +} + +func SetGitHubToken(token string) error { + cfg, err := Load() + if err != nil { + return fmt.Errorf("load existing config: %w", err) + } + cfg.GitHubToken = token + return Save(cfg) +} + func Save(cfg *Config) error { path := ConfigPath() if path == "" { diff --git a/internal/config/types.go b/internal/config/types.go index e9896c4..16fc18d 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -1,5 +1,6 @@ package config type Config struct { - APIKey string `json:"api_key"` + APIKey string `json:"api_key"` + GitHubToken string `json:"github_token,omitempty"` } diff --git a/internal/cookbook/autoupdate.go b/internal/cookbook/autoupdate.go new file mode 100644 index 0000000..c8ff97f --- /dev/null +++ b/internal/cookbook/autoupdate.go @@ -0,0 +1,113 @@ +package cookbook + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "time" +) + +const ( + // defaultAutoUpdateSecs is 24 hours — cookbooks don't change as frequently + // as Homebrew formulae, so a daily pull is plenty. + defaultAutoUpdateSecs = 24 * 60 * 60 +) + +func autoUpdateStampPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".kimchi", "last_cookbook_update"), nil +} + +// AutoUpdateIfStale pulls all registered cookbooks if the auto-update interval +// has elapsed since the last pull. It is a no-op when: +// - KIMCHI_NO_AUTO_UPDATE is set to any non-empty value +// - No cookbooks are registered +// - The stamp file is fresh enough +// +// Errors from individual cookbook pulls are written to errW and do not abort +// the update of other cookbooks (mirrors Homebrew behaviour). +// The overall function only returns an error for hard failures (e.g. can't +// read the home directory). +func AutoUpdateIfStale(outW, errW io.Writer) error { + if os.Getenv("KIMCHI_NO_AUTO_UPDATE") != "" { + return nil + } + + interval := defaultAutoUpdateSecs + if v := os.Getenv("KIMCHI_AUTO_UPDATE_SECS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + interval = n + } + } + + stampPath, err := autoUpdateStampPath() + if err != nil { + return err + } + + if !isStale(stampPath, interval) { + return nil + } + + // Ensure the default cookbook is cloned before we attempt to pull it. + if err := EnsureDefault(outW); err != nil { + fmt.Fprintf(errW, "warning: ensure default cookbook: %v\n", err) + // Non-fatal — continue with whatever cookbooks are available. + } + + cookbooks, err := Load() + if err != nil || len(cookbooks) == 0 { + // No cookbooks registered — nothing to do, but touch the stamp so we + // don't attempt again on every invocation. + _ = touchStamp(stampPath) + return nil + } + + fmt.Fprintln(outW, "==> Auto-updating cookbooks…") + for _, cb := range cookbooks { + if err := Pull(cb.Path); err != nil { + fmt.Fprintf(errW, "warning: auto-update %s: %v\n", cb.Name, err) + } + } + + return touchStamp(stampPath) +} + +// isStale returns true when the stamp file is older than intervalSecs seconds +// (or does not exist yet). +func isStale(stampPath string, intervalSecs int) bool { + info, err := os.Stat(stampPath) + if err != nil { + return true // missing → treat as stale + } + return time.Since(info.ModTime()) > time.Duration(intervalSecs)*time.Second +} + +// TouchAutoUpdateStamp resets the auto-update cooldown, so the next invocation +// won't pull cookbooks again. Call this after an explicit `kimchi update`. +func TouchAutoUpdateStamp() error { + p, err := autoUpdateStampPath() + if err != nil { + return err + } + return touchStamp(p) +} + +// touchStamp creates or updates the modification time of the stamp file. +func touchStamp(stampPath string) error { + if err := os.MkdirAll(filepath.Dir(stampPath), 0755); err != nil { + return err + } + f, err := os.OpenFile(stampPath, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + f.Close() + now := time.Now() + return os.Chtimes(stampPath, now, now) +} diff --git a/internal/cookbook/cookbook.go b/internal/cookbook/cookbook.go new file mode 100644 index 0000000..fafab38 --- /dev/null +++ b/internal/cookbook/cookbook.go @@ -0,0 +1,13 @@ +package cookbook + +// Cookbook represents a registered recipe repository. +type Cookbook struct { + Name string `json:"name"` + URL string `json:"url"` + Path string `json:"path"` // absolute path to local clone +} + +// RecipePath returns the path where a recipe yaml lives inside the cookbook. +func (c *Cookbook) RecipePath(recipeName string) string { + return c.Path + "/recipes/" + recipeName + "/recipe.yaml" +} diff --git a/internal/cookbook/default.go b/internal/cookbook/default.go new file mode 100644 index 0000000..7a74934 --- /dev/null +++ b/internal/cookbook/default.go @@ -0,0 +1,72 @@ +package cookbook + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +const ( + // builtinDefaultCookbookURL is compiled into the binary. + // Override at runtime with KIMCHI_DEFAULT_COOKBOOK_URL. + // Set KIMCHI_DEFAULT_COOKBOOK_URL="" to disable the default cookbook entirely. + builtinDefaultCookbookURL = "https://github.com/castai/kimchi-cookbook.git" + + // DefaultCookbookName is the local name used for the default cookbook. + DefaultCookbookName = "kimchi-cookbook" +) + +// DefaultCookbookURL returns the URL of the default cookbook. +// It returns the value of KIMCHI_DEFAULT_COOKBOOK_URL if set (even if empty, +// which disables the default cookbook), otherwise the compiled-in URL. +func DefaultCookbookURL() string { + if v, ok := os.LookupEnv("KIMCHI_DEFAULT_COOKBOOK_URL"); ok { + return v + } + return builtinDefaultCookbookURL +} + +// IsDefault reports whether name refers to the default cookbook. +func IsDefault(name string) bool { + return name == DefaultCookbookName +} + +// defaultCookbookPath returns the local clone path for the default cookbook. +func defaultCookbookPath() (string, error) { + cloneDir, err := DefaultCloneDir() + if err != nil { + return "", err + } + return filepath.Join(cloneDir, DefaultCookbookName), nil +} + +// EnsureDefault clones the default cookbook if it is not already present on +// disk. It is a no-op when: +// - KIMCHI_DEFAULT_COOKBOOK_URL is set to an empty string (disabled) +// - The clone directory already exists and looks like a git repository +// +// Clone errors are returned so the caller can decide whether to treat them as +// fatal. +func EnsureDefault(outW io.Writer) error { + defURL := DefaultCookbookURL() + if defURL == "" { + return nil // explicitly disabled + } + + defPath, err := defaultCookbookPath() + if err != nil { + return err + } + + // Already cloned — nothing to do. + if _, err := os.Stat(filepath.Join(defPath, ".git")); err == nil { + return nil + } + + fmt.Fprintf(outW, "==> Cloning default cookbook (%s)…\n", DefaultCookbookName) + if err := os.MkdirAll(filepath.Dir(defPath), 0755); err != nil { + return fmt.Errorf("create cookbook dir: %w", err) + } + return Clone(defURL, defPath) +} diff --git a/internal/cookbook/git.go b/internal/cookbook/git.go new file mode 100644 index 0000000..8cf59e4 --- /dev/null +++ b/internal/cookbook/git.go @@ -0,0 +1,207 @@ +package cookbook + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Clone clones a remote repository to destDir. +func Clone(url, destDir string) error { + if err := os.MkdirAll(filepath.Dir(destDir), 0755); err != nil { + return err + } + out, err := run("", "git", "clone", url, destDir) + if err != nil { + return fmt.Errorf("git clone: %s", out) + } + return nil +} + +// Pull fetches and fast-forwards the default branch. +func Pull(dir string) error { + out, err := run(dir, "git", "pull", "--ff-only") + if err != nil { + return fmt.Errorf("git pull: %s", out) + } + return nil +} + +// AddFiles stages the given file paths inside dir. +func AddFiles(dir string, paths []string) error { + args := append([]string{"add", "--"}, paths...) + out, err := run(dir, "git", args...) + if err != nil { + return fmt.Errorf("git add: %s", out) + } + return nil +} + +// Commit creates a commit with the given message. +func Commit(dir, message string) error { + out, err := run(dir, "git", "commit", "-m", message) + if err != nil { + return fmt.Errorf("git commit: %s", out) + } + return nil +} + +// CreateTag creates an annotated tag. Annotated tags (unlike lightweight ones) +// are pushed by `git push --follow-tags`. +func CreateTag(dir, tag string) error { + out, err := run(dir, "git", "tag", "-a", tag, "-m", tag) + if err != nil { + return fmt.Errorf("git tag %s: %s", tag, out) + } + return nil +} + +// TagExists reports whether the given tag already exists in the repo. +func TagExists(dir, tag string) bool { + out, err := run(dir, "git", "tag", "-l", tag) + return err == nil && strings.TrimSpace(out) == tag +} + +// Push pushes commits (but not tags) to origin. Returns (false, nil) when the +// caller should fall back to a GitHub fork/PR flow. Tags are intentionally +// excluded so they are only pushed after a successful direct push via PushTag. +func Push(dir string) (hasAccess bool, err error) { + out, err := run(dir, "git", "push") + if err != nil { + if isAuthError(out) { + return false, nil + } + return false, fmt.Errorf("git push: %s", out) + } + return true, nil +} + +// PushTag pushes a single named tag to origin. +func PushTag(dir, tag string) error { + out, err := run(dir, "git", "push", "origin", tag) + if err != nil { + return fmt.Errorf("git push tag %s: %s", tag, out) + } + return nil +} + +// CreateBranch creates and checks out a new branch. +func CreateBranch(dir, branch string) error { + out, err := run(dir, "git", "checkout", "-b", branch) + if err != nil { + return fmt.Errorf("git checkout -b %s: %s", branch, out) + } + return nil +} + +// PushBranch pushes a named branch to origin. Force-push is used because +// kimchi-managed branches may already exist from a previous interrupted push. +func PushBranch(dir, branch string) (hasAccess bool, err error) { + out, err := run(dir, "git", "push", "-u", "origin", branch, "--force") + if err != nil { + if isAuthError(out) { + return false, nil + } + return false, fmt.Errorf("git push branch: %s", out) + } + return true, nil +} + +// AddRemote adds a named remote. +func AddRemote(dir, name, url string) error { + out, err := run(dir, "git", "remote", "add", name, url) + if err != nil { + return fmt.Errorf("git remote add: %s", out) + } + return nil +} + +// RemoteURL returns the fetch URL for the given remote name. +func RemoteURL(dir, remote string) (string, error) { + out, err := run(dir, "git", "remote", "get-url", remote) + if err != nil { + return "", fmt.Errorf("git remote get-url: %s", out) + } + return strings.TrimSpace(out), nil +} + +// IsRepo reports whether dir is inside a git repository. +func IsRepo(dir string) bool { + _, err := run(dir, "git", "rev-parse", "--git-dir") + return err == nil +} + +// HasUncommitted reports whether there are uncommitted changes. +func HasUncommitted(dir string) bool { + out, err := run(dir, "git", "status", "--porcelain") + return err == nil && strings.TrimSpace(out) != "" +} + +// HasUnpushedCommits reports whether the local branch is ahead of its remote tracking branch. +func HasUnpushedCommits(dir string) bool { + out, err := run(dir, "git", "log", "@{u}..HEAD", "--oneline") + return err == nil && strings.TrimSpace(out) != "" +} + +// SyncToRemote fetches from origin and hard-resets the working tree to +// origin's default branch. Safe to call on kimchi-managed cookbook clones +// where local divergence is always from a previous interrupted push. +func SyncToRemote(dir string) error { + return syncToRef(dir, "origin") +} + +// SyncForkToUpstream fetches from the "upstream" remote and hard-resets the +// working tree to it, so fork branches are always based on the latest upstream. +func SyncForkToUpstream(dir string) error { + return syncToRef(dir, "upstream") +} + +func syncToRef(dir, remote string) error { + if out, err := run(dir, "git", "fetch", remote); err != nil { + return fmt.Errorf("git fetch %s: %s", remote, out) + } + // Resolve the remote HEAD to handle repos with non-main default branches. + out, err := run(dir, "git", "rev-parse", "--abbrev-ref", remote+"/HEAD") + if err != nil { + out = remote + "/main" + } + ref := strings.TrimSpace(out) + if out2, err := run(dir, "git", "reset", "--hard", ref); err != nil { + return fmt.Errorf("git reset --hard %s: %s", ref, out2) + } + return nil +} + +func isAuthError(output string) bool { + lower := strings.ToLower(output) + for _, s := range []string{ + "permission denied", + "authentication failed", + "could not read username", + "invalid credentials", + "403", + "401", + "repository not found", + "remote: error: access denied", + // Protected branch — must go through a pull request. + "protected branch", + "gh006", + "changes must be made through a pull request", + } { + if strings.Contains(lower, s) { + return true + } + } + return false +} + +func run(dir string, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + if dir != "" { + cmd.Dir = dir + } + out, err := cmd.CombinedOutput() + return string(out), err +} diff --git a/internal/cookbook/github.go b/internal/cookbook/github.go new file mode 100644 index 0000000..8331f64 --- /dev/null +++ b/internal/cookbook/github.go @@ -0,0 +1,270 @@ +package cookbook + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// githubClientID is the GitHub OAuth App client ID compiled into the binary. +// Override at build time: -ldflags "-X github.com/castai/kimchi/internal/cookbook.githubClientID=" +// or set KIMCHI_GITHUB_CLIENT_ID at runtime. +// TODO: this is a temporary Github APP - replace it with official one before release +const githubClientID = "Ov23liNx4u53Pg5wkjmg" + +// DeviceCodeResponse is returned by RequestDeviceCode. +type DeviceCodeResponse struct { + DeviceCode string + UserCode string + VerificationURL string + ExpiresIn int + Interval int // seconds between poll attempts +} + +// RequestDeviceCode starts a GitHub device auth flow and returns the codes to show the user. +// Set KIMCHI_GITHUB_CLIENT_ID or compile in githubClientID before calling. +func RequestDeviceCode() (DeviceCodeResponse, error) { + cid := os.Getenv("KIMCHI_GITHUB_CLIENT_ID") + if cid == "" { + cid = githubClientID + } + if cid == "" { + return DeviceCodeResponse{}, fmt.Errorf( + "GitHub OAuth app not configured — set KIMCHI_GITHUB_CLIENT_ID", + ) + } + + resp, err := http.PostForm("https://github.com/login/device/code", url.Values{ + "client_id": {cid}, + "scope": {"repo"}, + }) + if err != nil { + return DeviceCodeResponse{}, fmt.Errorf("request device code: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + vals, _ := url.ParseQuery(string(body)) + dcr := DeviceCodeResponse{ + DeviceCode: vals.Get("device_code"), + UserCode: vals.Get("user_code"), + VerificationURL: vals.Get("verification_uri"), + ExpiresIn: 900, + Interval: 5, + } + fmt.Sscanf(vals.Get("expires_in"), "%d", &dcr.ExpiresIn) + fmt.Sscanf(vals.Get("interval"), "%d", &dcr.Interval) + return dcr, nil +} + +// PollForToken attempts a single token exchange. Returns ("", nil) when still pending. +func PollForToken(deviceCode string) (token string, err error) { + cid := os.Getenv("KIMCHI_GITHUB_CLIENT_ID") + if cid == "" { + cid = githubClientID + } + + resp, err := http.PostForm("https://github.com/login/oauth/access_token", url.Values{ + "client_id": {cid}, + "device_code": {deviceCode}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + }) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + vals, _ := url.ParseQuery(string(body)) + + if token = vals.Get("access_token"); token != "" { + return token, nil + } + switch vals.Get("error") { + case "authorization_pending", "slow_down": + return "", nil // still waiting + case "": + return "", nil + default: + return "", fmt.Errorf("%s: %s", vals.Get("error"), vals.Get("error_description")) + } +} + +// ValidateToken checks that the token is accepted by the GitHub API and returns +// the authenticated user's login. +func ValidateToken(token string) (string, error) { + username, err := GetUsername(token) + if err != nil { + return "", fmt.Errorf("invalid GitHub token: %w", err) + } + return username, nil +} + +// GetUsername returns the authenticated user's login. +func GetUsername(token string) (string, error) { + var result struct { + Login string `json:"login"` + } + if err := githubGet(token, "https://api.github.com/user", &result); err != nil { + return "", err + } + return result.Login, nil +} + +// ForkRepo forks owner/repo on behalf of the authenticated user. +// Returns the HTTPS clone URL of the fork. +func ForkRepo(token, owner, repo string) (string, error) { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/forks", owner, repo) + var result struct { + CloneURL string `json:"clone_url"` + HTMLURL string `json:"html_url"` + } + if err := githubPost(token, apiURL, nil, &result); err != nil { + return "", fmt.Errorf("fork %s/%s: %w", owner, repo, err) + } + // GitHub may return 202 (fork is being created); wait briefly + if result.CloneURL == "" { + time.Sleep(3 * time.Second) + forkURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", githubUsername(token), repo) + if err := githubGet(token, forkURL, &result); err != nil { + return "", fmt.Errorf("get fork: %w", err) + } + } + return result.CloneURL, nil +} + +// CreatePR opens a pull request. head should be "user:branch". +// Returns the PR URL. +func CreatePR(token, owner, repo, head, base, title, body string) (string, error) { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/pulls", owner, repo) + payload := map[string]string{ + "title": title, + "head": head, + "base": base, + "body": body, + } + var result struct { + HTMLURL string `json:"html_url"` + Number int `json:"number"` + } + if err := githubPost(token, apiURL, payload, &result); err != nil { + return "", fmt.Errorf("create PR: %w", err) + } + return result.HTMLURL, nil +} + +// FindOpenPR returns the number and URL of an open PR whose head matches, or 0/"" if none. +func FindOpenPR(token, owner, repo, head string) (int, string, error) { + apiURL := fmt.Sprintf( + "https://api.github.com/repos/%s/%s/pulls?state=open&head=%s", + owner, repo, url.QueryEscape(head), + ) + var results []struct { + Number int `json:"number"` + HTMLURL string `json:"html_url"` + } + if err := githubGet(token, apiURL, &results); err != nil { + return 0, "", err + } + if len(results) > 0 { + return results[0].Number, results[0].HTMLURL, nil + } + return 0, "", nil +} + +// ParseGitHubURL extracts owner and repo from a GitHub URL (HTTPS or SSH). +func ParseGitHubURL(rawURL string) (owner, repo string, err error) { + rawURL = strings.TrimSuffix(rawURL, ".git") + // SSH: git@github.com:owner/repo + if strings.HasPrefix(rawURL, "git@github.com:") { + parts := strings.SplitN(strings.TrimPrefix(rawURL, "git@github.com:"), "/", 2) + if len(parts) == 2 { + return parts[0], parts[1], nil + } + } + // HTTPS: https://github.com/owner/repo + u, err2 := url.Parse(rawURL) + if err2 != nil || u.Host != "github.com" { + return "", "", fmt.Errorf("not a GitHub URL: %s", rawURL) + } + parts := strings.SplitN(strings.TrimPrefix(u.Path, "/"), "/", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("cannot parse GitHub URL path: %s", rawURL) + } + return parts[0], parts[1], nil +} + +// TokenCloneURL inserts a GitHub token into an HTTPS clone URL. +func TokenCloneURL(token, rawURL string) string { + rawURL = strings.TrimSuffix(rawURL, ".git") + if strings.HasPrefix(rawURL, "https://") { + return "https://oauth2:" + token + "@" + strings.TrimPrefix(rawURL, "https://") + ".git" + } + return rawURL +} + +// --- helpers --- + +func githubUsername(token string) string { + u, _ := GetUsername(token) + return u +} + +func githubGet(token, apiURL string, dest any) error { + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return fmt.Errorf("GitHub API %s: %s", resp.Status, body) + } + return json.Unmarshal(body, dest) +} + +func githubPost(token, apiURL string, payload any, dest any) error { + var body io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return err + } + body = bytes.NewReader(data) + } + req, err := http.NewRequest("POST", apiURL, body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return fmt.Errorf("GitHub API %s: %s", resp.Status, respBody) + } + if dest != nil { + return json.Unmarshal(respBody, dest) + } + return nil +} diff --git a/internal/cookbook/scaffold.go b/internal/cookbook/scaffold.go new file mode 100644 index 0000000..aa55571 --- /dev/null +++ b/internal/cookbook/scaffold.go @@ -0,0 +1,66 @@ +package cookbook + +import ( + "fmt" + "os" + "path/filepath" +) + +const cookbookYAML = `name: %s +` + +const readmeMD = `# %s + +A kimchi recipe cookbook. + +## Structure + +` + "```" + ` +recipes/ + / + recipe.yaml +` + "```" + ` +` + +// Scaffold writes a minimal cookbook structure into dir, commits, and pushes. +// It expects dir to already be a git repo (cloned from the remote). +func Scaffold(dir, name string) error { + // Create recipes/ directory + if err := os.MkdirAll(filepath.Join(dir, "recipes"), 0755); err != nil { + return err + } + + // .kimchi/cookbook.yaml + kimchiDir := filepath.Join(dir, ".kimchi") + if err := os.MkdirAll(kimchiDir, 0755); err != nil { + return err + } + cbYAML := fmt.Sprintf(cookbookYAML, name) + if err := os.WriteFile(filepath.Join(kimchiDir, "cookbook.yaml"), []byte(cbYAML), 0644); err != nil { + return err + } + + // README.md (only if it doesn't already exist) + readmePath := filepath.Join(dir, "README.md") + if _, err := os.Stat(readmePath); os.IsNotExist(err) { + if err := os.WriteFile(readmePath, []byte(fmt.Sprintf(readmeMD, name)), 0644); err != nil { + return err + } + } + + // Commit and push scaffold + if !HasUncommitted(dir) { + return nil // nothing to commit (e.g. remote already had these files) + } + if err := AddFiles(dir, []string{".kimchi", "recipes", "README.md"}); err != nil { + return err + } + if err := Commit(dir, "Initial cookbook scaffold"); err != nil { + return err + } + out, err := run(dir, "git", "push") + if err != nil { + return fmt.Errorf("git push scaffold: %s", out) + } + return nil +} diff --git a/internal/cookbook/store.go b/internal/cookbook/store.go new file mode 100644 index 0000000..b8f1224 --- /dev/null +++ b/internal/cookbook/store.go @@ -0,0 +1,176 @@ +package cookbook + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +func storePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".kimchi", "cookbooks.json"), nil +} + +// DefaultCloneDir returns the default directory for cloning cookbooks. +func DefaultCloneDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".kimchi", "cookbooks"), nil +} + +// Load returns all cookbooks: the default cookbook (if enabled and cloned) +// followed by all user-registered cookbooks. +func Load() ([]Cookbook, error) { + p, err := storePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(p) + if os.IsNotExist(err) { + data = nil + } else if err != nil { + return nil, fmt.Errorf("read cookbooks: %w", err) + } + + var userList []Cookbook + if len(data) > 0 { + if err := json.Unmarshal(data, &userList); err != nil { + return nil, fmt.Errorf("parse cookbooks: %w", err) + } + } + + // Prepend the default cookbook if it is enabled and already cloned on disk. + // We do not clone here — EnsureDefault (called by AutoUpdateIfStale) handles that. + defURL := DefaultCookbookURL() + if defURL != "" { + defPath, pathErr := defaultCookbookPath() + if pathErr == nil { + if _, statErr := os.Stat(filepath.Join(defPath, ".git")); statErr == nil { + // Only prepend if the user hasn't registered a cookbook with the same name. + alreadyRegistered := false + for _, c := range userList { + if c.Name == DefaultCookbookName { + alreadyRegistered = true + break + } + } + if !alreadyRegistered { + def := Cookbook{Name: DefaultCookbookName, URL: defURL, Path: defPath} + userList = append([]Cookbook{def}, userList...) + } + } + } + } + + return userList, nil +} + +// loadUserList reads only the user-registered cookbooks from disk (the JSON +// store). It does NOT include the built-in default cookbook. Use this for +// mutations (Add, Remove) so the default is never accidentally persisted. +func loadUserList() ([]Cookbook, error) { + p, err := storePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(p) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read cookbooks: %w", err) + } + var list []Cookbook + if err := json.Unmarshal(data, &list); err != nil { + return nil, fmt.Errorf("parse cookbooks: %w", err) + } + return list, nil +} + +func save(list []Cookbook) error { + p, err := storePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(list, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, data, 0644) +} + +// Add registers a new cookbook. Returns an error if a cookbook with the same name is already registered. +func Add(cb Cookbook) error { + list, err := loadUserList() + if err != nil { + return err + } + for _, c := range list { + if c.Name == cb.Name { + return fmt.Errorf("cookbook %q is already registered (path: %s)", cb.Name, c.Path) + } + } + return save(append(list, cb)) +} + +// Remove unregisters a cookbook by name. +// The default cookbook cannot be removed via this function; set +// KIMCHI_DEFAULT_COOKBOOK_URL="" to disable it instead. +func Remove(name string) error { + if IsDefault(name) && DefaultCookbookURL() != "" { + return fmt.Errorf("cannot remove the default cookbook %q; set KIMCHI_DEFAULT_COOKBOOK_URL= to disable it", name) + } + list, err := loadUserList() + if err != nil { + return err + } + next := list[:0] + found := false + for _, c := range list { + if c.Name == name { + found = true + continue + } + next = append(next, c) + } + if !found { + return fmt.Errorf("cookbook %q not found", name) + } + return save(next) +} + +// Get returns a cookbook by name, or nil if not found. +func Get(name string) (*Cookbook, error) { + list, err := Load() + if err != nil { + return nil, err + } + for i, c := range list { + if c.Name == name { + return &list[i], nil + } + } + return nil, nil +} + +// NameFromURL derives a cookbook name from its remote URL. +// e.g. "https://github.com/alice/my-cookbook.git" → "my-cookbook" +func NameFromURL(rawURL string) string { + // Strip trailing .git + name := strings.TrimSuffix(rawURL, ".git") + // Take the last path component + if idx := strings.LastIndexAny(name, "/:\\"); idx >= 0 { + name = name[idx+1:] + } + return name +} diff --git a/internal/recipe/backup.go b/internal/recipe/backup.go new file mode 100644 index 0000000..8c2505a --- /dev/null +++ b/internal/recipe/backup.go @@ -0,0 +1,265 @@ +package recipe + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/castai/kimchi/internal/tools" +) + +// BackupMeta is written as meta.json inside every backup slot. +type BackupMeta struct { + Tool tools.ToolID `json:"tool"` + RecipeName string `json:"recipe_name,omitempty"` // empty for baseline + RecipeVersion string `json:"recipe_version,omitempty"` // version at time of capture + RecipeCookbook string `json:"recipe_cookbook,omitempty"` // cookbook at time of capture + CapturedAt time.Time `json:"captured_at"` + Files []string `json:"files"` // paths relative to $HOME +} + +// BackupSlot describes one backup directory available for restore. +type BackupSlot struct { + Tool tools.ToolID + RecipeName string // empty = baseline + CapturedAt time.Time + Dir string // absolute path to the backup directory + Meta BackupMeta +} + +func backupsRoot() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".kimchi", "backups"), nil +} + +func backupBaselineDir(tool tools.ToolID) (string, error) { + root, err := backupsRoot() + if err != nil { + return "", err + } + return filepath.Join(root, string(tool), "baseline"), nil +} + +func backupSlotDir(tool tools.ToolID, recipeName string) (string, error) { + root, err := backupsRoot() + if err != nil { + return "", err + } + return filepath.Join(root, string(tool), recipeName), nil +} + +// EnsureBaseline captures the current state of filesToCapture into the +// baseline slot for this tool. Does nothing if a baseline already exists. +// Returns the absolute paths that were actually backed up (nil when the +// baseline already existed and no files were captured). +func EnsureBaseline(tool tools.ToolID, filesToCapture []string) ([]string, error) { + dir, err := backupBaselineDir(tool) + if err != nil { + return nil, err + } + if _, err := os.Stat(filepath.Join(dir, "meta.json")); err == nil { + return nil, nil // baseline already exists + } + return captureFilesToDir(dir, BackupMeta{Tool: tool}, filesToCapture) +} + +// snapshotWithMeta is like Snapshot but also stores version and cookbook in meta.json. +// Returns the absolute paths that were actually backed up. +func snapshotWithMeta(tool tools.ToolID, recipeName, version, cookbook string, filesToCapture []string) ([]string, error) { + dir, err := backupSlotDir(tool, recipeName) + if err != nil { + return nil, err + } + _ = os.RemoveAll(dir) // remove stale slot + return captureFilesToDir(dir, BackupMeta{ + Tool: tool, + RecipeName: recipeName, + RecipeVersion: version, + RecipeCookbook: cookbook, + CapturedAt: time.Now(), + }, filesToCapture) +} + +// SnapshotCurrentlyInstalled captures the current on-disk state of every recipe +// already installed for tool into per-recipe backup slots. This preserves the +// pre-upgrade state so users can roll back to it. Should be called right before +// installing a new recipe, after EnsureBaseline. +// Returns the union of all absolute paths that were actually backed up. +func SnapshotCurrentlyInstalled(tool tools.ToolID) ([]string, error) { + installed, err := loadInstalledForTool(tool) + if err != nil || len(installed) == 0 { + return nil, err + } + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + var allBacked []string + for _, rec := range installed { + m, _ := LoadManifest(tool, rec.Name) + var filesToCapture []string + if m != nil { + filesToCapture = append(filesToCapture, m.AssetFiles...) + } + // opencode.json is excluded from manifests (merge target) but should be + // included in the backup so the full config state is preserved. + filesToCapture = append(filesToCapture, filepath.Join(home, ".config", "opencode", "opencode.json")) + backed, err := snapshotWithMeta(tool, rec.Name, rec.Version, rec.Cookbook, filesToCapture) + if err != nil { + return nil, err + } + allBacked = append(allBacked, backed...) + } + return allBacked, nil +} + +func captureFilesToDir(destDir string, meta BackupMeta, paths []string) ([]string, error) { + if err := os.MkdirAll(destDir, 0700); err != nil { + return nil, err + } + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + var capturedRel []string + var capturedAbs []string + for _, src := range paths { + if _, err := os.Stat(src); err != nil { + continue // file doesn't exist yet; skip + } + rel, err := filepath.Rel(home, src) + if err != nil { + rel = filepath.Base(src) + } + dst := filepath.Join(destDir, rel) + if err := os.MkdirAll(filepath.Dir(dst), 0700); err != nil { + return nil, err + } + if err := copyFile(src, dst); err != nil { + return nil, err + } + capturedRel = append(capturedRel, rel) + capturedAbs = append(capturedAbs, src) + } + + meta.CapturedAt = time.Now() + meta.Files = capturedRel + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return nil, err + } + return capturedAbs, os.WriteFile(filepath.Join(destDir, "meta.json"), data, 0600) +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +// ListBackupSlots returns all backup slots found under ~/.kimchi/backups/. +func ListBackupSlots() ([]BackupSlot, error) { + root, err := backupsRoot() + if err != nil { + return nil, err + } + toolDirs, err := os.ReadDir(root) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var slots []BackupSlot + for _, td := range toolDirs { + if !td.IsDir() { + continue + } + toolID := tools.ToolID(td.Name()) + slotDirs, err := os.ReadDir(filepath.Join(root, td.Name())) + if err != nil { + continue + } + for _, sd := range slotDirs { + if !sd.IsDir() { + continue + } + dir := filepath.Join(root, td.Name(), sd.Name()) + metaData, err := os.ReadFile(filepath.Join(dir, "meta.json")) + if err != nil { + continue + } + var meta BackupMeta + if err := json.Unmarshal(metaData, &meta); err != nil { + continue + } + slots = append(slots, BackupSlot{ + Tool: toolID, + RecipeName: meta.RecipeName, + CapturedAt: meta.CapturedAt, + Dir: dir, + Meta: meta, + }) + } + } + return slots, nil +} + +// RestoreSlot restores all files from a BackupSlot to their original locations +// under $HOME. It first removes all currently installed recipe asset files so +// that files present in the current install but absent from the backup slot are +// not left behind. After restore it updates kimchi state to match the slot. +func RestoreSlot(slot BackupSlot) error { + // Remove all currently installed asset files before restoring, so the + // on-disk state is fully replaced rather than merged. + installed, err := loadInstalledForTool(slot.Tool) + if err != nil { + return fmt.Errorf("load installed recipes: %w", err) + } + for _, rec := range installed { + if err := UninstallByManifest(slot.Tool, rec.Name); err != nil { + return fmt.Errorf("uninstall %s before restore: %w", rec.Name, err) + } + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + for _, rel := range slot.Meta.Files { + src := filepath.Join(slot.Dir, rel) + dst := filepath.Join(home, rel) + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return fmt.Errorf("restore mkdir %s: %w", rel, err) + } + if err := copyFile(src, dst); err != nil { + return fmt.Errorf("restore %s: %w", rel, err) + } + } + if slot.RecipeName != "" { + // Per-recipe restore: this slot represents a known installed state, + // so replace the installed list with just this recipe. + _ = RecordInstall(slot.RecipeName, slot.Meta.RecipeVersion, slot.Meta.RecipeCookbook, slot.Tool) + } else { + // Baseline restore: wipe all install records and manifests for this tool. + _ = clearAllInstalledForTool(slot.Tool) + _ = clearAllManifestsForTool(slot.Tool) + } + return nil +} diff --git a/internal/recipe/builder.go b/internal/recipe/builder.go new file mode 100644 index 0000000..c217986 --- /dev/null +++ b/internal/recipe/builder.go @@ -0,0 +1,180 @@ +package recipe + +import ( + "strings" + "time" +) + +// ExportOptions carries the user's choices from the TUI wizard. +type ExportOptions struct { + Name string + Author string + Description string + Tags []string + UseCase string + + IncludeAgentsMD bool + IncludeSkills bool + IncludeCustomCommands bool + IncludeAgents bool + IncludeTUI bool + IncludeThemeFiles bool + IncludePluginFiles bool + IncludeToolFiles bool + + // Seed is the previously-installed recipe for this name. When set, Build + // preserves its version, cookbook, forked_from, and created_at so that + // re-exporting an installed recipe doesn't lose its lineage metadata. + Seed *Recipe +} + +// 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 + } + + now := time.Now().UTC().Format(time.RFC3339) + + version := "0.1.0" + createdAt := now + cookbook := "" + var forkedFrom *ForkedFrom + + if opts.Seed != nil { + version = opts.Seed.Version + createdAt = opts.Seed.CreatedAt + cookbook = opts.Seed.Cookbook + forkedFrom = opts.Seed.ForkedFrom + if opts.Author == "" { + opts.Author = opts.Seed.Author + } + if opts.Description == "" { + opts.Description = opts.Seed.Description + } + if len(opts.Tags) == 0 { + opts.Tags = opts.Seed.Tags + } + } + + r := &Recipe{ + Name: opts.Name, + Version: version, + Cookbook: cookbook, + Author: opts.Author, + Description: opts.Description, + Tags: opts.Tags, + CreatedAt: createdAt, + UpdatedAt: now, + Model: displaySlug, + UseCase: opts.UseCase, + ForkedFrom: forkedFrom, + 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..88e58f7 --- /dev/null +++ b/internal/recipe/export.go @@ -0,0 +1,44 @@ +package recipe + +import ( + "fmt" + "io" + + "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 +} + +// WriteYAMLTo marshals r to YAML and writes it to w (e.g. os.Stdout for --dry-run). +func WriteYAMLTo(w io.Writer, r *Recipe) error { + data, err := yaml.Marshal(r) + if err != nil { + return fmt.Errorf("marshal recipe: %w", err) + } + _, err = fmt.Fprint(w, fileHeader) + if err != nil { + return err + } + _, err = w.Write(data) + return err +} diff --git a/internal/recipe/fork.go b/internal/recipe/fork.go new file mode 100644 index 0000000..e5d202f --- /dev/null +++ b/internal/recipe/fork.go @@ -0,0 +1,80 @@ +package recipe + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// ForkOptions controls how `recipe fork` behaves. +type ForkOptions struct { + // Source is a file path, "name", "cookbook/name", or "name@version". + Source string + // NewName overrides the recipe name in the forked copy. + // If empty the original name is kept. + NewName string + // OutputPath is where the forked recipe yaml is written. + // If empty it defaults to ".yaml" in the current directory. + OutputPath string +} + +// Fork downloads (or reads) a recipe, marks it as forked, and writes the result locally. +func Fork(opts ForkOptions) (string, error) { + var r *Recipe + var err error + + if isFilePath(opts.Source) { + r, err = ReadFromFile(opts.Source) + } else { + r, err = ResolveSource(opts.Source) + } + if err != nil { + return "", fmt.Errorf("resolve recipe: %w", err) + } + + // Record where this came from + r.ForkedFrom = &ForkedFrom{ + Author: r.Author, + Cookbook: r.Cookbook, + Version: r.Version, + } + + // Apply overrides + if opts.NewName != "" { + if opts.NewName != r.Name { + fmt.Fprintf(os.Stderr, + "warning: renaming recipe changes the forked_from lineage — the original was %q\n", r.Name) + } + r.Name = opts.NewName + } + + // Reset fields for the fork + r.Version = "0.1.0" + r.Cookbook = "" // resolved on first push + now := time.Now().UTC().Format(time.RFC3339) + r.CreatedAt = now + r.UpdatedAt = now + + // Determine output path + out := opts.OutputPath + if out == "" { + out = sanitizeFilename(r.Name) + ".yaml" + } + + if err := WriteYAML(out, r); err != nil { + return "", fmt.Errorf("write fork: %w", err) + } + + return filepath.Abs(out) +} + +func sanitizeFilename(name string) string { + return strings.Map(func(r rune) rune { + if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' { + return '-' + } + return r + }, name) +} diff --git a/internal/recipe/headless.go b/internal/recipe/headless.go new file mode 100644 index 0000000..6d08a0b --- /dev/null +++ b/internal/recipe/headless.go @@ -0,0 +1,94 @@ +package recipe + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/castai/kimchi/internal/config" + "github.com/castai/kimchi/internal/tools" +) + +// HeadlessInstallOptions controls non-interactive recipe installation. +type HeadlessInstallOptions struct { + // Source is the recipe path, name, cookbook/name, or name@version. + Source string +} + +// InstallHeadless installs a recipe without a TUI. It uses the stored Kimchi +// API key for provider secrets and skips recipes that require external secrets +// not yet available non-interactively. Returns an error with details when +// external secrets are required. +func InstallHeadless(opts HeadlessInstallOptions) error { + r, err := ResolveSource(opts.Source) + if err != nil { + return fmt.Errorf("resolve recipe: %w", err) + } + + // Build secret values: start with kimchi provider placeholders. + secretValues := make(map[string]string) + apiKey, _ := config.GetAPIKey() + if apiKey != "" { + all := make(map[string]struct{}) + CollectAllSecretPlaceholders(r, all) + external := DetectExternalSecretPlaceholders(r) + externalSet := make(map[string]struct{}, len(external)) + for _, p := range external { + externalSet[p] = struct{}{} + } + for p := range all { + if _, isExternal := externalSet[p]; !isExternal { + secretValues[p] = apiKey + } + } + } + + // Reject recipes that need external secrets — can't prompt headlessly. + if external := DetectExternalSecretPlaceholders(r); len(external) > 0 { + return fmt.Errorf( + "recipe %q requires external secrets that cannot be filled non-interactively: %v\n"+ + " use `kimchi recipe install %s` to fill them interactively", + r.Name, external, opts.Source, + ) + } + + filesToCapture, err := PredictAssetPaths(r) + if err != nil { + return fmt.Errorf("predict asset paths: %w", err) + } + baselineBacked, err := EnsureBaseline(tools.ToolOpenCode, filesToCapture) + if err != nil { + return fmt.Errorf("backup baseline: %w", err) + } + if err := RemoveAssetFiles(baselineBacked); err != nil { + return fmt.Errorf("clean baseline assets: %w", err) + } + snapshotBacked, err := SnapshotCurrentlyInstalled(tools.ToolOpenCode) + if err != nil { + return fmt.Errorf("backup current recipes: %w", err) + } + if err := RemoveAssetFiles(snapshotBacked); err != nil { + return fmt.Errorf("clean installed assets: %w", err) + } + + written, err := InstallOpenCode(r, secretValues, nil) + if err != nil { + return err + } + + // Save manifest (exclude opencode.json — it's a merge target, not verbatim). + var assetFiles []string + for _, p := range written { + if filepath.Base(p) != "opencode.json" { + assetFiles = append(assetFiles, p) + } + } + _ = SaveManifest(&RecipeManifest{ + RecipeName: r.Name, + Tool: tools.ToolOpenCode, + InstalledAt: time.Now(), + AssetFiles: assetFiles, + }) + _ = RecordInstall(r.Name, r.Version, r.Cookbook, tools.ToolOpenCode) + return nil +} diff --git a/internal/recipe/installed.go b/internal/recipe/installed.go new file mode 100644 index 0000000..62f3e7b --- /dev/null +++ b/internal/recipe/installed.go @@ -0,0 +1,125 @@ +package recipe + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/castai/kimchi/internal/tools" +) + +// InstalledRecipe tracks a recipe that has been installed on this machine. +type InstalledRecipe struct { + Name string `json:"name"` + Version string `json:"version"` + Cookbook string `json:"cookbook,omitempty"` + Tool tools.ToolID `json:"tool,omitempty"` + InstalledAt time.Time `json:"installed_at"` + Pinned bool `json:"pinned"` +} + +// LoadInstalled returns all installed recipes across all tools. +func LoadInstalled() ([]InstalledRecipe, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + dir := filepath.Join(home, ".kimchi", "installed") + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var all []InstalledRecipe + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".json" { + continue + } + toolID := tools.ToolID(strings.TrimSuffix(e.Name(), ".json")) + recs, err := loadInstalledForTool(toolID) + if err != nil { + continue + } + all = append(all, recs...) + } + return all, nil +} + +// RecordInstall saves the install record for a recipe, replacing any previously +// installed recipe for the same tool (only one recipe per tool at a time). +func RecordInstall(name, version, cookbook string, tool tools.ToolID) error { + return saveInstalledForTool(tool, []InstalledRecipe{ + { + Name: name, + Version: version, + Cookbook: cookbook, + Tool: tool, + InstalledAt: time.Now(), + }, + }) +} + +// GetLastInstalled returns the most recently installed recipe across all tools, or nil if none. +func GetLastInstalled() (*InstalledRecipe, error) { + list, err := LoadInstalled() + if err != nil { + return nil, err + } + var latest *InstalledRecipe + for i := range list { + if latest == nil || list[i].InstalledAt.After(latest.InstalledAt) { + latest = &list[i] + } + } + return latest, nil +} + +// GetInstalled returns the install record for a recipe by name, or nil if not installed. +func GetInstalled(name string) (*InstalledRecipe, error) { + list, err := LoadInstalled() + if err != nil { + return nil, err + } + for i, r := range list { + if r.Name == name { + return &list[i], nil + } + } + return nil, nil +} + +// Pin marks a recipe as pinned (will not be upgraded automatically). +func Pin(name string) error { + return setPinned(name, true) +} + +// Unpin removes the pin from a recipe. +func Unpin(name string) error { + return setPinned(name, false) +} + +func setPinned(name string, pinned bool) error { + list, err := LoadInstalled() + if err != nil { + return err + } + for _, r := range list { + if r.Name == name { + toolList, err := loadInstalledForTool(r.Tool) + if err != nil { + return err + } + for i, tr := range toolList { + if tr.Name == name { + toolList[i].Pinned = pinned + return saveInstalledForTool(r.Tool, toolList) + } + } + } + } + return fmt.Errorf("recipe %q is not installed", name) +} diff --git a/internal/recipe/installed_per_tool.go b/internal/recipe/installed_per_tool.go new file mode 100644 index 0000000..985e536 --- /dev/null +++ b/internal/recipe/installed_per_tool.go @@ -0,0 +1,58 @@ +package recipe + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/castai/kimchi/internal/tools" +) + +func installedPerToolPath(tool tools.ToolID) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".kimchi", "installed", string(tool)+".json"), nil +} + +func loadInstalledForTool(tool tools.ToolID) ([]InstalledRecipe, error) { + p, err := installedPerToolPath(tool) + if err != nil { + return nil, err + } + data, err := os.ReadFile(p) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read installed: %w", err) + } + var list []InstalledRecipe + if err := json.Unmarshal(data, &list); err != nil { + return nil, fmt.Errorf("parse installed: %w", err) + } + return list, nil +} + +func saveInstalledForTool(tool tools.ToolID, list []InstalledRecipe) error { + p, err := installedPerToolPath(tool) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil { + return err + } + data, err := json.MarshalIndent(list, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, data, 0600) +} + +// clearAllInstalledForTool wipes all installed records for a tool. +// Called when restoring a baseline slot (all recipes are gone). +func clearAllInstalledForTool(tool tools.ToolID) error { + return saveInstalledForTool(tool, nil) +} diff --git a/internal/recipe/installer.go b/internal/recipe/installer.go new file mode 100644 index 0000000..b13f6f0 --- /dev/null +++ b/internal/recipe/installer.go @@ -0,0 +1,522 @@ +package recipe + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/castai/kimchi/internal/config" + "github.com/castai/kimchi/internal/tools" +) + +// ReadFromFile parses a recipe YAML file and returns the Recipe. +func ReadFromFile(path string) (*Recipe, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + var r Recipe + if err := yaml.Unmarshal(data, &r); err != nil { + return nil, fmt.Errorf("parse recipe: %w", err) + } + if r.Name == "" { + return nil, fmt.Errorf("invalid recipe: missing name") + } + if r.Tools.OpenCode == nil { + return nil, fmt.Errorf("invalid recipe: no supported tool configuration found") + } + return &r, nil +} + +// Conflict describes a file that already exists on disk and would be overwritten. +type Conflict struct { + Kind string // "agents_md" | "skill" | "command" | "agent" | "theme" | "plugin" | "tool" | "ref" | "tui" + Name string // human-readable label; empty for single-file kinds like "agents_md" + Path string // absolute path shown to the user +} + +// DetectConflicts returns which of the recipe's embedded assets already exist on disk. +// Only assets present in the recipe are checked — others are never reported. +func DetectConflicts(r *Recipe) ([]Conflict, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + base := filepath.Join(homeDir, ".config", "opencode") + + oc := r.Tools.OpenCode + if oc == nil { + return nil, nil + } + + var conflicts []Conflict + check := func(kind, name, path string) { + if _, err := os.Stat(path); err == nil { + conflicts = append(conflicts, Conflict{Kind: kind, Name: name, Path: path}) + } + } + + if oc.AgentsMD != "" { + check("agents_md", "", filepath.Join(base, "AGENTS.md")) + } + + for _, s := range oc.Skills { + skillDir := filepath.Join(base, "skills", s.Name) + check("skill", s.Name, filepath.Join(skillDir, "SKILL.md")) + for _, f := range s.Files { + check("skill", s.Name+"/"+f.Path, filepath.Join(skillDir, filepath.FromSlash(f.Path))) + } + } + + for _, c := range oc.CustomCommands { + check("command", c.Name, filepath.Join(base, "commands", c.Name+".md")) + } + + for _, a := range oc.Agents { + check("agent", a.Name, filepath.Join(base, "agents", a.Name+".md")) + } + + if oc.TUI != nil { + check("tui", "", filepath.Join(base, "tui.json")) + } + + for _, f := range oc.ThemeFiles { + check("theme", f.Path, filepath.Join(base, "themes", f.Path)) + } + + for _, f := range oc.PluginFiles { + check("plugin", f.Path, filepath.Join(base, "plugins", filepath.FromSlash(f.Path))) + } + + for _, f := range oc.ToolFiles { + check("tool", f.Path, filepath.Join(base, "tools", filepath.FromSlash(f.Path))) + } + + for _, f := range oc.ReferencedFiles { + check("ref", f.Path, filepath.Join(base, filepath.FromSlash(f.Path))) + } + + return conflicts, nil +} + +// AssetDecisions maps each Conflict.Path → true (overwrite) or false (skip). +// Non-conflicting assets (not in this map) are always written. +type AssetDecisions map[string]bool + +// InstallOpenCode writes the recipe's OpenCode config to opencode.json and all +// embedded assets to the appropriate paths. secretValues maps each +// kimchi:secret: placeholder found in the recipe to its real value; all +// placeholders are replaced before any file is written. decisions controls +// overwrite behaviour for files that already exist on disk. +// Returns the list of absolute paths that were actually written. +func InstallOpenCode(r *Recipe, secretValues map[string]string, decisions AssetDecisions) ([]string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + base := filepath.Join(homeDir, ".config", "opencode") + oc := r.Tools.OpenCode + + var written []string + record := func(p string) { written = append(written, p) } + + // ── opencode.json ──────────────────────────────────────────────────────── + + jsonPath := filepath.Join(base, "opencode.json") + existing, err := config.ReadJSON(jsonPath) + if err != nil { + return nil, fmt.Errorf("read existing opencode config: %w", err) + } + + if oc.Providers != nil { + existing["provider"] = oc.Providers + } + setIfNonZero(existing, "model", oc.Model) + setIfNonZero(existing, "small_model", oc.SmallModel) + setIfNonZero(existing, "default_agent", oc.DefaultAgent) + setIfNonNil(existing, "compaction", oc.Compaction) + setIfNonNil(existing, "agent", oc.AgentConfigs) + setIfNonNil(existing, "mcp", oc.MCP) + setIfNonNil(existing, "tools", oc.Tools) + setIfNonNil(existing, "experimental", oc.Experimental) + setIfNonNil(existing, "command", oc.InlineCommands) + if oc.Permission != nil { + existing["permission"] = oc.Permission + } + if oc.Formatter != nil { + existing["formatter"] = oc.Formatter + } + if oc.LSP != nil { + existing["lsp"] = oc.LSP + } + if oc.Snapshot != nil { + existing["snapshot"] = *oc.Snapshot + } + if len(oc.DisabledProviders) > 0 { + existing["disabled_providers"] = oc.DisabledProviders + } + if len(oc.EnabledProviders) > 0 { + existing["enabled_providers"] = oc.EnabledProviders + } + if len(oc.Plugin) > 0 { + existing["plugin"] = oc.Plugin + } + if len(oc.Instructions) > 0 { + existing["instructions"] = oc.Instructions + } + existing["$schema"] = "https://opencode.ai/config.json" + + // Replace every kimchi:secret: placeholder with its real value in one pass. + if len(secretValues) > 0 { + existing = replaceSecretsInAny(existing, secretValues).(map[string]any) + } + + if err := config.WriteJSON(jsonPath, existing); err != nil { + return nil, fmt.Errorf("write opencode config: %w", err) + } + record(jsonPath) + + // ── tui.json ───────────────────────────────────────────────────────────── + + if oc.TUI != nil { + tuiPath := filepath.Join(base, "tui.json") + if shouldWrite(tuiPath, decisions) { + if err := config.WriteJSON(tuiPath, tuiConfigToMap(oc.TUI)); err != nil { + return nil, fmt.Errorf("write tui.json: %w", err) + } + record(tuiPath) + } + } + + // ── AGENTS.md ───────────────────────────────────────────────────────────── + + if oc.AgentsMD != "" { + p := filepath.Join(base, "AGENTS.md") + if shouldWrite(p, decisions) { + if err := config.WriteFile(p, []byte(oc.AgentsMD)); err != nil { + return nil, fmt.Errorf("write AGENTS.md: %w", err) + } + record(p) + } + } + + // ── Skills ─────────────────────────────────────────────────────────────── + + for _, s := range oc.Skills { + skillDir := filepath.Join(base, "skills", s.Name) + p := filepath.Join(skillDir, "SKILL.md") + if shouldWrite(p, decisions) { + if err := config.WriteFile(p, []byte(s.Content)); err != nil { + return nil, fmt.Errorf("write skill %s: %w", s.Name, err) + } + record(p) + } + for _, f := range s.Files { + fp := filepath.Join(skillDir, filepath.FromSlash(f.Path)) + if shouldWrite(fp, decisions) { + if err := config.WriteFile(fp, []byte(f.Content)); err != nil { + return nil, fmt.Errorf("write skill file %s/%s: %w", s.Name, f.Path, err) + } + record(fp) + } + } + } + + // ── Custom commands ─────────────────────────────────────────────────────── + + for _, c := range oc.CustomCommands { + p := filepath.Join(base, "commands", c.Name+".md") + if shouldWrite(p, decisions) { + if err := config.WriteFile(p, []byte(c.Content)); err != nil { + return nil, fmt.Errorf("write command %s: %w", c.Name, err) + } + record(p) + } + } + + // ── Agents ─────────────────────────────────────────────────────────────── + + for _, a := range oc.Agents { + p := filepath.Join(base, "agents", a.Name+".md") + if shouldWrite(p, decisions) { + if err := config.WriteFile(p, []byte(a.Content)); err != nil { + return nil, fmt.Errorf("write agent %s: %w", a.Name, err) + } + record(p) + } + } + + // ── Theme / plugin / tool files ─────────────────────────────────────────── + + for _, f := range oc.ThemeFiles { + p := filepath.Join(base, "themes", f.Path) + if shouldWrite(p, decisions) { + if err := config.WriteFile(p, []byte(f.Content)); err != nil { + return nil, fmt.Errorf("write theme %s: %w", f.Path, err) + } + record(p) + } + } + + for _, f := range oc.PluginFiles { + p := filepath.Join(base, "plugins", filepath.FromSlash(f.Path)) + if shouldWrite(p, decisions) { + if err := config.WriteFile(p, []byte(f.Content)); err != nil { + return nil, fmt.Errorf("write plugin file %s: %w", f.Path, err) + } + record(p) + } + } + + for _, f := range oc.ToolFiles { + p := filepath.Join(base, "tools", filepath.FromSlash(f.Path)) + if shouldWrite(p, decisions) { + if err := config.WriteFile(p, []byte(f.Content)); err != nil { + return nil, fmt.Errorf("write tool file %s: %w", f.Path, err) + } + record(p) + } + } + + // ── @-referenced files ──────────────────────────────────────────────────── + + for _, f := range oc.ReferencedFiles { + p := filepath.Join(base, filepath.FromSlash(f.Path)) + if shouldWrite(p, decisions) { + if err := config.WriteFile(p, []byte(f.Content)); err != nil { + return nil, fmt.Errorf("write referenced file %s: %w", f.Path, err) + } + record(p) + } + } + + return written, nil +} + +// PredictAssetPaths returns the absolute paths that InstallOpenCode would touch +// for the given recipe. Used to determine backup capture scope before installing. +func PredictAssetPaths(r *Recipe) ([]string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + base := filepath.Join(homeDir, ".config", "opencode") + oc := r.Tools.OpenCode + if oc == nil { + return nil, nil + } + var paths []string + add := func(p string) { paths = append(paths, p) } + + add(filepath.Join(base, "opencode.json")) + if oc.TUI != nil { + add(filepath.Join(base, "tui.json")) + } + if oc.AgentsMD != "" { + add(filepath.Join(base, "AGENTS.md")) + } + for _, s := range oc.Skills { + skillDir := filepath.Join(base, "skills", s.Name) + add(filepath.Join(skillDir, "SKILL.md")) + for _, f := range s.Files { + add(filepath.Join(skillDir, filepath.FromSlash(f.Path))) + } + } + for _, c := range oc.CustomCommands { + add(filepath.Join(base, "commands", c.Name+".md")) + } + for _, a := range oc.Agents { + add(filepath.Join(base, "agents", a.Name+".md")) + } + for _, f := range oc.ThemeFiles { + add(filepath.Join(base, "themes", f.Path)) + } + for _, f := range oc.PluginFiles { + add(filepath.Join(base, "plugins", filepath.FromSlash(f.Path))) + } + for _, f := range oc.ToolFiles { + add(filepath.Join(base, "tools", filepath.FromSlash(f.Path))) + } + for _, f := range oc.ReferencedFiles { + add(filepath.Join(base, filepath.FromSlash(f.Path))) + } + return paths, nil +} + +// DetectExternalSecretPlaceholders returns all unique kimchi:secret: placeholder +// strings found in the recipe that are NOT inside the kimchi provider's own +// options block (those are auto-filled from the stored Kimchi API key). +// The returned slice is sorted for stable display in the TUI. +func DetectExternalSecretPlaceholders(r *Recipe) []string { + oc := r.Tools.OpenCode + if oc == nil { + return nil + } + + // Collect every placeholder in the whole OpenCode config. + all := make(map[string]struct{}) + collectSecretPlaceholders(oc.Providers, all) + collectSecretPlaceholders(oc.MCP, all) + collectSecretPlaceholders(oc.AgentConfigs, all) + collectSecretPlaceholders(oc.Tools, all) + + // Placeholders that live inside the kimchi provider's options are handled + // automatically using the stored API key — exclude them. + for p := range kimchiProviderPlaceholders(oc.Providers) { + delete(all, p) + } + + out := make([]string, 0, len(all)) + for p := range all { + out = append(out, p) + } + sort.Strings(out) + return out +} + +// kimchiProviderPlaceholders returns the set of placeholder strings found in +// the kimchi provider's options block. +func kimchiProviderPlaceholders(providers map[string]any) map[string]struct{} { + out := make(map[string]struct{}) + if providers == nil { + return out + } + prov, ok := providers[tools.ProviderName].(map[string]any) + if !ok { + return out + } + opts, ok := prov["options"].(map[string]any) + if !ok { + return out + } + for _, v := range opts { + if s, ok := v.(string); ok && strings.HasPrefix(s, SecretPlaceholderPrefix) { + out[s] = struct{}{} + } + } + return out +} + +// CollectAllSecretPlaceholders populates out with every kimchi:secret: placeholder +// found anywhere in the recipe's OpenCode config (providers, MCP, agents, tools). +func CollectAllSecretPlaceholders(r *Recipe, out map[string]struct{}) { + oc := r.Tools.OpenCode + if oc == nil { + return + } + collectSecretPlaceholders(oc.Providers, out) + collectSecretPlaceholders(oc.MCP, out) + collectSecretPlaceholders(oc.AgentConfigs, out) + collectSecretPlaceholders(oc.Tools, out) +} + +// collectSecretPlaceholders recursively walks v and adds any kimchi:secret: +// prefixed string to out. +func collectSecretPlaceholders(v any, out map[string]struct{}) { + switch v := v.(type) { + case string: + if strings.HasPrefix(v, SecretPlaceholderPrefix) { + out[v] = struct{}{} + } + case map[string]any: + for _, val := range v { + collectSecretPlaceholders(val, out) + } + case []any: + for _, item := range v { + collectSecretPlaceholders(item, out) + } + } +} + +// replaceSecretsInAny recursively walks v and replaces any string that is a +// key in secrets with its corresponding value. New maps/slices are returned so +// the original data is never mutated. +func replaceSecretsInAny(v any, secrets map[string]string) any { + switch v := v.(type) { + case string: + if replacement, ok := secrets[v]; ok { + return replacement + } + return v + case map[string]any: + out := make(map[string]any, len(v)) + for k, val := range v { + out[k] = replaceSecretsInAny(val, secrets) + } + return out + case []any: + out := make([]any, len(v)) + for i, item := range v { + out[i] = replaceSecretsInAny(item, secrets) + } + return out + } + return v +} + + +// setIfNonZero writes key=value to m only when value is not the zero string. +func setIfNonZero(m map[string]any, key, value string) { + if value != "" { + m[key] = value + } +} + +// setIfNonNil writes key=value to m only when value is non-nil. +func setIfNonNil(m map[string]any, key string, value map[string]any) { + if value != nil { + m[key] = value + } +} + +// tuiConfigToMap converts a TUIConfig struct back to a map suitable for WriteJSON. +func tuiConfigToMap(t *TUIConfig) map[string]any { + m := make(map[string]any) + if t.Theme != "" { + m["theme"] = t.Theme + } + if t.ScrollSpeed != 0 { + m["scroll_speed"] = t.ScrollSpeed + } + if t.ScrollAcceleration != nil { + m["scroll_acceleration"] = t.ScrollAcceleration + } + if t.DiffStyle != "" { + m["diff_style"] = t.DiffStyle + } + if len(t.Keybinds) > 0 { + m["keybinds"] = t.Keybinds + } + return m +} + +// shouldWrite returns true if the path should be written. +// Paths not in decisions (no pre-existing conflict) are always written. +// Paths in decisions are written only if the value is true (overwrite). +func shouldWrite(path string, decisions AssetDecisions) bool { + if v, inMap := decisions[path]; inMap { + return v + } + return true +} + +// RemoveAssetFiles removes each path in the list that exists on disk, skipping +// opencode.json since it is a merge target rather than a verbatim asset. +// Missing files are silently ignored. Call this after taking backups and before +// InstallOpenCode to guarantee a conflict-free install. +func RemoveAssetFiles(paths []string) error { + for _, p := range paths { + if filepath.Base(p) == "opencode.json" { + continue + } + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", p, err) + } + } + return nil +} diff --git a/internal/recipe/manifest.go b/internal/recipe/manifest.go new file mode 100644 index 0000000..35cc7ad --- /dev/null +++ b/internal/recipe/manifest.go @@ -0,0 +1,109 @@ +package recipe + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/castai/kimchi/internal/tools" +) + +// RecipeManifest records every file written by an install so they can be +// removed cleanly before a re-install. +type RecipeManifest struct { + RecipeName string `json:"recipe_name"` + Tool tools.ToolID `json:"tool"` + InstalledAt time.Time `json:"installed_at"` + AssetFiles []string `json:"asset_files"` // absolute paths of written files (not opencode.json) +} + +func manifestPath(tool tools.ToolID, recipeName string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".kimchi", "manifests", string(tool), recipeName+".json"), nil +} + +// LoadManifest returns the manifest for (tool, recipeName), or nil if absent. +func LoadManifest(tool tools.ToolID, recipeName string) (*RecipeManifest, error) { + p, err := manifestPath(tool, recipeName) + if err != nil { + return nil, err + } + data, err := os.ReadFile(p) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read manifest: %w", err) + } + var m RecipeManifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse manifest: %w", err) + } + return &m, nil +} + +// SaveManifest persists a manifest. Dirs 0700, file 0600. +func SaveManifest(m *RecipeManifest) error { + p, err := manifestPath(m.Tool, m.RecipeName) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil { + return err + } + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, data, 0600) +} + +// DeleteManifest removes the manifest for (tool, recipeName). No-op if absent. +func DeleteManifest(tool tools.ToolID, recipeName string) error { + p, err := manifestPath(tool, recipeName) + if err != nil { + return err + } + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// clearAllManifestsForTool removes all manifest files for a tool. +// Called when restoring a baseline slot. +func clearAllManifestsForTool(tool tools.ToolID) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + dir := filepath.Join(home, ".kimchi", "manifests", string(tool)) + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + for _, e := range entries { + _ = os.Remove(filepath.Join(dir, e.Name())) + } + return nil +} + +// UninstallByManifest deletes all asset files listed in the manifest (best-effort). +func UninstallByManifest(tool tools.ToolID, recipeName string) error { + m, err := LoadManifest(tool, recipeName) + if err != nil || m == nil { + return err + } + for _, p := range m.AssetFiles { + _ = os.Remove(p) // best-effort + } + return nil +} diff --git a/internal/recipe/push.go b/internal/recipe/push.go new file mode 100644 index 0000000..e4746a9 --- /dev/null +++ b/internal/recipe/push.go @@ -0,0 +1,390 @@ +package recipe + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" + + "github.com/castai/kimchi/internal/config" + "github.com/castai/kimchi/internal/cookbook" +) + +// PushOptions controls the push behaviour. +type PushOptions struct { + // File is the recipe yaml to push. + File string + // CookbookName overrides the cookbook to push to (otherwise taken from the recipe). + CookbookName string + // Version bump flags — at most one may be true. + Patch bool + Minor bool + Major bool + // Meta allows pushing without a version bump when only metadata changed. + Meta bool + // DryRun prints what would happen without writing or pushing. + DryRun bool +} + +// Push publishes a recipe to its target cookbook. +// +// If the user has write access to the cookbook's remote the recipe is committed +// and tagged directly. Otherwise a GitHub device-auth flow is triggered, +// the cookbook is forked, and a pull request is opened. +func Push(opts PushOptions, logFn func(string)) error { + // ── 1. Read the recipe ────────────────────────────────────────────────── + + r, err := ReadFromFile(opts.File) + if err != nil { + return fmt.Errorf("read recipe: %w", err) + } + + // ── 2. Resolve the target cookbook ───────────────────────────────────── + + cb, err := resolveCookbook(r, opts.CookbookName) + if err != nil { + return err + } + // If cookbook was resolved by auto-selection, write it back to the recipe file. + if r.Cookbook != cb.Name { + r.Cookbook = cb.Name + } + + // ── 2a. Sync cookbook with remote ───────────────────────────────────── + // Always reset to the remote state before doing any work. This handles + // both the stale-clone case and the diverged-after-failed-push case. + + if syncErr := cookbook.SyncToRemote(cb.Path); syncErr != nil { + logFn("warning: could not sync cookbook: " + syncErr.Error()) + } + + // ── 3. Check for version bump requirement ────────────────────────────── + + existingPath := cb.RecipePath(r.Name) + var existing *Recipe + if data, err := os.ReadFile(existingPath); err == nil { + var ex Recipe + if yaml.Unmarshal(data, &ex) == nil { + existing = &ex + } + } + + // ── 3a. Compute the tag for the current version ──────────────────────── + // Do this before the "nothing changed" check so we can detect a previous + // interrupted push (local tag exists but remote push failed). + + bumpedVersion := r.Version + switch { + case opts.Major: + bumpedVersion, err = BumpMajor(r.Version) + case opts.Minor: + bumpedVersion, err = BumpMinor(r.Version) + case opts.Patch: + bumpedVersion, err = BumpPatch(r.Version) + } + if err != nil { + return fmt.Errorf("bump version: %w", err) + } + tag := r.Name + "@" + bumpedVersion + + // If the local tag already exists a previous push was interrupted after + // tagging but before a successful push — skip the "nothing changed" guard. + previouslyInterrupted := cookbook.TagExists(cb.Path, tag) + + if existing != nil && !previouslyInterrupted { + bodyChanged := recipeBodyChanged(existing, r) + if bodyChanged && !opts.Patch && !opts.Minor && !opts.Major { + return fmt.Errorf( + "recipe body has changed but no version bump flag was provided\n"+ + " current version: %s\n"+ + " use --patch, --minor, or --major to bump the version", + r.Version, + ) + } + if !bodyChanged && !opts.Meta && !opts.Patch && !opts.Minor && !opts.Major { + return fmt.Errorf("nothing to push — recipe is unchanged (use --meta to push metadata-only changes)") + } + } + + // ── 4. Apply the pre-computed version bump ───────────────────────────── + + r.Version = bumpedVersion + r.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + logFn(fmt.Sprintf("pushing %s as %s", r.Name, tag)) + + if opts.DryRun { + logFn("dry-run: would write recipe to " + existingPath) + logFn("dry-run: would commit, tag " + tag + ", and push") + return nil + } + + // ── 5. Write bumped version back to the source file ──────────────────── + + if err := WriteYAML(opts.File, r); err != nil { + return fmt.Errorf("write recipe file: %w", err) + } + + // ── 6. Copy recipe into the cookbook ────────────────────────────────── + + if err := os.MkdirAll(filepath.Dir(existingPath), 0755); err != nil { + return fmt.Errorf("create recipe dir: %w", err) + } + data, err := os.ReadFile(opts.File) + if err != nil { + return err + } + if err := os.WriteFile(existingPath, data, 0644); err != nil { + return fmt.Errorf("write recipe to cookbook: %w", err) + } + + // ── 7. Commit ────────────────────────────────────────────────────────── + + relPath, _ := filepath.Rel(cb.Path, existingPath) + if err := cookbook.AddFiles(cb.Path, []string{relPath}); err != nil { + return err + } + commitMsg := fmt.Sprintf("Add %s", tag) + if existing != nil { + commitMsg = fmt.Sprintf("Update %s", tag) + } + if err := cookbook.Commit(cb.Path, commitMsg); err != nil { + return err + } + if !cookbook.TagExists(cb.Path, tag) { + if err := cookbook.CreateTag(cb.Path, tag); err != nil { + return err + } + } + + // ── 8. Push (direct or via GitHub PR) ───────────────────────────────── + + logFn("pushing to " + cb.URL + "…") + hasAccess, err := cookbook.Push(cb.Path) + if err != nil { + return err + } + if hasAccess { + // Push the tag only after confirming direct write access — avoids + // leaking tags to the upstream repo when branch protection forces a PR. + if err := cookbook.PushTag(cb.Path, tag); err != nil { + return err + } + logFn("✓ pushed " + tag) + return nil + } + + // No write access → GitHub fork + PR flow + logFn("no write access — starting GitHub fork flow") + return pushViaGitHubPR(r, cb, tag, relPath, opts.File, logFn) +} + +// pushViaGitHubPR forks the cookbook on GitHub, pushes a branch, and opens a PR. +func pushViaGitHubPR(r *Recipe, cb *cookbook.Cookbook, tag, relPath, sourceFile string, logFn func(string)) error { + // Load stored GitHub token, or run device flow now. + token, err := config.GetGitHubToken() + if err != nil || token == "" { + logFn("No GitHub token found — starting device authorisation flow") + token, err = runGitHubDeviceFlow(logFn) + if err != nil { + return fmt.Errorf("GitHub auth: %w", err) + } + if saveErr := config.SetGitHubToken(token); saveErr != nil { + logFn("warning: could not save GitHub token: " + saveErr.Error()) + } + } + + username, err := cookbook.GetUsername(token) + if err != nil { + return fmt.Errorf("get GitHub username: %w", err) + } + + // Force the author to match the authenticated GitHub username. + if r.Author != username { + r.Author = username + if err := WriteYAML(sourceFile, r); err != nil { + return fmt.Errorf("update author in recipe file: %w", err) + } + logFn(fmt.Sprintf("author set to %s", username)) + } + + owner, repo, err := cookbook.ParseGitHubURL(cb.URL) + if err != nil { + return fmt.Errorf("parse cookbook URL: %w", err) + } + + // Fork the cookbook if needed + logFn(fmt.Sprintf("forking %s/%s…", owner, repo)) + forkURL, err := cookbook.ForkRepo(token, owner, repo) + if err != nil { + return fmt.Errorf("fork cookbook: %w", err) + } + + // Clone fork into a temp dir + tmp, err := os.MkdirTemp("", "kimchi-push-*") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + + cloneURL := cookbook.TokenCloneURL(token, forkURL) + logFn("cloning fork…") + if err := cookbook.Clone(cloneURL, tmp); err != nil { + return fmt.Errorf("clone fork: %w", err) + } + + // Sync the fork clone to the upstream so the branch has no conflicts. + upstreamURL := cookbook.TokenCloneURL(token, cb.URL) + logFn("syncing fork with upstream…") + if err := cookbook.AddRemote(tmp, "upstream", upstreamURL); err != nil { + return fmt.Errorf("add upstream remote: %w", err) + } + if err := cookbook.SyncForkToUpstream(tmp); err != nil { + return fmt.Errorf("sync fork to upstream: %w", err) + } + + branch := "add/" + tag + if err := cookbook.CreateBranch(tmp, branch); err != nil { + return fmt.Errorf("create branch: %w", err) + } + + // Copy recipe into temp clone + destPath := filepath.Join(tmp, relPath) + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return err + } + data, err := os.ReadFile(cb.RecipePath(r.Name)) + if err != nil { + return err + } + if err := os.WriteFile(destPath, data, 0644); err != nil { + return err + } + + if err := cookbook.AddFiles(tmp, []string{relPath}); err != nil { + return err + } + if err := cookbook.Commit(tmp, fmt.Sprintf("Add recipe %s", tag)); err != nil { + return err + } + + logFn("pushing branch to fork…") + hasAccess, err := cookbook.PushBranch(tmp, branch) + if err != nil { + return err + } + if !hasAccess { + return fmt.Errorf("could not push to fork — check GitHub token permissions") + } + + // Open PR (or report if one already exists) + head := username + ":" + branch + prNum, prURL, _ := cookbook.FindOpenPR(token, owner, repo, head) + if prNum > 0 { + logFn(fmt.Sprintf("updated existing PR: %s", prURL)) + return nil + } + + prURL, err = cookbook.CreatePR( + token, owner, repo, + head, "main", + fmt.Sprintf("Add recipe %s", tag), + fmt.Sprintf("Adding recipe `%s` version `%s`.", r.Name, r.Version), + ) + if err != nil { + return fmt.Errorf("create PR: %w", err) + } + logFn(fmt.Sprintf("✓ PR opened: %s", prURL)) + return nil +} + +// resolveCookbook finds the target cookbook for a push. +// If only one cookbook is registered it is used automatically. +// If multiple exist and neither the recipe nor the --cookbook flag resolves one, +// it returns an error asking the user to set one. +func resolveCookbook(r *Recipe, flagCookbook string) (*cookbook.Cookbook, error) { + cookbooks, err := cookbook.Load() + if err != nil { + return nil, fmt.Errorf("load cookbooks: %w", err) + } + if len(cookbooks) == 0 { + return nil, fmt.Errorf("no cookbooks registered — use `kimchi cookbook add ` first") + } + + target := flagCookbook + if target == "" { + target = r.Cookbook + } + + if target != "" { + for i, cb := range cookbooks { + if cb.Name == target { + return &cookbooks[i], nil + } + } + return nil, fmt.Errorf("cookbook %q not found in registered cookbooks", target) + } + + if len(cookbooks) == 1 { + return &cookbooks[0], nil + } + + // Multiple cookbooks and no explicit target — fall back to the default cookbook. + for i, cb := range cookbooks { + if cookbook.IsDefault(cb.Name) { + return &cookbooks[i], nil + } + } + + names := make([]string, len(cookbooks)) + for i, cb := range cookbooks { + names[i] = cb.Name + } + return nil, fmt.Errorf( + "multiple cookbooks registered — specify one with --cookbook\n available: %s", + joinStrings(names, ", "), + ) +} + +// recipeBodyChanged compares the tools section of two recipes. +func recipeBodyChanged(a, b *Recipe) bool { + aBytes, _ := yaml.Marshal(a.Tools) + bBytes, _ := yaml.Marshal(b.Tools) + return !bytes.Equal(aBytes, bBytes) +} + +// runGitHubDeviceFlow starts the GitHub device auth flow, prints the code and URL +// via logFn, polls until the user authorises, and returns the access token. +func runGitHubDeviceFlow(logFn func(string)) (string, error) { + dcr, err := cookbook.RequestDeviceCode() + if err != nil { + return "", err + } + logFn(fmt.Sprintf("Open %s and enter code: %s", dcr.VerificationURL, dcr.UserCode)) + + deadline := time.Now().Add(time.Duration(dcr.ExpiresIn) * time.Second) + for time.Now().Before(deadline) { + time.Sleep(time.Duration(dcr.Interval) * time.Second) + token, err := cookbook.PollForToken(dcr.DeviceCode) + if err != nil { + return "", err + } + if token != "" { + return token, nil + } + } + return "", fmt.Errorf("device auth timed out") +} + +func joinStrings(ss []string, sep string) string { + result := "" + for i, s := range ss { + if i > 0 { + result += sep + } + result += s + } + return result +} 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..ce94575 --- /dev/null +++ b/internal/recipe/recipe.go @@ -0,0 +1,126 @@ +package recipe + +// Recipe is the top-level portable snapshot of an AI tool configuration. +type Recipe struct { + // Header — identity and routing metadata + Name string `yaml:"name"` + Version string `yaml:"version"` // semver, e.g. "0.1.0" + Cookbook string `yaml:"cookbook,omitempty"` + Author string `yaml:"author,omitempty"` + Description string `yaml:"description,omitempty"` + Tags []string `yaml:"tags,omitempty"` + CreatedAt string `yaml:"created_at,omitempty"` + UpdatedAt string `yaml:"updated_at,omitempty"` + ForkedFrom *ForkedFrom `yaml:"forked_from,omitempty"` + + // Convenience summary fields + Model string `yaml:"model,omitempty"` + UseCase string `yaml:"use_case,omitempty"` + + // Tool-specific config blocks + Tools ToolsMap `yaml:"tools"` +} + +// ForkedFrom records the origin of a recipe that was created via `recipe fork`. +type ForkedFrom struct { + Author string `yaml:"author"` + Cookbook string `yaml:"cookbook"` + Version string `yaml:"version"` +} + +// ToolsMap holds per-tool configuration blocks. Fields are omitted when nil. +type ToolsMap struct { + OpenCode *OpenCodeConfig `yaml:"opencode,omitempty"` +} + +// SupportedToolNames returns the names of tools that have config blocks in this recipe. +func (t ToolsMap) SupportedToolNames() []string { + var names []string + if t.OpenCode != nil { + names = append(names, "opencode") + } + return names +} + +// 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/registry.go b/internal/recipe/registry.go new file mode 100644 index 0000000..ec62a1c --- /dev/null +++ b/internal/recipe/registry.go @@ -0,0 +1,209 @@ +package recipe + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/castai/kimchi/internal/cookbook" +) + +// RecipeRef is a lightweight reference to a recipe in a local cookbook. +type RecipeRef struct { + Name string + Version string + Cookbook string // cookbook name + Author string + Tools string // comma-joined list of supported tool names + Path string // absolute path to recipe.yaml +} + +// ResolveSource parses a source string and returns the matching recipe. +// +// Supported formats: +// - ./path/to/recipe.yaml or /abs/path → read from file +// - name@version → match by name and exact version +// - author/name → match by cookbook name / recipe name +// - name → match by recipe name (first match across cookbooks) +func ResolveSource(source string) (*Recipe, error) { + if isFilePath(source) { + return ReadFromFile(source) + } + ref, err := FindRecipe(source) + if err != nil { + return nil, err + } + return ReadFromFile(ref.Path) +} + +// FindRecipe locates a recipe in the registered cookbooks by source string. +func FindRecipe(source string) (*RecipeRef, error) { + name, version, cookbookName := parseSource(source) + + cookbooks, err := cookbook.Load() + if err != nil { + return nil, fmt.Errorf("load cookbooks: %w", err) + } + if len(cookbooks) == 0 { + return nil, fmt.Errorf("no cookbooks registered — use `kimchi cookbook add ` first") + } + + for _, cb := range cookbooks { + if cookbookName != "" && cb.Name != cookbookName { + continue + } + recipesDir := filepath.Join(cb.Path, "recipes") + entries, err := os.ReadDir(recipesDir) + if err != nil { + continue + } + for _, e := range entries { + if !e.IsDir() || !strings.EqualFold(e.Name(), name) { + continue + } + p := filepath.Join(recipesDir, e.Name(), "recipe.yaml") + r, err := readHeaderOnly(p) + if err != nil { + continue + } + if version != "" && r.Version != version { + continue + } + return &RecipeRef{ + Name: r.Name, + Version: r.Version, + Cookbook: cb.Name, + Author: r.Author, + Tools: strings.Join(r.Tools.SupportedToolNames(), ", "), + Path: p, + }, nil + } + } + return nil, fmt.Errorf("recipe %q not found in any registered cookbook", source) +} + +// ListAll returns all recipes found across all registered cookbooks. +func ListAll() ([]RecipeRef, error) { + cookbooks, err := cookbook.Load() + if err != nil { + return nil, fmt.Errorf("load cookbooks: %w", err) + } + var refs []RecipeRef + for _, cb := range cookbooks { + found, err := listCookbook(cb) + if err != nil { + continue + } + refs = append(refs, found...) + } + return refs, nil +} + +// Search returns recipes whose name, description, or tags match query. +func Search(query string) ([]RecipeRef, error) { + all, err := ListAll() + if err != nil { + return nil, err + } + lower := strings.ToLower(query) + var results []RecipeRef + for _, ref := range all { + if matchesQuery(ref, lower) { + results = append(results, ref) + } + } + return results, nil +} + +func listCookbook(cb cookbook.Cookbook) ([]RecipeRef, error) { + recipesDir := filepath.Join(cb.Path, "recipes") + entries, err := os.ReadDir(recipesDir) + if err != nil { + return nil, err + } + var refs []RecipeRef + for _, e := range entries { + if !e.IsDir() { + continue + } + p := filepath.Join(recipesDir, e.Name(), "recipe.yaml") + r, err := readHeaderOnly(p) + if err != nil { + continue + } + refs = append(refs, RecipeRef{ + Name: r.Name, + Version: r.Version, + Cookbook: cb.Name, + Author: r.Author, + Tools: strings.Join(r.Tools.SupportedToolNames(), ", "), + Path: p, + }) + } + return refs, nil +} + +func matchesQuery(ref RecipeRef, lower string) bool { + if strings.Contains(strings.ToLower(ref.Name), lower) { + return true + } + // Load full recipe for description / tags search + r, err := readHeaderOnly(ref.Path) + if err != nil { + return false + } + if strings.Contains(strings.ToLower(r.Description), lower) { + return true + } + for _, t := range r.Tags { + if strings.Contains(strings.ToLower(t), lower) { + return true + } + } + return false +} + +// readHeaderOnly reads only the top-level recipe fields (no tool config) for speed. +func readHeaderOnly(path string) (*Recipe, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var r Recipe + if err := yaml.Unmarshal(data, &r); err != nil { + return nil, err + } + return &r, nil +} + +// isFilePath reports whether source looks like a file system path. +func isFilePath(source string) bool { + if strings.HasPrefix(source, "./") || strings.HasPrefix(source, "../") || strings.HasPrefix(source, "/") { + return true + } + if strings.HasSuffix(source, ".yaml") || strings.HasSuffix(source, ".yml") { + return true + } + return false +} + +// parseSource splits "name", "author/name", "name@version", "author/name@version" +// into (name, version, cookbookName). +func parseSource(source string) (name, version, cookbookName string) { + // Split off @version + if idx := strings.LastIndex(source, "@"); idx >= 0 { + version = source[idx+1:] + source = source[:idx] + } + // Split author/name + if idx := strings.Index(source, "/"); idx >= 0 { + cookbookName = source[:idx] + name = source[idx+1:] + } else { + name = source + } + return +} 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/recipe/version.go b/internal/recipe/version.go new file mode 100644 index 0000000..b73d2cc --- /dev/null +++ b/internal/recipe/version.go @@ -0,0 +1,63 @@ +package recipe + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" +) + +// BumpPatch increments the patch component of a semver string (e.g. "1.2.3" → "1.2.4"). +func BumpPatch(v string) (string, error) { + sv, err := semver.NewVersion(v) + if err != nil { + return "", fmt.Errorf("parse version %q: %w", v, err) + } + next := sv.IncPatch() + return next.Original(), nil +} + +// BumpMinor increments the minor component (e.g. "1.2.3" → "1.3.0"). +func BumpMinor(v string) (string, error) { + sv, err := semver.NewVersion(v) + if err != nil { + return "", fmt.Errorf("parse version %q: %w", v, err) + } + next := sv.IncMinor() + return next.Original(), nil +} + +// BumpMajor increments the major component (e.g. "1.2.3" → "2.0.0"). +func BumpMajor(v string) (string, error) { + sv, err := semver.NewVersion(v) + if err != nil { + return "", fmt.Errorf("parse version %q: %w", v, err) + } + next := sv.IncMajor() + return next.Original(), nil +} + +// ValidateVersion returns an error if v is not a valid semver string. +func ValidateVersion(v string) error { + _, err := semver.NewVersion(v) + if err != nil { + return fmt.Errorf("%q is not valid semver: %w", v, err) + } + return nil +} + +// CompareVersions returns -1, 0, or 1 if a < b, a == b, or a > b. +// Invalid versions sort as less than valid ones. +func CompareVersions(a, b string) int { + va, err1 := semver.NewVersion(a) + vb, err2 := semver.NewVersion(b) + if err1 != nil && err2 != nil { + return 0 + } + if err1 != nil { + return -1 + } + if err2 != nil { + return 1 + } + return va.Compare(vb) +} 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/auth_wizard.go b/internal/tui/auth_wizard.go new file mode 100644 index 0000000..a34a05e --- /dev/null +++ b/internal/tui/auth_wizard.go @@ -0,0 +1,45 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + + "github.com/castai/kimchi/internal/tui/steps" +) + +// authWizard is a minimal bubbletea model that runs a single auth step standalone. +type authWizard struct { + step steps.Step + aborted bool + done bool +} + +func (w *authWizard) Init() tea.Cmd { + return tea.Batch(w.step.Init(), tea.EnterAltScreen) +} + +func (w *authWizard) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case steps.NextStepMsg: + w.done = true + return w, tea.Quit + case steps.AbortMsg: + w.aborted = true + return w, tea.Quit + } + updated, cmd := w.step.Update(msg) + w.step = updated + return w, cmd +} + +func (w *authWizard) View() string { + return steps.StepView(w.step.Info(), w.step.View()) +} + +// RunAuthWizard launches the standalone Cast AI API key auth TUI. +func RunAuthWizard() error { + w := &authWizard{step: steps.NewAuthStep()} + p := tea.NewProgram(w, tea.WithAltScreen()) + _, err := p.Run() + return err +} + diff --git a/internal/tui/export_wizard.go b/internal/tui/export_wizard.go new file mode 100644 index 0000000..c4f7aaf --- /dev/null +++ b/internal/tui/export_wizard.go @@ -0,0 +1,338 @@ +package tui + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/castai/kimchi/internal/config" + "github.com/castai/kimchi/internal/cookbook" + "github.com/castai/kimchi/internal/recipe" + "github.com/castai/kimchi/internal/tools" + "github.com/castai/kimchi/internal/tui/steps" +) + +const defaultOutputPath = "kimchi-recipe.yaml" + +// ExportWizardOptions are the options for the export wizard, set from CLI flags. +type ExportWizardOptions struct { + OutputPath string + Name string // pre-fills the recipe name prompt + Tags []string // pre-fills tags; combined with any entered in the wizard + DryRun bool // print to stdout instead of writing a file + Seed *recipe.Recipe // pre-fills metadata from an existing recipe + GitHubUsername string // forces the author field to the authenticated GitHub identity +} + +// 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 + dryRun bool + 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 from the given options. +func newExportWizard(wizOpts ExportWizardOptions) *exportWizard { + toolStep := steps.NewExportToolStep() + scopeStep := steps.NewExportScopeStep() + + // Derive initial meta values: flags > GitHub identity > seed. + initialName := wizOpts.Name + initialAuthor := wizOpts.GitHubUsername // always use GitHub username when available + initialDescription := "" + initialTags := wizOpts.Tags + if wizOpts.Seed != nil { + if initialName == "" { + initialName = wizOpts.Seed.Name + } + if initialAuthor == "" { + initialAuthor = wizOpts.Seed.Author + } + initialDescription = wizOpts.Seed.Description + if len(initialTags) == 0 { + initialTags = wizOpts.Seed.Tags + } + } + + meta := steps.NewExportMetaStep(initialName, initialAuthor, initialDescription) + githubUsername := wizOpts.GitHubUsername + meta.SetSeedLookupFn(func(name string) (string, string, []string) { + if ref, _ := recipe.FindRecipe(name); ref != nil { + if seed, _ := recipe.ReadFromFile(ref.Path); seed != nil { + author := seed.Author + if githubUsername != "" { + author = githubUsername + } + return author, seed.Description, seed.Tags + } + } + return githubUsername, "", nil + }) + useCase := steps.NewExportUseCaseStep() + + w := &exportWizard{ + outputPath: wizOpts.OutputPath, + dryRun: wizOpts.DryRun, + scope: config.ScopeGlobal, // default; updated when scope step completes + toolStep: toolStep, + scopeStep: scopeStep, + metaStep: meta, + useCaseStep: useCase, + opts: recipe.ExportOptions{ + Tags: initialTags, + Seed: wizOpts.Seed, + }, + } + + // 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) + } + if w.dryRun { + return a.UnresolvedRefs, recipe.WriteYAMLTo(os.Stdout, r) + } + 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 := wizOpts.OutputPath + if confirmOutputPath == "" { + confirmOutputPath = defaultOutputPath + } + if wizOpts.DryRun { + confirmOutputPath = "" + } + 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 wizOpts.OutputPath == "" && !wizOpts.DryRun { + 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() + // If no seed yet and the name matches an installed recipe, load it now + // so Build() preserves its lineage (version, cookbook, forked_from, created_at) + // and fills in any fields the user left blank. + if w.opts.Seed == nil && w.opts.Name != "" { + if ref, _ := recipe.FindRecipe(w.opts.Name); ref != nil { + if seed, _ := recipe.ReadFromFile(ref.Path); seed != nil { + w.opts.Seed = seed + if w.opts.Author == "" { + w.opts.Author = seed.Author + } + if w.opts.Description == "" { + w.opts.Description = seed.Description + } + if len(w.opts.Tags) == 0 { + w.opts.Tags = seed.Tags + } + } + } + } + + 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. +func RunExportWizard(wizOpts ExportWizardOptions) error { + // Pre-fill author from stored GitHub identity so the recipe always uses + // the GitHub username rather than a free-form string. + if wizOpts.GitHubUsername == "" { + if token, _ := config.GetGitHubToken(); token != "" { + if username, _ := cookbook.GetUsername(token); username != "" { + wizOpts.GitHubUsername = username + } + } + } + // If a name was provided and no seed was explicitly set, try to load the + // existing recipe from any registered cookbook to pre-fill metadata. + if wizOpts.Name != "" && wizOpts.Seed == nil { + if ref, _ := recipe.FindRecipe(wizOpts.Name); ref != nil { + if seed, _ := recipe.ReadFromFile(ref.Path); seed != nil { + wizOpts.Seed = seed + } + } + } + // No name provided — pre-fill from the last installed recipe so the user + // can re-export without retyping everything. + if wizOpts.Name == "" && wizOpts.Seed == nil { + if last, _ := recipe.GetLastInstalled(); last != nil { + if ref, _ := recipe.FindRecipe(last.Name); ref != nil { + if seed, _ := recipe.ReadFromFile(ref.Path); seed != nil { + wizOpts.Name = seed.Name + wizOpts.Seed = seed + } + } + } + } + w := newExportWizard(wizOpts) + p := tea.NewProgram(w, tea.WithAltScreen()) + _, err := p.Run() + return err +} diff --git a/internal/tui/install_wizard.go b/internal/tui/install_wizard.go new file mode 100644 index 0000000..b8cb98c --- /dev/null +++ b/internal/tui/install_wizard.go @@ -0,0 +1,320 @@ +package tui + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + 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" +) + +// InstallWizardOptions are the options for the install wizard, set from CLI flags. +type InstallWizardOptions struct { + Source string // file path, recipe name, or name@version + NoApply bool // preview only; do not write any files +} + +// installWizard drives the recipe install flow. +// Steps are injected dynamically after the source step resolves the recipe. +type installWizard struct { + stepList []steps.Step + current int + aborted bool + finished bool + noApply bool + + // collected across steps + parsedRecipe *recipe.Recipe + filteredRecipe *recipe.Recipe // recipe after user's asset selection + apiKey string + secretValues map[string]string // placeholder → real value, built up incrementally + decisions recipe.AssetDecisions + + // typed refs for dynamic injection and result collection + sourceStep *steps.InstallSourceStep + assetsStep *steps.InstallAssetsStep + secretsStep *steps.InstallSecretsStep + progressStep *steps.InstallProgressStep +} + +func newInstallWizard(wizOpts InstallWizardOptions) *installWizard { + source := steps.NewInstallSourceStep(strings.TrimSpace(wizOpts.Source)) + w := &installWizard{ + noApply: wizOpts.NoApply, + sourceStep: source, + secretValues: make(map[string]string), + stepList: []steps.Step{source}, + } + return w +} + +func (w *installWizard) Init() tea.Cmd { + return tea.Batch(w.stepList[0].Init(), tea.EnterAltScreen) +} + +func (w *installWizard) 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 *installWizard) View() string { + if w.current >= len(w.stepList) { + return "" + } + step := w.stepList[w.current] + return steps.StepView(step.Info(), step.View()) +} + +func (w *installWizard) collectStepResult() { + if w.current >= len(w.stepList) { + return + } + switch s := w.stepList[w.current].(type) { + case *steps.InstallSourceStep: + w.parsedRecipe = s.ParsedRecipe() + w.filteredRecipe = w.parsedRecipe + w.injectRemainingSteps() + + case *steps.InstallAssetsStep: + w.filteredRecipe = s.FilteredRecipe() + w.applyKimchiSecrets() + w.rebuildWriteFn() + + case *steps.AuthStep: + w.apiKey = s.APIKey() + w.applyKimchiSecrets() + w.rebuildWriteFn() + + case *steps.InstallSecretsStep: + for k, v := range s.SecretValues() { + w.secretValues[k] = v + } + w.rebuildWriteFn() + + case *steps.InstallConflictsStep: + w.decisions = s.Decisions() + w.rebuildWriteFn() + } +} + +// injectRemainingSteps is called once after the source step resolves the recipe. +// It builds the remaining step list based on what's needed. +func (w *installWizard) injectRemainingSteps() { + r := w.parsedRecipe + + // Preview is always shown. + preview := steps.NewInstallPreviewStep(r) + + // Assets selection step — always shown so the user can pick a subset. + assetsStep := steps.NewInstallAssetsStep(r) + w.assetsStep = assetsStep + + // Assemble step list starting after the source step (index 0). + tail := []steps.Step{preview, assetsStep} + + // When --no-apply is set, stop after the preview — no auth, conflicts, or install. + if w.noApply { + w.stepList = append(w.stepList[:1], tail...) + return + } + + // Auth step only if no Kimchi API key is stored yet. + // NOTE: auth / secrets / conflicts are based on the full recipe at this point; + // they are re-evaluated after the assets step in rebuildWriteFn. + var authStep *steps.AuthStep + if key, _ := config.GetAPIKey(); key == "" { + authStep = steps.NewAuthStep() + } else { + w.apiKey = func() string { k, _ := config.GetAPIKey(); return k }() + w.applyKimchiSecrets() + } + + // Secrets step if the recipe contains third-party provider or MCP secrets. + var secretsStep *steps.InstallSecretsStep + if externalSecrets := recipe.DetectExternalSecretPlaceholders(r); len(externalSecrets) > 0 { + secretsStep = steps.NewInstallSecretsStep(externalSecrets) + w.secretsStep = secretsStep + } + + // Progress step — always last. writeFn is a placeholder until all data is known. + itemLabels := w.buildItemLabels(r, nil) + progress := steps.NewInstallProgressStep(itemLabels, func() error { + return fmt.Errorf("install not ready") // replaced by rebuildWriteFn + }) + w.progressStep = progress + + if authStep != nil { + tail = append(tail, authStep) + } + if secretsStep != nil { + tail = append(tail, secretsStep) + } + tail = append(tail, progress) + w.stepList = append(w.stepList[:1], tail...) +} + +// applyKimchiSecrets maps the kimchi provider's secret placeholders to the +// stored API key. Called as soon as apiKey is known (either from storage or +// after the auth step completes). +func (w *installWizard) applyKimchiSecrets() { + r := w.filteredRecipe + if r == nil { + r = w.parsedRecipe + } + if r == nil || r.Tools.OpenCode == nil { + return + } + // Collect ALL placeholders, subtract external ones — the remainder are kimchi secrets. + all := make(map[string]struct{}) + recipe.CollectAllSecretPlaceholders(r, all) + external := recipe.DetectExternalSecretPlaceholders(r) + externalSet := make(map[string]struct{}, len(external)) + for _, p := range external { + externalSet[p] = struct{}{} + } + for p := range all { + if _, isExternal := externalSet[p]; !isExternal { + w.secretValues[p] = w.apiKey + } + } +} + +// rebuildWriteFn assembles the final writeFn once all secrets and decisions are known. +func (w *installWizard) rebuildWriteFn() { + if w.progressStep == nil { + return + } + r := w.filteredRecipe + if r == nil { + r = w.parsedRecipe + } + if r == nil { + return + } + // Update the progress checklist to reflect the filtered asset selection. + w.progressStep.SetItems(w.buildItemLabels(r, nil)) + + secretValues := w.secretValues + decisions := w.decisions + orig := w.parsedRecipe // for RecordInstall (name/version/cookbook from original) + + w.progressStep.SetWriteFn(func() error { + filesToCapture, err := recipe.PredictAssetPaths(orig) + if err != nil { + return fmt.Errorf("predict asset paths: %w", err) + } + baselineBacked, err := recipe.EnsureBaseline(tools.ToolOpenCode, filesToCapture) + if err != nil { + return fmt.Errorf("backup baseline: %w", err) + } + if err := recipe.RemoveAssetFiles(baselineBacked); err != nil { + return fmt.Errorf("clean baseline assets: %w", err) + } + snapshotBacked, err := recipe.SnapshotCurrentlyInstalled(tools.ToolOpenCode) + if err != nil { + return fmt.Errorf("backup current recipes: %w", err) + } + if err := recipe.RemoveAssetFiles(snapshotBacked); err != nil { + return fmt.Errorf("clean installed assets: %w", err) + } + + written, err := recipe.InstallOpenCode(r, secretValues, decisions) + if err != nil { + return err + } + + // Save manifest (exclude opencode.json — merge target, not verbatim). + var assetFiles []string + for _, p := range written { + if filepath.Base(p) != "opencode.json" { + assetFiles = append(assetFiles, p) + } + } + _ = recipe.SaveManifest(&recipe.RecipeManifest{ + RecipeName: orig.Name, + Tool: tools.ToolOpenCode, + InstalledAt: time.Now(), + AssetFiles: assetFiles, + }) + _ = recipe.RecordInstall(orig.Name, orig.Version, orig.Cookbook, tools.ToolOpenCode) + return nil + }) +} + +// buildItemLabels returns human-readable labels for the progress step checklist. +func (w *installWizard) buildItemLabels(r *recipe.Recipe, _ recipe.AssetDecisions) []string { + oc := r.Tools.OpenCode + labels := []string{"opencode.json"} + + if oc == nil { + return labels + } + if oc.TUI != nil { + labels = append(labels, "tui.json") + } + if oc.AgentsMD != "" { + labels = append(labels, "AGENTS.md") + } + for _, s := range oc.Skills { + labels = append(labels, fmt.Sprintf("skills/%s/SKILL.md", s.Name)) + for _, f := range s.Files { + labels = append(labels, fmt.Sprintf("skills/%s/%s", s.Name, f.Path)) + } + } + for _, c := range oc.CustomCommands { + labels = append(labels, fmt.Sprintf("commands/%s.md", c.Name)) + } + for _, a := range oc.Agents { + labels = append(labels, fmt.Sprintf("agents/%s.md", a.Name)) + } + for _, f := range oc.ThemeFiles { + labels = append(labels, fmt.Sprintf("themes/%s", f.Path)) + } + for _, f := range oc.PluginFiles { + labels = append(labels, fmt.Sprintf("plugins/%s", f.Path)) + } + for _, f := range oc.ToolFiles { + labels = append(labels, fmt.Sprintf("tools/%s", f.Path)) + } + for _, f := range oc.ReferencedFiles { + labels = append(labels, f.Path) + } + return labels +} + +// RunInstallWizard launches the recipe install TUI. +func RunInstallWizard(wizOpts InstallWizardOptions) error { + w := newInstallWizard(wizOpts) + p := tea.NewProgram(w, tea.WithAltScreen()) + _, err := p.Run() + return err +} diff --git a/internal/tui/restore_wizard.go b/internal/tui/restore_wizard.go new file mode 100644 index 0000000..58d2084 --- /dev/null +++ b/internal/tui/restore_wizard.go @@ -0,0 +1,80 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + + "github.com/castai/kimchi/internal/tui/steps" +) + +type restoreWizard struct { + stepList []steps.Step + current int + pickerStep *steps.RestorePickerStep + confirmStep *steps.RestoreConfirmStep +} + +func newRestoreWizard() *restoreWizard { + picker := steps.NewRestorePickerStep() + return &restoreWizard{ + stepList: []steps.Step{picker}, + pickerStep: picker, + } +} + +func (w *restoreWizard) Init() tea.Cmd { + return tea.Batch(w.stepList[0].Init(), tea.EnterAltScreen) +} + +func (w *restoreWizard) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case steps.NextStepMsg: + // After picker: inject confirm step with selected slot. + if picker, ok := w.stepList[w.current].(*steps.RestorePickerStep); ok { + if slot := picker.SelectedSlot(); slot != nil && w.confirmStep == nil { + confirm := steps.NewRestoreConfirmStep(slot) + w.confirmStep = confirm + w.stepList = append(w.stepList, confirm) + } + } + if w.current >= len(w.stepList)-1 { + return w, tea.Quit + } + w.current++ + return w, w.stepList[w.current].Init() + + case steps.PrevStepMsg: + if w.current > 0 { + // Going back from confirm: allow re-picking. + if w.confirmStep != nil && w.current == len(w.stepList)-1 { + w.stepList = w.stepList[:len(w.stepList)-1] + w.confirmStep = nil + } + w.current-- + return w, w.stepList[w.current].Init() + } + return w, nil + + case steps.AbortMsg: + return w, tea.Quit + } + + updatedStep, cmd := w.stepList[w.current].Update(msg) + w.stepList[w.current] = updatedStep + return w, cmd +} + +func (w *restoreWizard) View() string { + if w.current >= len(w.stepList) { + return "" + } + step := w.stepList[w.current] + return steps.StepView(step.Info(), step.View()) +} + +// RunRestoreWizard launches the backup restore TUI. +func RunRestoreWizard() error { + w := newRestoreWizard() + 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..ecdee50 --- /dev/null +++ b/internal/tui/steps/export_meta.go @@ -0,0 +1,169 @@ +package steps + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +// SeedLookupFn returns pre-fill values for author, description, and tags +// given a recipe name. Called when the cursor leaves the name field. +// Return empty strings / nil slice to leave fields as-is. +type SeedLookupFn func(name string) (author, description string, tags []string) + +// ExportMetaStep collects recipe metadata: name, author, description. +type ExportMetaStep struct { + inputs [3]textinput.Model + focused int + err string + seedLookupFn SeedLookupFn +} + +// NewExportMetaStep creates the metadata step. Initial values pre-fill the +// corresponding fields; empty strings leave them blank. +func NewExportMetaStep(initialName, initialAuthor, initialDescription string) *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].SetValue(initialName) + s.inputs[1].SetValue(initialAuthor) + s.inputs[2].SetValue(initialDescription) + s.inputs[0].Focus() + return s +} + +// SetSeedLookupFn attaches a function that pre-fills author/description/tags +// when the user moves away from the name field. +func (s *ExportMetaStep) SetSeedLookupFn(fn SeedLookupFn) { + s.seedLookupFn = fn +} + +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 +} + +// applySeedIfLeavingName pre-fills author/description from the seed lookup +// function when the cursor is leaving field 0 (the name field). +func (s *ExportMetaStep) applySeedIfLeavingName() { + if s.focused != 0 || s.seedLookupFn == nil { + return + } + name := s.RecipeName() + if name == "" { + return + } + author, description, _ := s.seedLookupFn(name) + if s.inputs[1].Value() == "" && author != "" { + s.inputs[1].SetValue(author) + } + if s.inputs[2].Value() == "" && description != "" { + s.inputs[2].SetValue(description) + } +} + +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.applySeedIfLeavingName() + 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.applySeedIfLeavingName() + 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, + }, + } +} diff --git a/internal/tui/steps/install_assets.go b/internal/tui/steps/install_assets.go new file mode 100644 index 0000000..1143f75 --- /dev/null +++ b/internal/tui/steps/install_assets.go @@ -0,0 +1,294 @@ +package steps + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/castai/kimchi/internal/recipe" +) + +const assetsPageSize = 14 + +type installAssetItem struct { + id string // unique key: "config", "agents_md", "skill:name", etc. + label string // display label + desc string // secondary line +} + +// InstallAssetsStep lets the user pick which assets from a recipe to install. +// All items default to selected. FilteredRecipe() returns a copy of the recipe +// containing only the checked assets. +type InstallAssetsStep struct { + r *recipe.Recipe + items []installAssetItem + selected map[string]bool + cursor int + offset int +} + +func NewInstallAssetsStep(r *recipe.Recipe) *InstallAssetsStep { + s := &InstallAssetsStep{ + r: r, + selected: make(map[string]bool), + } + s.buildItems() + return s +} + +func (s *InstallAssetsStep) buildItems() { + oc := s.r.Tools.OpenCode + if oc == nil { + return + } + + add := func(id, label, desc string) { + s.items = append(s.items, installAssetItem{id: id, label: label, desc: desc}) + s.selected[id] = true + } + + add("config", "Config settings", "Model, providers, MCP servers, and other opencode.json settings") + + if oc.AgentsMD != "" { + add("agents_md", "AGENTS.md", "System prompt injected into every session") + } + for _, sk := range oc.Skills { + add("skill:"+sk.Name, fmt.Sprintf("Skill: %s", sk.Name), "skills/"+sk.Name+"/SKILL.md") + } + for _, c := range oc.CustomCommands { + add("command:"+c.Name, fmt.Sprintf("Command: %s", c.Name), "commands/"+c.Name+".md") + } + for _, a := range oc.Agents { + add("agent:"+a.Name, fmt.Sprintf("Agent: %s", a.Name), "agents/"+a.Name+".md") + } + if oc.TUI != nil { + add("tui", "TUI config", "Theme, keybinds and display settings (tui.json)") + } + for _, f := range oc.ThemeFiles { + add("theme:"+f.Path, fmt.Sprintf("Theme: %s", f.Path), "themes/"+f.Path) + } + for _, f := range oc.PluginFiles { + add("plugin:"+f.Path, fmt.Sprintf("Plugin: %s", f.Path), "plugins/"+f.Path) + } + for _, f := range oc.ToolFiles { + add("tool:"+f.Path, fmt.Sprintf("Tool: %s", f.Path), "tools/"+f.Path) + } +} + +// FilteredRecipe returns a shallow copy of the recipe with only the selected assets. +func (s *InstallAssetsStep) FilteredRecipe() *recipe.Recipe { + src := s.r + oc := src.Tools.OpenCode + if oc == nil { + return src + } + + filtered := *oc // shallow copy of OpenCodeConfig + + // Strip config settings if deselected. + if !s.selected["config"] { + filtered.Providers = nil + filtered.Model = "" + filtered.SmallModel = "" + filtered.DefaultAgent = "" + filtered.DisabledProviders = nil + filtered.EnabledProviders = nil + filtered.Plugin = nil + filtered.Snapshot = nil + filtered.Instructions = nil + filtered.Compaction = nil + filtered.AgentConfigs = nil + filtered.MCP = nil + filtered.Permission = nil + filtered.Tools = nil + filtered.Experimental = nil + filtered.Formatter = nil + filtered.LSP = nil + filtered.InlineCommands = nil + } + + if !s.selected["agents_md"] { + filtered.AgentsMD = "" + } + + var skills []recipe.SkillEntry + for _, sk := range oc.Skills { + if s.selected["skill:"+sk.Name] { + skills = append(skills, sk) + } + } + filtered.Skills = skills + + var commands []recipe.CommandEntry + for _, c := range oc.CustomCommands { + if s.selected["command:"+c.Name] { + commands = append(commands, c) + } + } + filtered.CustomCommands = commands + + var agents []recipe.AgentEntry + for _, a := range oc.Agents { + if s.selected["agent:"+a.Name] { + agents = append(agents, a) + } + } + filtered.Agents = agents + + if !s.selected["tui"] { + filtered.TUI = nil + } + + var themes []recipe.FileEntry + for _, f := range oc.ThemeFiles { + if s.selected["theme:"+f.Path] { + themes = append(themes, f) + } + } + filtered.ThemeFiles = themes + + var plugins []recipe.FileEntry + for _, f := range oc.PluginFiles { + if s.selected["plugin:"+f.Path] { + plugins = append(plugins, f) + } + } + filtered.PluginFiles = plugins + + var toolFiles []recipe.FileEntry + for _, f := range oc.ToolFiles { + if s.selected["tool:"+f.Path] { + toolFiles = append(toolFiles, f) + } + } + filtered.ToolFiles = toolFiles + + // Keep only referenced files that are referenced by selected markdown assets. + hasAnyMD := !s.selected["agents_md"] && filtered.AgentsMD == "" // already cleared above + _ = hasAnyMD + // Simplest safe approach: include referenced files only when at least one + // markdown asset is selected. + anyMD := filtered.AgentsMD != "" || len(filtered.Skills) > 0 || + len(filtered.CustomCommands) > 0 || len(filtered.Agents) > 0 + if !anyMD { + filtered.ReferencedFiles = nil + } + + r := *src + r.Tools.OpenCode = &filtered + return &r +} + +func (s *InstallAssetsStep) Init() tea.Cmd { return nil } + +func (s *InstallAssetsStep) 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.cursor > 0 { + s.cursor-- + if s.cursor < s.offset { + s.offset = s.cursor + } + } + case "down", "j": + if s.cursor < len(s.items)-1 { + s.cursor++ + if s.cursor >= s.offset+assetsPageSize { + s.offset = s.cursor - assetsPageSize + 1 + } + } + case " ": + id := s.items[s.cursor].id + s.selected[id] = !s.selected[id] + case "a": + // Toggle all on/off. + allOn := true + for _, item := range s.items { + if !s.selected[item.id] { + allOn = false + break + } + } + for _, item := range s.items { + s.selected[item.id] = !allOn + } + case "enter": + return s, func() tea.Msg { return NextStepMsg{} } + } + } + return s, nil +} + +func (s *InstallAssetsStep) View() string { + var b strings.Builder + + if len(s.items) == 0 { + b.WriteString("No selectable assets in this recipe.\n") + b.WriteString(Styles.Help.Render("Press enter to continue")) + b.WriteString("\n") + return b.String() + } + + b.WriteString("Choose which assets to install from this recipe.\n\n") + + total := len(s.items) + end := s.offset + assetsPageSize + if end > total { + end = total + } + + if s.offset > 0 { + b.WriteString(Styles.Desc.Render(fmt.Sprintf(" ↑ %d more above\n", s.offset))) + } + + for i := s.offset; i < end; i++ { + item := s.items[i] + cursor := " " + if s.cursor == i { + cursor = Styles.Cursor.Render("► ") + } + + checkbox := "[ ]" + if s.selected[item.id] { + checkbox = Styles.Selected.Render("[✓]") + } + + firstLine := cursor + checkbox + " " + item.label + if s.cursor == i { + b.WriteString(Styles.Selected.Render(firstLine)) + } else { + b.WriteString(firstLine) + } + b.WriteString("\n") + b.WriteString(" " + Styles.Desc.Render(item.desc) + "\n") + } + + if end < total { + b.WriteString(Styles.Desc.Render(fmt.Sprintf(" ↓ %d more below\n", total-end))) + } + + return b.String() +} + +func (s *InstallAssetsStep) Name() string { return "Select Assets" } + +func (s *InstallAssetsStep) Info() StepInfo { + return StepInfo{ + Name: "Select Assets", + KeyBindings: []KeyBinding{ + BindingsNavigate, + BindingsSelect, + {Key: "a", Text: "toggle all"}, + BindingsConfirm, + BindingsBack, + BindingsQuit, + }, + } +} diff --git a/internal/tui/steps/install_conflicts.go b/internal/tui/steps/install_conflicts.go new file mode 100644 index 0000000..f9c83b7 --- /dev/null +++ b/internal/tui/steps/install_conflicts.go @@ -0,0 +1,133 @@ +package steps + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/castai/kimchi/internal/recipe" +) + +const conflictsPageSize = 12 + +// InstallConflictsStep lets the user decide per-file whether to overwrite or skip +// files that already exist on disk. +type InstallConflictsStep struct { + conflicts []recipe.Conflict + overwrite map[string]bool // path → overwrite? + cursor int + offset int // first visible item index +} + +func NewInstallConflictsStep(conflicts []recipe.Conflict) *InstallConflictsStep { + overwrite := make(map[string]bool, len(conflicts)) + for _, c := range conflicts { + overwrite[c.Path] = true // default: overwrite + } + return &InstallConflictsStep{ + conflicts: conflicts, + overwrite: overwrite, + } +} + +// Decisions returns the AssetDecisions map for use by the installer. +func (s *InstallConflictsStep) Decisions() recipe.AssetDecisions { + d := make(recipe.AssetDecisions, len(s.overwrite)) + for k, v := range s.overwrite { + d[k] = v + } + return d +} + +func (s *InstallConflictsStep) Init() tea.Cmd { return nil } + +func (s *InstallConflictsStep) 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.cursor > 0 { + s.cursor-- + if s.cursor < s.offset { + s.offset = s.cursor + } + } + case "down", "j": + if s.cursor < len(s.conflicts)-1 { + s.cursor++ + if s.cursor >= s.offset+conflictsPageSize { + s.offset = s.cursor - conflictsPageSize + 1 + } + } + case " ": + path := s.conflicts[s.cursor].Path + s.overwrite[path] = !s.overwrite[path] + case "enter": + return s, func() tea.Msg { return NextStepMsg{} } + } + } + return s, nil +} + +func (s *InstallConflictsStep) View() string { + var b strings.Builder + + total := len(s.conflicts) + b.WriteString("The following files already exist. Choose what to do with each one.\n\n") + + end := s.offset + conflictsPageSize + if end > total { + end = total + } + + if s.offset > 0 { + b.WriteString(Styles.Desc.Render(fmt.Sprintf(" ↑ %d more above\n", s.offset))) + } + + for i := s.offset; i < end; i++ { + c := s.conflicts[i] + cursor := " " + if s.cursor == i { + cursor = Styles.Cursor.Render("► ") + } + + checkbox := "[ ] Skip " + if s.overwrite[c.Path] { + checkbox = Styles.Selected.Render("[✓] Overwrite") + } + + line := cursor + checkbox + " " + Styles.Desc.Render(c.Path) + if s.cursor == i { + b.WriteString(Styles.Selected.Render(line)) + } else { + b.WriteString(line) + } + b.WriteString("\n") + } + + if end < total { + b.WriteString(Styles.Desc.Render(fmt.Sprintf(" ↓ %d more below\n", total-end))) + } + + return b.String() +} + +func (s *InstallConflictsStep) Name() string { return "File Conflicts" } + +func (s *InstallConflictsStep) Info() StepInfo { + return StepInfo{ + Name: "File Conflicts", + KeyBindings: []KeyBinding{ + BindingsNavigate, + BindingsSelect, + BindingsConfirm, + BindingsBack, + BindingsQuit, + }, + } +} diff --git a/internal/tui/steps/install_preview.go b/internal/tui/steps/install_preview.go new file mode 100644 index 0000000..0156cea --- /dev/null +++ b/internal/tui/steps/install_preview.go @@ -0,0 +1,90 @@ +package steps + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/castai/kimchi/internal/recipe" +) + +// InstallPreviewStep shows a summary of the parsed recipe and asks the user to confirm. +type InstallPreviewStep struct { + r *recipe.Recipe +} + +func NewInstallPreviewStep(r *recipe.Recipe) *InstallPreviewStep { + return &InstallPreviewStep{r: r} +} + +func (s *InstallPreviewStep) Init() tea.Cmd { return nil } + +func (s *InstallPreviewStep) 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{} } + } + } + return s, nil +} + +func (s *InstallPreviewStep) View() string { + var b strings.Builder + r := s.r + oc := r.Tools.OpenCode + + b.WriteString("Review the recipe before installing.\n\n") + + b.WriteString(fmt.Sprintf(" %s %s\n", Styles.Desc.Render("Name: "), r.Name)) + if r.Author != "" { + b.WriteString(fmt.Sprintf(" %s %s\n", Styles.Desc.Render("Author: "), r.Author)) + } + if r.Description != "" { + b.WriteString(fmt.Sprintf(" %s %s\n", Styles.Desc.Render("Desc: "), r.Description)) + } + b.WriteString(fmt.Sprintf(" %s %s\n", Styles.Desc.Render("Tool: "), "OpenCode")) + b.WriteString(fmt.Sprintf(" %s %s\n", Styles.Desc.Render("Use case:"), r.UseCase)) + b.WriteString(fmt.Sprintf(" %s %s\n", Styles.Desc.Render("Model: "), r.Model)) + + if oc != nil && hasAssets(oc) { + b.WriteString("\n " + Styles.Desc.Render("Assets included:") + "\n") + if oc.AgentsMD != "" { + b.WriteString(Styles.Success.Render(" ✓ AGENTS.md") + "\n") + } + if len(oc.Skills) > 0 { + b.WriteString(Styles.Success.Render(fmt.Sprintf(" ✓ %d skill(s)", len(oc.Skills))) + "\n") + } + if len(oc.CustomCommands) > 0 { + b.WriteString(Styles.Success.Render(fmt.Sprintf(" ✓ %d custom command(s)", len(oc.CustomCommands))) + "\n") + } + if len(oc.Agents) > 0 { + b.WriteString(Styles.Success.Render(fmt.Sprintf(" ✓ %d custom agent(s)", len(oc.Agents))) + "\n") + } + } + + b.WriteString("\n") + b.WriteString(Styles.Help.Render("Press enter to continue, esc to go back")) + b.WriteString("\n") + + return b.String() +} + +func hasAssets(oc *recipe.OpenCodeConfig) bool { + return oc.AgentsMD != "" || len(oc.Skills) > 0 || len(oc.CustomCommands) > 0 || len(oc.Agents) > 0 +} + +func (s *InstallPreviewStep) Name() string { return "Preview" } + +func (s *InstallPreviewStep) Info() StepInfo { + return StepInfo{ + Name: "Recipe Preview", + KeyBindings: []KeyBinding{BindingsConfirm, BindingsBack, BindingsQuit}, + } +} diff --git a/internal/tui/steps/install_progress.go b/internal/tui/steps/install_progress.go new file mode 100644 index 0000000..267c116 --- /dev/null +++ b/internal/tui/steps/install_progress.go @@ -0,0 +1,195 @@ +package steps + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +type installItemStatus int + +const ( + installItemPending installItemStatus = iota + installItemWriting + installItemDone + installItemFailed +) + +type installProgressItem struct { + label string + status installItemStatus +} + +type installStartMsg struct{} +type installWriteCompleteMsg struct{ err error } + +// InstallProgressStep performs the actual install (via writeFn) and shows the result. +type InstallProgressStep struct { + items []installProgressItem + writeFn func() error + state installProgressState + err error + spin spinner.Model + startOnce sync.Once +} + +type installProgressState int + +const ( + installProgressWaiting installProgressState = iota + installProgressRunning + installProgressDone + installProgressError +) + +// NewInstallProgressStep creates the final install step. +// itemLabels is a list of human-readable descriptions of what will be written, +// shown as a checklist. writeFn performs the actual work. +func NewInstallProgressStep(itemLabels []string, writeFn func() error) *InstallProgressStep { + items := make([]installProgressItem, len(itemLabels)) + for i, l := range itemLabels { + items[i] = installProgressItem{label: l, status: installItemPending} + } + sp := spinner.New() + sp.Spinner = spinner.Dot + return &InstallProgressStep{ + items: items, + writeFn: writeFn, + spin: sp, + } +} + +// SetWriteFn updates the write function after the step is created. +// Called by the wizard once all decisions have been collected. +func (s *InstallProgressStep) SetWriteFn(fn func() error) { + s.writeFn = fn +} + +// SetItems replaces the checklist labels. Called when the asset selection +// changes so the progress step reflects only the chosen assets. +func (s *InstallProgressStep) SetItems(labels []string) { + items := make([]installProgressItem, len(labels)) + for i, l := range labels { + items[i] = installProgressItem{label: l, status: installItemPending} + } + s.items = items +} + +func (s *InstallProgressStep) Init() tea.Cmd { + return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg { + return installStartMsg{} + }) +} + +func (s *InstallProgressStep) Update(msg tea.Msg) (Step, tea.Cmd) { + switch msg := msg.(type) { + case installStartMsg: + var cmd tea.Cmd + s.startOnce.Do(func() { + s.state = installProgressRunning + for i := range s.items { + s.items[i].status = installItemWriting + } + cmd = tea.Batch(s.spin.Tick, s.doInstall()) + }) + return s, cmd + + case installWriteCompleteMsg: + if msg.err != nil { + s.state = installProgressError + s.err = msg.err + for i := range s.items { + if s.items[i].status == installItemWriting { + s.items[i].status = installItemFailed + } + } + } else { + s.state = installProgressDone + for i := range s.items { + s.items[i].status = installItemDone + } + } + return s, nil + + case spinner.TickMsg: + if s.state == installProgressRunning { + var cmd tea.Cmd + s.spin, cmd = s.spin.Update(msg) + return s, cmd + } + + case tea.KeyMsg: + if s.state == installProgressDone || s.state == installProgressError { + switch msg.String() { + case "enter", "ctrl+c", "q": + return s, func() tea.Msg { return NextStepMsg{} } + } + } + } + return s, nil +} + +func (s *InstallProgressStep) doInstall() tea.Cmd { + return func() tea.Msg { + return installWriteCompleteMsg{err: s.writeFn()} + } +} + +func (s *InstallProgressStep) View() string { + var b strings.Builder + + spinChars := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + spinIdx := 0 + + for _, item := range s.items { + var icon, label string + switch item.status { + case installItemDone: + icon = Styles.Success.Render("✓") + label = Styles.Success.Render(" " + item.label) + case installItemFailed: + icon = Styles.Error.Render("✗") + label = Styles.Error.Render(" " + item.label) + case installItemWriting: + spin := spinChars[spinIdx%len(spinChars)] + spinIdx++ + icon = Styles.Spinner.Render(spin) + label = Styles.Spinner.Render(" " + item.label) + default: + icon = "○" + label = " " + item.label + } + b.WriteString(fmt.Sprintf(" %s%s\n", icon, label)) + } + + if s.state == installProgressDone { + b.WriteString("\n") + b.WriteString(Styles.Success.Render("✓ Recipe installed successfully!")) + b.WriteString("\n\n") + b.WriteString(Styles.Help.Render("Press enter to exit")) + } else if s.state == installProgressError { + b.WriteString("\n") + b.WriteString(Styles.Error.Render(fmt.Sprintf("✗ Install failed: %v", s.err))) + b.WriteString("\n\n") + b.WriteString(Styles.Help.Render("Press enter to exit")) + } + + return b.String() +} + +func (s *InstallProgressStep) Name() string { return "Installing" } + +func (s *InstallProgressStep) Info() StepInfo { + bindings := []KeyBinding{} + if s.state == installProgressDone || s.state == installProgressError { + bindings = []KeyBinding{BindingsConfirm} + } + return StepInfo{ + Name: "Installing", + KeyBindings: bindings, + } +} diff --git a/internal/tui/steps/install_secrets.go b/internal/tui/steps/install_secrets.go new file mode 100644 index 0000000..54e2f19 --- /dev/null +++ b/internal/tui/steps/install_secrets.go @@ -0,0 +1,148 @@ +package steps + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +// InstallSecretsStep prompts the user to supply real values for every +// kimchi:secret: placeholder that is not auto-filled by the stored Kimchi API +// key (e.g. third-party provider keys, MCP tokens). +type InstallSecretsStep struct { + placeholders []string // ordered list of placeholder strings + inputs []textinput.Model + focused int + err string +} + +func NewInstallSecretsStep(placeholders []string) *InstallSecretsStep { + inputs := make([]textinput.Model, len(placeholders)) + for i, p := range placeholders { + ti := textinput.New() + ti.Placeholder = "value" + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '●' + ti.Width = 50 + ti.Prompt = fmt.Sprintf(" %s: ", Styles.Desc.Render(placeholderLabel(p))) + inputs[i] = ti + } + if len(inputs) > 0 { + inputs[0].Focus() + } + return &InstallSecretsStep{ + placeholders: placeholders, + inputs: inputs, + } +} + +// SecretValues returns a map from placeholder string → user-supplied value. +func (s *InstallSecretsStep) SecretValues() map[string]string { + out := make(map[string]string, len(s.placeholders)) + for i, p := range s.placeholders { + out[p] = strings.TrimSpace(s.inputs[i].Value()) + } + return out +} + +func (s *InstallSecretsStep) Init() tea.Cmd { + return textinput.Blink +} + +func (s *InstallSecretsStep) 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 { + s.inputs[s.focused].Blur() + s.focused++ + s.inputs[s.focused].Focus() + return s, textinput.Blink + } + // Validate all fields are non-empty. + for i, inp := range s.inputs { + if strings.TrimSpace(inp.Value()) == "" { + s.err = fmt.Sprintf("%s is required", placeholderLabel(s.placeholders[i])) + s.inputs[s.focused].Blur() + s.focused = i + 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 *InstallSecretsStep) View() string { + var b strings.Builder + + b.WriteString("The recipe contains secrets from third-party providers.\n") + b.WriteString("Enter the real values — they will be written to your local config only.\n\n") + + for i, p := range s.placeholders { + cursor := " " + if s.focused == i { + cursor = Styles.Cursor.Render("► ") + } + b.WriteString(cursor + Styles.Desc.Render(placeholderLabel(p)+":") + "\n") + b.WriteString(" " + s.inputs[i].View() + "\n\n") + } + + if s.err != "" { + b.WriteString(Styles.Error.Render("✗ " + s.err) + "\n") + } + + return b.String() +} + +func (s *InstallSecretsStep) Name() string { return "Secrets" } + +func (s *InstallSecretsStep) Info() StepInfo { + return StepInfo{ + Name: "Enter Secrets", + KeyBindings: []KeyBinding{ + {Key: "tab", Text: "next field"}, + BindingsConfirm, + BindingsBack, + BindingsQuit, + }, + } +} + +// placeholderLabel converts e.g. "kimchi:secret:OPENAI_APIKEY" → "OPENAI_APIKEY". +func placeholderLabel(placeholder string) string { + const prefix = "kimchi:secret:" + if after, ok := strings.CutPrefix(placeholder, prefix); ok { + return after + } + return placeholder +} diff --git a/internal/tui/steps/install_source.go b/internal/tui/steps/install_source.go new file mode 100644 index 0000000..7a1f888 --- /dev/null +++ b/internal/tui/steps/install_source.go @@ -0,0 +1,166 @@ +package steps + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/castai/kimchi/internal/recipe" +) + +type installSourceState int + +const ( + installSourceIdle installSourceState = iota + installSourceParsing + installSourceValid + installSourceInvalid +) + +type parseCompleteMsg struct { + r *recipe.Recipe + err error +} + +type installSourceAdvanceMsg struct{} + +// InstallSourceStep collects the recipe source and resolves it asynchronously. +// Source may be a local file path, a recipe name, "cookbook/name", or "name@version". +type InstallSourceStep struct { + input textinput.Model + spin spinner.Model + state installSourceState + parsed *recipe.Recipe + errMsg string + autoStart bool +} + +func NewInstallSourceStep(prefillSource string) *InstallSourceStep { + ti := textinput.New() + ti.Placeholder = "path/to/recipe.yaml or name or name@version" + ti.Width = 60 + if prefillSource != "" { + ti.SetValue(prefillSource) + } + ti.Focus() + + sp := spinner.New() + sp.Spinner = spinner.Dot + + return &InstallSourceStep{ + input: ti, + spin: sp, + state: installSourceIdle, + autoStart: prefillSource != "", + } +} + +func (s *InstallSourceStep) ParsedRecipe() *recipe.Recipe { return s.parsed } + +func (s *InstallSourceStep) Init() tea.Cmd { + if s.autoStart { + return tea.Batch(s.spin.Tick, s.resolve(s.input.Value())) + } + return textinput.Blink +} + +func (s *InstallSourceStep) 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 != installSourceParsing { + return s, func() tea.Msg { return PrevStepMsg{} } + } + case "enter": + if s.state == installSourceIdle || s.state == installSourceInvalid { + src := strings.TrimSpace(s.input.Value()) + if src == "" { + s.errMsg = "Recipe source is required" + s.state = installSourceInvalid + return s, nil + } + s.state = installSourceParsing + s.errMsg = "" + return s, tea.Batch(s.spin.Tick, s.resolve(src)) + } + if s.state == installSourceValid { + return s, func() tea.Msg { return NextStepMsg{} } + } + } + + case parseCompleteMsg: + if msg.err != nil { + s.state = installSourceInvalid + s.errMsg = msg.err.Error() + return s, nil + } + s.parsed = msg.r + s.state = installSourceValid + return s, tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { + return installSourceAdvanceMsg{} + }) + + case installSourceAdvanceMsg: + return s, func() tea.Msg { return NextStepMsg{} } + + case spinner.TickMsg: + if s.state == installSourceParsing { + var cmd tea.Cmd + s.spin, cmd = s.spin.Update(msg) + return s, cmd + } + } + + if s.state == installSourceIdle || s.state == installSourceInvalid { + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + return s, cmd + } + return s, nil +} + +func (s *InstallSourceStep) resolve(source string) tea.Cmd { + return func() tea.Msg { + r, err := recipe.ResolveSource(source) + return parseCompleteMsg{r: r, err: err} + } +} + +func (s *InstallSourceStep) View() string { + var b strings.Builder + + b.WriteString("Enter the recipe to install.\n") + b.WriteString(Styles.Desc.Render("Supported: file path · name · cookbook/name · name@version") + "\n\n") + b.WriteString(Styles.Desc.Render("Source:")) + b.WriteString("\n") + b.WriteString(s.input.View()) + b.WriteString("\n\n") + + switch s.state { + case installSourceParsing: + b.WriteString(Styles.Spinner.Render(fmt.Sprintf("%s Resolving recipe…", s.spin.View()))) + case installSourceValid: + b.WriteString(Styles.Success.Render(fmt.Sprintf("✓ Recipe \"%s\" (%s) loaded", s.parsed.Name, s.parsed.Version))) + case installSourceInvalid: + b.WriteString(Styles.Error.Render(fmt.Sprintf("✗ %s", s.errMsg))) + } + b.WriteString("\n") + + return b.String() +} + +func (s *InstallSourceStep) Name() string { return "Recipe Source" } + +func (s *InstallSourceStep) Info() StepInfo { + return StepInfo{ + Name: "Recipe Source", + KeyBindings: []KeyBinding{BindingsConfirm, BindingsBack, BindingsQuit}, + } +} diff --git a/internal/tui/steps/restore_confirm.go b/internal/tui/steps/restore_confirm.go new file mode 100644 index 0000000..6696bcd --- /dev/null +++ b/internal/tui/steps/restore_confirm.go @@ -0,0 +1,144 @@ +package steps + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/castai/kimchi/internal/recipe" +) + +type restoreCompleteMsg struct{ err error } + +// RestoreConfirmStep shows the selected slot details, asks for confirmation, +// then performs the restore. +type RestoreConfirmStep struct { + slot *recipe.BackupSlot + offset int + done bool + err error +} + +func NewRestoreConfirmStep(slot *recipe.BackupSlot) *RestoreConfirmStep { + return &RestoreConfirmStep{slot: slot} +} + +func (s *RestoreConfirmStep) Init() tea.Cmd { return nil } + +func (s *RestoreConfirmStep) Update(msg tea.Msg) (Step, tea.Cmd) { + switch msg := msg.(type) { + case restoreCompleteMsg: + s.done = true + s.err = msg.err + return s, nil + + case tea.KeyMsg: + if s.done { + switch msg.String() { + case "enter", "q", "ctrl+c": + return s, func() tea.Msg { return NextStepMsg{} } + } + return s, nil + } + switch msg.String() { + case "ctrl+c", "q": + return s, func() tea.Msg { return AbortMsg{} } + case "esc", "n": + return s, func() tea.Msg { return PrevStepMsg{} } + case "up", "k": + if s.offset > 0 { + s.offset-- + } + case "down", "j": + if s.slot != nil && s.offset+restorePageSize < len(s.slot.Meta.Files) { + s.offset++ + } + case "y", "enter": + return s, s.doRestore() + } + } + return s, nil +} + +func (s *RestoreConfirmStep) doRestore() tea.Cmd { + slot := *s.slot + return func() tea.Msg { + return restoreCompleteMsg{err: recipe.RestoreSlot(slot)} + } +} + +func (s *RestoreConfirmStep) View() string { + var b strings.Builder + if s.slot == nil { + return "No backup selected.\n" + } + + if !s.done { + name := s.slot.RecipeName + if name == "" { + name = "baseline (pre-install state)" + } + b.WriteString(fmt.Sprintf("Tool: %s\n", s.slot.Tool)) + b.WriteString(fmt.Sprintf("Backup: %s\n", name)) + b.WriteString(fmt.Sprintf("Captured: %s\n\n", s.slot.CapturedAt.Format("2006-01-02 15:04:05"))) + + files := s.slot.Meta.Files + b.WriteString(fmt.Sprintf("Files to restore (%d):\n", len(files))) + if s.offset > 0 { + b.WriteString(Styles.Desc.Render(fmt.Sprintf(" ↑ %d more above\n", s.offset))) + } + end := s.offset + restorePageSize + if end > len(files) { + end = len(files) + } + for _, f := range files[s.offset:end] { + b.WriteString(" " + Styles.Desc.Render(f) + "\n") + } + if end < len(files) { + b.WriteString(Styles.Desc.Render(fmt.Sprintf(" ↓ %d more below\n", len(files)-end))) + } + b.WriteString("\n") + b.WriteString(Styles.Warning.Render("This will overwrite your current config. Continue?")) + b.WriteString(" ") + b.WriteString(Styles.Key.Render("y") + Styles.Help.Render("/↵ yes") + " ") + b.WriteString(Styles.Key.Render("n") + Styles.Help.Render("/esc no")) + return b.String() + } + + if s.err != nil { + b.WriteString(Styles.Error.Render(fmt.Sprintf("✗ Restore failed: %v", s.err))) + } else { + b.WriteString(Styles.Success.Render("✓ Restore complete!")) + if s.slot.RecipeName != "" { + b.WriteString("\n") + b.WriteString(Styles.Help.Render(fmt.Sprintf( + "Config restored to the backed-up state of recipe %q.", + s.slot.RecipeName, + ))) + } else { + b.WriteString("\n") + b.WriteString(Styles.Help.Render("Config restored to the pre-install baseline.")) + } + } + b.WriteString("\n\n") + b.WriteString(Styles.Help.Render("Press enter to exit")) + return b.String() +} + +func (s *RestoreConfirmStep) Name() string { return "Confirm Restore" } + +func (s *RestoreConfirmStep) Info() StepInfo { + if s.done { + return StepInfo{Name: "Confirm Restore", KeyBindings: []KeyBinding{BindingsConfirm}} + } + return StepInfo{ + Name: "Confirm Restore", + KeyBindings: []KeyBinding{ + {Key: "y/↵", Text: "confirm"}, + {Key: "n/esc", Text: "cancel"}, + BindingsNavigate, + BindingsQuit, + }, + } +} diff --git a/internal/tui/steps/restore_picker.go b/internal/tui/steps/restore_picker.go new file mode 100644 index 0000000..7f79135 --- /dev/null +++ b/internal/tui/steps/restore_picker.go @@ -0,0 +1,146 @@ +package steps + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/castai/kimchi/internal/recipe" +) + +type restoreLoadMsg struct { + slots []recipe.BackupSlot + err error +} + +const restorePageSize = 14 + +// RestorePickerStep loads backup slots and lets the user pick one. +type RestorePickerStep struct { + slots []recipe.BackupSlot + cursor int + offset int + selected *recipe.BackupSlot + loading bool + err error +} + +func NewRestorePickerStep() *RestorePickerStep { + return &RestorePickerStep{loading: true} +} + +func (s *RestorePickerStep) SelectedSlot() *recipe.BackupSlot { return s.selected } + +func (s *RestorePickerStep) Init() tea.Cmd { + return func() tea.Msg { + slots, err := recipe.ListBackupSlots() + return restoreLoadMsg{slots: slots, err: err} + } +} + +func (s *RestorePickerStep) Update(msg tea.Msg) (Step, tea.Cmd) { + switch msg := msg.(type) { + case restoreLoadMsg: + s.loading = false + s.err = msg.err + s.slots = msg.slots + 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{} } + } + if s.loading || len(s.slots) == 0 { + return s, nil + } + switch msg.String() { + case "up", "k": + if s.cursor > 0 { + s.cursor-- + if s.cursor < s.offset { + s.offset = s.cursor + } + } + case "down", "j": + if s.cursor < len(s.slots)-1 { + s.cursor++ + if s.cursor >= s.offset+restorePageSize { + s.offset = s.cursor - restorePageSize + 1 + } + } + case "enter": + slot := s.slots[s.cursor] + s.selected = &slot + return s, func() tea.Msg { return NextStepMsg{} } + } + } + return s, nil +} + +func (s *RestorePickerStep) View() string { + var b strings.Builder + if s.loading { + b.WriteString(Styles.Spinner.Render("Loading backups…") + "\n") + return b.String() + } + if s.err != nil { + b.WriteString(Styles.Error.Render(fmt.Sprintf("Error loading backups: %v", s.err)) + "\n") + return b.String() + } + if len(s.slots) == 0 { + b.WriteString("No backups found.\n\n") + b.WriteString(Styles.Help.Render("Install a recipe first to create a backup.") + "\n") + return b.String() + } + + b.WriteString("Select a backup to restore.\n\n") + + total := len(s.slots) + end := s.offset + restorePageSize + if end > total { + end = total + } + if s.offset > 0 { + b.WriteString(Styles.Desc.Render(fmt.Sprintf(" ↑ %d more above\n", s.offset))) + } + for i := s.offset; i < end; i++ { + slot := s.slots[i] + cursor := " " + if s.cursor == i { + cursor = Styles.Cursor.Render("► ") + } + toolLabel := string(slot.Tool) + " / " + var nameLabel string + if slot.RecipeName == "" { + nameLabel = Styles.Warning.Render("baseline") + } else { + nameLabel = slot.RecipeName + } + date := Styles.Desc.Render(slot.CapturedAt.Format("2006-01-02 15:04")) + fileCount := Styles.Desc.Render(fmt.Sprintf("(%d files)", len(slot.Meta.Files))) + line := cursor + toolLabel + nameLabel + " " + date + " " + fileCount + if s.cursor == i { + b.WriteString(Styles.Selected.Render(line)) + } else { + b.WriteString(line) + } + b.WriteString("\n") + } + if end < total { + b.WriteString(Styles.Desc.Render(fmt.Sprintf(" ↓ %d more below\n", total-end))) + } + return b.String() +} + +func (s *RestorePickerStep) Name() string { return "Select Backup" } + +func (s *RestorePickerStep) Info() StepInfo { + return StepInfo{ + Name: "Select Backup", + KeyBindings: []KeyBinding{BindingsNavigate, BindingsConfirm, BindingsBack, BindingsQuit}, + } +} diff --git a/internal/update/auto.go b/internal/update/auto.go new file mode 100644 index 0000000..8ee2e15 --- /dev/null +++ b/internal/update/auto.go @@ -0,0 +1,56 @@ +package update + +import ( + "context" + "fmt" + "io" + "os" +) + +// AutoSelfUpdateIfNeeded checks whether a newer kimchi release is available +// and, if so, attempts to apply it automatically. +// +// It is a no-op when: +// - KIMCHI_NO_AUTO_UPDATE is set to any non-empty value +// - The cached version check is still fresh (no extra API call is made) +// - The current binary is already up to date +// +// When a new version is found but the process lacks write permission to the +// binary, a one-line notice is printed instead of failing. +// All errors are treated as non-fatal so the caller's command still runs. +func AutoSelfUpdateIfNeeded(ctx context.Context, currentVersion string, outW, errW io.Writer) { + if os.Getenv("KIMCHI_NO_AUTO_UPDATE") != "" { + return + } + + client := NewGitHubClient() + res, err := Check(ctx, client, currentVersion) // uses 24h cached state + if err != nil { + return // network unavailable or parse error — skip silently + } + + if !res.LatestVersion.GreaterThan(&res.CurrentVersion) { + return // already up to date + } + + execPath, err := ResolveExecutablePath() + if err != nil { + return + } + + if err := CheckPermissions(execPath); err != nil { + // Can't write the binary — just notify the user. + fmt.Fprintf(outW, "==> A new release of kimchi is available: %s → %s\n", + res.CurrentVersion.String(), res.LatestVersion.String()) + fmt.Fprintln(outW, " Reinstall or run with elevated permissions to update.") + return + } + + fmt.Fprintf(outW, "==> Updating kimchi %s → %s…\n", + res.CurrentVersion.String(), res.LatestVersion.String()) + if err := Apply(ctx, client, res.LatestTag, WithExecutablePath(execPath)); err != nil { + fmt.Fprintf(errW, "warning: auto-update failed: %v\n", err) + return + } + fmt.Fprintf(outW, "==> Updated to kimchi %s\n", res.LatestVersion.String()) +}