From c5e6ca6e4dffa6dcc9017cc318ca29824624a817 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Wed, 18 Mar 2026 16:21:31 +0300 Subject: [PATCH] feat: Add kit install command for git-based extension distribution Add comprehensive extension installation system for Kit: Features: - kit install - Install extensions from git repos - kit install --local - Install to project .kit/git/ directory - kit install --select - Interactive selection for multi-extension repos - kit install --update - Update installed extensions - kit install --uninstall - Remove installed extensions - Version pinning via @ref (tags, branches, commits) - Support multiple URL formats (shorthand, git:, https, ssh) Implementation: - internal/extensions/installer.go - Git clone, checkout, validation - internal/extensions/manifest.go - Package tracking with Include filtering - internal/extensions/loader.go - Respect Include field when loading - cmd/install.go - Cobra command with interactive prompts - PromptMultiSelectConfig API - Multi-select prompts for extensions Storage: - Global: ~/.local/share/kit/git//// - Project: .kit/git//// - Manifests: packages.json tracking installed packages Examples: - Reorganized examples/extensions/ with README.md - Added status-tools/ multi-file extension example - Created comprehensive install guide in SKILL.md Testing: - Added installer_test.go with 15+ test cases - All tests pass, build clean Closes #extension-distribution --- .agents/skills/kit-extensions/SKILL.md | 153 ++++++ cmd/install.go | 323 ++++++++++++ examples/extensions/README.md | 174 +++++++ examples/extensions/status-tools/helpers.go | 43 ++ examples/extensions/status-tools/main.go | 49 ++ internal/extensions/api.go | 39 ++ internal/extensions/installer.go | 537 ++++++++++++++++++++ internal/extensions/installer_test.go | 392 ++++++++++++++ internal/extensions/loader.go | 99 ++++ internal/extensions/manifest.go | 291 +++++++++++ internal/extensions/symbols.go | 14 +- 11 files changed, 2108 insertions(+), 6 deletions(-) create mode 100644 cmd/install.go create mode 100644 examples/extensions/README.md create mode 100644 examples/extensions/status-tools/helpers.go create mode 100644 examples/extensions/status-tools/main.go create mode 100644 internal/extensions/installer.go create mode 100644 internal/extensions/installer_test.go create mode 100644 internal/extensions/manifest.go diff --git a/.agents/skills/kit-extensions/SKILL.md b/.agents/skills/kit-extensions/SKILL.md index eed6bb97..10bdf9a6 100644 --- a/.agents/skills/kit-extensions/SKILL.md +++ b/.agents/skills/kit-extensions/SKILL.md @@ -7,6 +7,8 @@ description: Guide for creating Kit extensions. Use when the user asks to build, Kit extensions are single-file Go programs interpreted at runtime by Yaegi. They hook into Kit's lifecycle, register custom tools and slash commands, display widgets, intercept editor input, render tool output, and more. +Extensions can be distributed via git repositories using `kit install`. Repos can contain single extensions or collections of multiple extensions. + ## Extension Structure Every extension must export a `package main` with an `Init(api ext.API)` function: @@ -772,6 +774,157 @@ kit extensions init --- +## Distributing Extensions via Git Repositories + +Extensions can be distributed and installed from git repositories using `kit install`. This enables sharing extensions with others and maintaining versioned collections. + +### Repository Structure + +Extensions support two organization patterns within a repo: + +**Single-file extensions** (simple, standalone): +``` +my-extension-repo/ +├── weather.go # Single extension file +├── todo.go # Another extension +└── README.md # Installation and usage docs +``` + +**Multi-file extensions** (with `main.go` entry point): +``` +my-extension-repo/ +├── git-tools/ +│ ├── main.go # Entry point +│ ├── helpers.go # Supporting code +│ └── config.go # Configuration +├── todo/ +│ ├── main.go # Entry point +│ └── storage.go # Storage logic +└── README.md +``` + +**Hybrid approach** (single files + subdirectories with main.go): +``` +my-extensions/ +├── weather.go # Single file extension +├── calculator.go # Single file extension +├── git-tools/ +│ ├── main.go # Multi-file extension +│ └── utils.go +└── README.md +``` + +### Installing from Git + +Users install extensions using the `kit install` command: + +```bash +# Install from GitHub (latest) +kit install github.com/user/repo + +# Pin to a specific version/tag +kit install github.com/user/repo@v1.0.0 +kit install github.com/user/repo@main +kit install github.com/user/repo@abc1234 + +# Install locally in project (./.kit/git/) +kit install github.com/user/repo --local + +# Interactive selection for repos with multiple extensions +kit install github.com/user/collection --select +``` + +Supported URL formats: +- `github.com/user/repo` — Shorthand (defaults to HTTPS) +- `git:github.com/user/repo` — Git prefix format +- `https://github.com/user/repo` — HTTPS URL +- `ssh://git@github.com/user/repo` — SSH URL +- `git@github.com:user/repo` — SSH shorthand + +### Managing Installed Extensions + +```bash +# Update an installed extension (skips pinned versions) +kit install github.com/user/repo --update + +# Remove an installed extension +kit install github.com/user/repo --uninstall + +# List all loaded extensions +kit extensions list + +# Validate all extensions +kit extensions validate +``` + +### Extension Selection + +For repos containing multiple extensions, users can select which to install: + +```bash +# Interactive selection +kit install github.com/user/collection --select +``` + +This prompts the user to choose which extensions to install. Selected extensions are recorded in the manifest, and only those are loaded at runtime (others in the repo are ignored). + +### README Template for Extension Repos + +Include this in your extension repo's README.md: + +```markdown +# My Kit Extensions + +A collection of extensions for [Kit](https://github.com/mark3labs/kit). + +## Installation + +### Install all extensions +\`\`\`bash +kit install github.com/username/repo +\`\`\` + +### Install specific extensions +\`\`\`bash +kit install github.com/username/repo --select +\`\`\` + +### Install locally in a project +\`\`\`bash +kit install github.com/username/repo --local +\`\`\` + +## Extensions + +### Extension Name +Description of what it does. + +- **Path**: `./ext-name/main.go` or `./ext-name.go` +- **Commands**: `/command-name` +- **Tools**: `tool_name` + +## Requirements + +- Kit vX.Y.Z+ +- Any other dependencies + +## Update + +\`\`\`bash +kit install github.com/username/repo --update +\`\`\` +``` + +### Storage Locations + +Installed extensions are stored at: + +- **Global**: `~/.local/share/kit/git////` +- **Project-local**: `./.kit/git////` +- **Manifest**: `packages.json` in respective directories + +--- + ## Complete Example: Plan Mode A full extension that restricts the agent to read-only tools, with a slash command, keyboard shortcut, option, status bar indicator, and system prompt injection: diff --git a/cmd/install.go b/cmd/install.go new file mode 100644 index 00000000..480df6a3 --- /dev/null +++ b/cmd/install.go @@ -0,0 +1,323 @@ +package cmd + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/charmbracelet/log" + "github.com/mark3labs/kit/internal/extensions" + "github.com/spf13/cobra" +) + +var ( + installLocalFlag bool + installUpdateFlag bool + installUninstallFlag bool + installSelectFlag bool + installAllFlag bool +) + +var installCmd = &cobra.Command{ + Use: "install ", + Short: "Install extensions from git repositories", + Long: `Install extensions from git repositories. + +The install command downloads and installs Kit extensions from git repositories. +Extensions are stored in the global extensions directory by default, or in the +project's .kit/git/ directory when using the --local flag. + +Supported URL formats: + - github.com/user/repo (shorthand, defaults to HTTPS) + - git:github.com/user/repo + - https://github.com/user/repo + - ssh://git@github.com/user/repo + - git@github.com:user/repo + +You can pin to a specific version, tag, or commit using @: + - github.com/user/repo@v1.0.0 + - github.com/user/repo@main + - github.com/user/repo@abc1234 + +Selection modes for repos with multiple extensions: + - Default: install all extensions + - --select: interactively choose which extensions to install + - --all: explicitly install all extensions (same as default) + +Examples: + kit install github.com/user/my-extension + kit install github.com/user/my-extension@v1.0.0 + kit install git:github.com/user/my-extension --local + kit install https://github.com/user/my-extension --select + kit install github.com/user/collection --select --local`, + Args: cobra.ExactArgs(1), + RunE: runInstall, +} + +func init() { + installCmd.Flags().BoolVarP(&installLocalFlag, "local", "l", false, "Install to project-local .kit/git/ directory") + installCmd.Flags().BoolVarP(&installUpdateFlag, "update", "u", false, "Update an already-installed package") + installCmd.Flags().BoolVar(&installUninstallFlag, "uninstall", false, "Remove an installed package") + installCmd.Flags().BoolVarP(&installSelectFlag, "select", "i", false, "Interactively select which extensions to install") + installCmd.Flags().BoolVar(&installAllFlag, "all", false, "Install all extensions (default behavior)") + + rootCmd.AddCommand(installCmd) +} + +func runInstall(cmd *cobra.Command, args []string) error { + sourceStr := args[0] + + // Check that git is available + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git is not installed or not in PATH") + } + + // Parse the source + source, err := extensions.ParseGitSource(sourceStr) + if err != nil { + return fmt.Errorf("invalid source: %w", err) + } + + // Determine scope + scope := extensions.ScopeGlobal + if installLocalFlag { + scope = extensions.ScopeProject + } + + installer := extensions.NewInstaller(".") + + // Handle uninstall + if installUninstallFlag { + return runUninstall(installer, source, scope) + } + + // Handle update + if installUpdateFlag { + return runUpdate(installer, source, scope) + } + + // Handle install + return runInstallPackage(installer, source, scope) +} + +func runInstallPackage(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error { + // Check if already installed + existingScope, installed := installer.IsInstalled(source) + if installed { + return fmt.Errorf("extension already installed (scope: %s). Use --update to update or --uninstall to remove", existingScope) + } + + // If --select flag is used, show interactive selection + if installSelectFlag { + return runInstallWithSelection(installer, source, scope) + } + + // Install all extensions + if err := installer.Install(source, scope); err != nil { + return fmt.Errorf("install failed: %w", err) + } + + // Show success message + scopeStr := "globally" + if scope == extensions.ScopeProject { + scopeStr = "locally in .kit/git/" + } + + if source.Pinned { + fmt.Printf("Installed %s at %s %s\n", source.String(), source.Ref, scopeStr) + } else { + fmt.Printf("Installed %s %s\n", source.String(), scopeStr) + } + + log.Info("extension installed", "source", source.String(), "scope", scope) + return nil +} + +func runInstallWithSelection(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error { + // Preview extensions in the repo + previews, tempDir, err := installer.PreviewExtensions(source) + if err != nil { + return fmt.Errorf("previewing extensions: %w", err) + } + defer extensions.CleanupTempDir(tempDir) + + if len(previews) == 0 { + return fmt.Errorf("no extensions found in %s", source.String()) + } + + // If only one extension, just install it + if len(previews) == 1 { + fmt.Printf("Found 1 extension in %s:\n - %s (%s)\n\n", source.String(), previews[0].Name, previews[0].Path) + return runInstallPackage(installer, source, scope) + } + + // Show found extensions + fmt.Printf("Found %d extensions in %s:\n", len(previews), source.String()) + for _, p := range previews { + fmt.Printf(" - %s (%s)\n", p.Name, p.Path) + } + fmt.Println() + + // Build options for multi-select + options := make([]string, len(previews)) + defaultSelected := make([]int, len(previews)) + for i, p := range previews { + options[i] = fmt.Sprintf("%s (%s)", p.Name, p.Path) + defaultSelected[i] = i // All selected by default + } + + // Show selection prompt (simple implementation using fmt.Scanln) + fmt.Println("Select extensions to install:") + fmt.Println(" [1] Install all extensions (default)") + fmt.Println(" [2] Install specific extensions") + fmt.Println() + fmt.Print("Enter choice (1 or 2): ") + + var choice string + if _, err := fmt.Scanln(&choice); err != nil { + choice = "1" // Default to all on error + } + + var includePaths []string + if choice == "2" { + // User wants to select specific extensions + fmt.Println("\nEnter the numbers of extensions to install (comma-separated, e.g., 1,3,5):") + for i, p := range previews { + fmt.Printf(" [%d] %s (%s)\n", i+1, p.Name, p.Path) + } + fmt.Println() + fmt.Print("Selection: ") + + var selection string + if _, err := fmt.Scanln(&selection); err != nil { + fmt.Println("No input received, cancelling install.") + return nil + } + + // Parse selection + selected := parseSelection(selection, len(previews)) + if len(selected) == 0 { + fmt.Println("No extensions selected, cancelling install.") + return nil + } + + includePaths = make([]string, len(selected)) + for i, idx := range selected { + includePaths[i] = previews[idx].Path + } + } + + // Install with includes (if empty, installs all) + if err := installer.InstallWithInclude(source, scope, includePaths); err != nil { + return fmt.Errorf("install failed: %w", err) + } + + // Show success message + scopeStr := "globally" + if scope == extensions.ScopeProject { + scopeStr = "locally in .kit/git/" + } + + if len(includePaths) > 0 { + fmt.Printf("Installed %d extension(s) from %s %s\n", len(includePaths), source.String(), scopeStr) + for _, path := range includePaths { + fmt.Printf(" - %s\n", path) + } + } else { + fmt.Printf("Installed %s %s\n", source.String(), scopeStr) + } + + log.Info("extension installed with selection", "source", source.String(), "scope", scope, "selected", len(includePaths)) + return nil +} + +func parseSelection(input string, max int) []int { + var selected []int + // Simple comma-separated parsing + parts := strings.Split(input, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + // Try to parse as number + var num int + if _, err := fmt.Sscanf(part, "%d", &num); err != nil { + continue + } + // Convert to 0-based index and validate + idx := num - 1 + if idx >= 0 && idx < max { + selected = append(selected, idx) + } + } + return selected +} + +func runUpdate(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error { + // Find the installed package + existingScope, installed := installer.IsInstalled(source) + if !installed { + // Try to find with wildcard (no version) + entry, foundScope, err := extensions.FindInManifest(source.Identity()) + if err != nil || entry == nil { + return fmt.Errorf("extension not installed: %s", source.Identity()) + } + // Parse the found entry's source + foundSource, err := extensions.ParseGitSource(entry.Source) + if err != nil { + return fmt.Errorf("failed to parse installed source: %w", err) + } + existingScope = foundScope + source = foundSource + } + + // Override scope if specified + if installLocalFlag && scope != existingScope { + return fmt.Errorf("extension installed in %s scope, cannot update with --local flag", existingScope) + } + scope = existingScope + + // Check if pinned + if source.Pinned { + fmt.Printf("Skipping %s (pinned at %s)\n", source.Identity(), source.Ref) + return nil + } + + // Update + if err := installer.Update(source, scope); err != nil { + return fmt.Errorf("update failed: %w", err) + } + + fmt.Printf("Updated %s\n", source.Identity()) + log.Info("extension updated", "source", source.Identity(), "scope", scope) + return nil +} + +func runUninstall(installer *extensions.Installer, source *extensions.GitSource, scope extensions.InstallScope) error { + // Find where it's installed (ignore scope flag for uninstall - remove from wherever it exists) + existingScope, installed := installer.IsInstalled(source) + if !installed { + // Try to find in manifests + entry, foundScope, err := extensions.FindInManifest(source.Identity()) + if err != nil || entry == nil { + return fmt.Errorf("extension not installed: %s", source.Identity()) + } + existingScope = foundScope + // Parse the found entry's source + foundSource, err := extensions.ParseGitSource(entry.Source) + if err != nil { + return fmt.Errorf("failed to parse installed source: %w", err) + } + source = foundSource + } + + // Uninstall from the scope where it's installed + if err := installer.Uninstall(source, existingScope); err != nil { + return fmt.Errorf("uninstall failed: %w", err) + } + + fmt.Printf("Uninstalled %s from %s scope\n", source.Identity(), existingScope) + log.Info("extension uninstalled", "source", source.Identity(), "scope", existingScope) + return nil +} diff --git a/examples/extensions/README.md b/examples/extensions/README.md new file mode 100644 index 00000000..45f72e4a --- /dev/null +++ b/examples/extensions/README.md @@ -0,0 +1,174 @@ +# Kit Extension Examples + +A collection of example extensions demonstrating various Kit capabilities. These can be installed individually or as a complete collection. + +## Installation + +### Install all examples +```bash +kit install github.com/mark3labs/kit/examples/extensions +``` + +### Install with interactive selection +```bash +kit install github.com/mark3labs/kit/examples/extensions --select +``` + +### Install locally in your project +```bash +kit install github.com/mark3labs/kit/examples/extensions --local +``` + +## Extension Index + +### Core Concepts + +| Extension | Description | Key API | +|-----------|-------------|---------| +| `minimal.go` | Minimal viable extension | Basic `Init()` function | +| `plan-mode.go` | Restrict agent to read-only tools | `OnBeforeAgentStart`, `SetActiveTools` | +| `tool-logger.go` | Log all tool calls to file | `OnToolCall`, `OnToolResult` | +| `notify.go` | Display notifications | `PrintInfo`, `PrintBlock` | + +### UI & Widgets + +| Extension | Description | Key API | +|-----------|-------------|---------| +| `widget-status.go` | Persistent status widget | `SetWidget`, `RemoveWidget` | +| `header-footer-demo.go` | Custom header/footer | `SetHeader`, `SetFooter` | +| `overlay-demo.go` | Modal overlay dialogs | `ShowOverlay` | +| `compact-notify.go` | Compact mode notifications | `PrintBlock` | +| `branded-output.go` | Custom styled output | `PrintBlock` with colors | + +### Input & Editor + +| Extension | Description | Key API | +|-----------|-------------|---------| +| `custom-editor-demo.go` | Custom key handling | `SetEditor`, `EditorKeyAction` | +| `pirate.go` | Transform user input | `OnInput`, `InputResult` | +| `interactive-shell.go` | Custom command input | Slash commands with prompts | +| `inline-bash.go` | Execute bash inline | Input handling, `exec` | + +### Session & Context + +| Extension | Description | Key API | +|-----------|-------------|---------| +| `context-inject.go` | Inject context into prompts | `OnContextPrepare` | +| `bookmark.go` | Bookmark messages | `AppendEntry`, `GetEntries` | +| `project-rules.go` | Project-specific rules | Session data, file reading | +| `protected-paths.go` | Block dangerous operations | `OnToolCall` with blocking | +| `permission-gate.go` | Confirm destructive actions | `OnToolCall` with confirmation | + +### Tools & Commands + +| Extension | Description | Key API | +|-----------|-------------|---------| +| `auto-commit.go` | Auto-commit changes | Custom tool, git operations | +| `summarize.go` | Summarize conversation | Custom tool with parameters | +| `confirm-destructive.go` | Confirm destructive commands | `OnToolCall` blocking | +| `lsp-diagnostics.go` | LSP integration | Complex extension, external process | + +### Subagents & Background Tasks + +| Extension | Description | Key API | +|-----------|-------------|---------| +| `kit-kit.go` | Spawn Kit as subagent | Subagent spawning | +| `subagent-test.go` | Test subagent functionality | `SpawnSubagent` | +| `subagent-widget.go` | Widget with subagent updates | Goroutines + widgets | +| `dev-reload.go` | Hot reload extensions | `ReloadExtensions` | + +### Rendering + +| Extension | Description | Key API | +|-----------|-------------|---------| +| `tool-renderer-demo.go` | Custom tool output styling | `RegisterToolRenderer` | +| `prompt-demo.go` | Interactive prompts | `PromptSelect`, `PromptConfirm` | + +## Extension Details + +### minimal.go +The bare minimum extension showing the required structure: +- Package `main` +- Import `kit/ext` +- Export `Init(api ext.API)` function + +### plan-mode.go +A complete example demonstrating: +- Slash command (`/plan`) +- Keyboard shortcut (`ctrl+alt+p`) +- Option registration +- Status bar indicators +- System prompt injection +- Tool filtering + +### widget-status.go +Shows how to create persistent UI elements: +- Create widgets with `SetWidget` +- Update content dynamically +- Remove when done +- Handle session lifecycle + +### context-inject.go +Advanced context manipulation: +- Read project files +- Inject into LLM context +- Filter messages +- Use negative indices for ephemeral content + +### lsp-diagnostics.go +Complex real-world example: +- Multi-file extension +- External process management (LSP server) +- File watching +- Diagnostics aggregation + +## Multi-File Extension Example + +The `kit-kit-agents/` directory demonstrates the multi-file pattern: + +``` +kit-kit-agents/ +├── main.go # Entry point with Init() +├── agent.go # Agent configuration +├── manager.go # Agent lifecycle management +└── README.md # Documentation +``` + +When the repo is installed, all files in subdirectories with `main.go` are loaded as separate extensions. + +## Testing & Validation + +After installing, test the extensions: + +```bash +# List all loaded extensions +kit extensions list + +# Validate all extensions +kit extensions validate + +# Run with a specific extension +kit -e ~/.local/share/kit/git/github.com/mark3labs/kit/examples/extensions/plan-mode.go +``` + +## Creating Your Own + +1. Copy `minimal.go` as a starting point +2. Modify the `Init()` function to register your handlers +3. Use the other examples for reference on specific APIs +4. Test with `kit -e your-extension.go` +5. Share by pushing to a git repository! + +## Update + +To get the latest examples: + +```bash +kit install github.com/mark3labs/kit/examples/extensions --update +``` + +## See Also + +- [Kit Extensions Guide](https://github.com/mark3labs/kit/blob/main/.agents/skills/kit-extensions/SKILL.md) +- [API Reference](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go) +- [Example Extensions Source](https://github.com/mark3labs/kit/tree/main/examples/extensions) diff --git a/examples/extensions/status-tools/helpers.go b/examples/extensions/status-tools/helpers.go new file mode 100644 index 00000000..f95dd7ca --- /dev/null +++ b/examples/extensions/status-tools/helpers.go @@ -0,0 +1,43 @@ +//go:build ignore + +package main + +import ( + "fmt" + "kit/ext" +) + +// Helper functions for the status-tools extension +// These are used by main.go but kept in a separate file +// to demonstrate the multi-file extension pattern. + +// formatMemory converts bytes to human-readable format +func formatMemory(bytes int64) string { + const ( + KB = 1024 + MB = 1024 * KB + GB = 1024 * MB + ) + + switch { + case bytes >= GB: + return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB)) + case bytes >= MB: + return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB)) + case bytes >= KB: + return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB)) + default: + return fmt.Sprintf("%d B", bytes) + } +} + +// showMemoryStatus displays memory usage (placeholder) +func showMemoryStatus(ctx ext.Context) { + // This is a placeholder that would show memory stats + // In a real extension, you'd integrate with system metrics + ctx.PrintBlock(ext.PrintBlockOpts{ + Text: "Memory status monitoring not yet implemented", + BorderColor: "#f9e2af", + Subtitle: "Memory", + }) +} diff --git a/examples/extensions/status-tools/main.go b/examples/extensions/status-tools/main.go new file mode 100644 index 00000000..c879832e --- /dev/null +++ b/examples/extensions/status-tools/main.go @@ -0,0 +1,49 @@ +//go:build ignore + +package main + +import ( + "fmt" + "time" + + "kit/ext" +) + +// Init registers the status tools extension. +// This extension provides multiple status-related utilities as a +// multi-file extension example. +func Init(api ext.API) { + // Register a status bar widget that shows time + api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) { + go func() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for range ticker.C { + ctx.SetStatus("clock", time.Now().Format("15:04:05"), 5) + } + }() + }) + + // Register a /status command + api.RegisterCommand(ext.CommandDef{ + Name: "status", + Description: "Show system status information", + Execute: func(args string, ctx ext.Context) (string, error) { + stats := ctx.GetContextStats() + info := fmt.Sprintf( + "Model: %s\nTokens: %d/%d (%.1f%%)\nMessages: %d", + ctx.Model, + stats.EstimatedTokens, + stats.ContextLimit, + stats.UsagePercent*100, + stats.MessageCount, + ) + ctx.PrintBlock(ext.PrintBlockOpts{ + Text: info, + BorderColor: "#89b4fa", + Subtitle: "System Status", + }) + return "", nil + }, + }) +} diff --git a/internal/extensions/api.go b/internal/extensions/api.go index b1eba553..bef0d52a 100644 --- a/internal/extensions/api.go +++ b/internal/extensions/api.go @@ -174,6 +174,22 @@ type Context struct { // } PromptInput func(PromptInputConfig) PromptInputResult + // PromptMultiSelect shows a multi-selection list to the user, allowing + // them to toggle options with spacebar and confirm with enter. In + // non-interactive mode, returns all options as selected. + // + // Example: + // + // result := ctx.PromptMultiSelect(ext.PromptMultiSelectConfig{ + // Message: "Select extensions to install:", + // Options: []string{"git", "todo", "weather"}, + // DefaultSelected: []int{0, 1, 2}, // All selected by default + // }) + // if !result.Cancelled { + // fmt.Println("Selected:", result.Values) + // } + PromptMultiSelect func(PromptMultiSelectConfig) PromptMultiSelectResult + // ShowOverlay displays a modal overlay dialog that blocks until the // user dismisses it or selects an action. The overlay renders as a // centered (or anchored) bordered box over the TUI. Returns a @@ -1000,6 +1016,29 @@ type PromptInputResult struct { Cancelled bool } +// PromptMultiSelectConfig configures a multi-selection prompt that allows +// the user to toggle multiple options and confirm their selection. +type PromptMultiSelectConfig struct { + // Message is the question or instruction displayed to the user. + Message string + // Options is the list of choices the user can select from. + Options []string + // DefaultSelected contains indices of options that should be + // pre-selected when the prompt appears. If nil, all options are selected. + DefaultSelected []int +} + +// PromptMultiSelectResult is the response from a multi-selection prompt. +type PromptMultiSelectResult struct { + // Values contains the text of selected options. + Values []string + // Indices contains the zero-based indices of selected options. + Indices []int + // Cancelled is true if the user dismissed the prompt (ESC) or + // the prompt was unavailable (non-interactive mode). + Cancelled bool +} + // --------------------------------------------------------------------------- // Header/Footer types (exposed to Yaegi — concrete structs) // --------------------------------------------------------------------------- diff --git a/internal/extensions/installer.go b/internal/extensions/installer.go new file mode 100644 index 00000000..1a02855e --- /dev/null +++ b/internal/extensions/installer.go @@ -0,0 +1,537 @@ +package extensions + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// InstallScope defines where a package should be installed. +type InstallScope string + +const ( + ScopeGlobal InstallScope = "global" + ScopeProject InstallScope = "project" +) + +// GitSource represents a parsed git repository URL. +type GitSource struct { + Repo string // Clone URL (e.g., https://github.com/user/repo.git) + Host string // Host (e.g., github.com) + Path string // Path (e.g., user/repo) + Ref string // Optional ref (tag, branch, commit) + Pinned bool // Whether a specific ref is pinned +} + +// String returns the canonical string representation. +func (g GitSource) String() string { + if g.Pinned { + return fmt.Sprintf("git:%s/%s@%s", g.Host, g.Path, g.Ref) + } + return fmt.Sprintf("git:%s/%s", g.Host, g.Path) +} + +// Identity returns a normalized identity string for deduplication. +func (g GitSource) Identity() string { + return fmt.Sprintf("%s/%s", g.Host, g.Path) +} + +// ParseGitSource parses a git source string into a GitSource. +// Supports formats like: +// - git:github.com/user/repo +// - git:github.com/user/repo@v1.0.0 +// - https://github.com/user/repo +// - https://github.com/user/repo@v1.0.0 +// - ssh://git@github.com/user/repo +// - git@github.com:user/repo +// - github.com/user/repo (shorthand, defaults to https) +func ParseGitSource(source string) (*GitSource, error) { + source = strings.TrimSpace(source) + + // Check for @ref suffix + ref := "" + pinned := false + if atIdx := strings.LastIndex(source, "@"); atIdx > 0 { + // Make sure it's not part of the protocol (e.g., @ in ssh://git@) + after := source[atIdx+1:] + if !strings.Contains(after, "/") && !strings.Contains(after, ":") { + ref = after + pinned = true + source = source[:atIdx] + } + } + + // Handle git: prefix + source, _ = strings.CutPrefix(source, "git:") + + var repo, host, path string + + // Handle explicit URLs + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + u, err := url.Parse(source) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + host = u.Host + path = strings.TrimPrefix(u.Path, "/") + path, _ = strings.CutSuffix(path, ".git") + repo = source + if !strings.HasSuffix(repo, ".git") { + repo += ".git" + } + } else if strings.HasPrefix(source, "ssh://") { + u, err := url.Parse(source) + if err != nil { + return nil, fmt.Errorf("invalid SSH URL: %w", err) + } + host = u.Host + path = strings.TrimPrefix(u.Path, "/") + path, _ = strings.CutSuffix(path, ".git") + repo = source + } else if strings.HasPrefix(source, "git@") { + // SSH shorthand: git@github.com:user/repo + parts := strings.SplitN(source, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid SSH shorthand format") + } + host = strings.TrimPrefix(parts[0], "git@") + path = parts[1] + path, _ = strings.CutSuffix(path, ".git") + repo = source + } else if strings.HasPrefix(source, "github.com/") || strings.HasPrefix(source, "gitlab.com/") || strings.HasPrefix(source, "bitbucket.org/") { + // Shorthand for known hosts: host/path + parts := strings.SplitN(source, "/", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid shorthand format, expected host/path") + } + host = parts[0] + path = parts[1] + repo = fmt.Sprintf("https://%s/%s.git", host, path) + } else if strings.HasPrefix(source, ".") || strings.HasPrefix(source, "/") || strings.HasPrefix(source, "~") { + // Local paths are not supported + return nil, fmt.Errorf("local paths not supported, use explicit extension path with -e flag") + } else { + // Generic shorthand: host/user/repo (3+ path segments) + parts := strings.Split(source, "/") + if len(parts) >= 3 { + host = parts[0] + path = strings.Join(parts[1:], "/") + repo = fmt.Sprintf("https://%s/%s.git", host, path) + } else { + return nil, fmt.Errorf("unrecognized source format: %s", source) + } + } + + return &GitSource{ + Repo: repo, + Host: host, + Path: path, + Ref: ref, + Pinned: pinned, + }, nil +} + +// Installer handles installing, updating, and removing git-based extensions. +type Installer struct { + // Global packages root: $XDG_DATA_HOME/kit/git/ (default ~/.local/share/kit/git/) + globalGitRoot string + // Project packages root: .kit/git/ + projectGitRoot string +} + +// NewInstaller creates a new Installer. +func NewInstaller(projectDir string) *Installer { + return &Installer{ + globalGitRoot: globalGitInstallRoot(), + projectGitRoot: filepath.Join(projectDir, ".kit", "git"), + } +} + +// Install clones a git repository to the appropriate scope. +func (i *Installer) Install(source *GitSource, scope InstallScope) error { + targetDir := i.getInstallPath(source, scope) + + // Check if already installed + if _, err := os.Stat(targetDir); err == nil { + return fmt.Errorf("extension already installed at %s", targetDir) + } + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(targetDir), 0755); err != nil { + return fmt.Errorf("creating parent directory: %w", err) + } + + // Clone the repository + cmd := exec.Command("git", "clone", "--depth=1", source.Repo, targetDir) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git clone failed: %w\n%s", err, string(output)) + } + + // Checkout specific ref if pinned + if source.Pinned && source.Ref != "" { + checkoutCmd := exec.Command("git", "checkout", source.Ref) + checkoutCmd.Dir = targetDir + if output, err := checkoutCmd.CombinedOutput(); err != nil { + // Clean up on failed checkout + _ = os.RemoveAll(targetDir) + return fmt.Errorf("git checkout failed: %w\n%s", err, string(output)) + } + } + + // Validate that the package contains valid extensions + if err := i.validatePackage(targetDir); err != nil { + _ = os.RemoveAll(targetDir) + return fmt.Errorf("validation failed: %w", err) + } + + // Add to manifest + entry := ManifestEntry{ + Source: source.String(), + Repo: source.Repo, + Host: source.Host, + Path: source.Path, + Ref: source.Ref, + Pinned: source.Pinned, + Scope: scope, + Installed: time.Now(), + } + if err := i.addToManifest(entry, scope); err != nil { + // Don't fail the install, just log the error + // The package is installed, manifest update failed + return fmt.Errorf("installed but failed to update manifest: %w", err) + } + + return nil +} + +// Uninstall removes an installed package. +func (i *Installer) Uninstall(source *GitSource, scope InstallScope) error { + targetDir := i.getInstallPath(source, scope) + + if _, err := os.Stat(targetDir); err != nil { + return fmt.Errorf("extension not found at %s", targetDir) + } + + // Remove the directory + if err := os.RemoveAll(targetDir); err != nil { + return fmt.Errorf("removing extension directory: %w", err) + } + + // Remove from manifest + if err := i.removeFromManifest(source.Identity(), scope); err != nil { + return fmt.Errorf("removed but failed to update manifest: %w", err) + } + + return nil +} + +// Update fetches and resets a git package to the latest. +// For pinned packages, this does nothing. +func (i *Installer) Update(source *GitSource, scope InstallScope) error { + if source.Pinned { + return nil // Don't update pinned packages + } + + targetDir := i.getInstallPath(source, scope) + + if _, err := os.Stat(targetDir); err != nil { + return i.Install(source, scope) + } + + // Fetch latest + fetchCmd := exec.Command("git", "fetch", "--prune", "origin") + fetchCmd.Dir = targetDir + if output, err := fetchCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git fetch failed: %w\n%s", err, string(output)) + } + + // Reset to tracking branch or origin/HEAD + resetCmd := exec.Command("git", "reset", "--hard", "@{upstream}") + resetCmd.Dir = targetDir + if _, err := resetCmd.CombinedOutput(); err != nil { + // Try alternative: set HEAD and reset to origin/HEAD + _ = exec.Command("git", "remote", "set-head", "origin", "-a").Run() + resetCmd = exec.Command("git", "reset", "--hard", "origin/HEAD") + resetCmd.Dir = targetDir + if output, err := resetCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git reset failed: %w\n%s", err, string(output)) + } + } + + // Clean untracked files + cleanCmd := exec.Command("git", "clean", "-fdx") + cleanCmd.Dir = targetDir + _ = cleanCmd.Run() // Ignore errors - clean is best effort + + // Update manifest timestamp + entry := ManifestEntry{ + Source: source.String(), + Repo: source.Repo, + Host: source.Host, + Path: source.Path, + Ref: "", + Pinned: false, + Scope: scope, + Installed: time.Now(), + Updated: time.Now(), + } + _ = i.addToManifest(entry, scope) // Best effort - don't fail update if manifest fails + + return nil +} + +// getInstallPath returns the target directory for a source. +func (i *Installer) getInstallPath(source *GitSource, scope InstallScope) string { + root := i.globalGitRoot + if scope == ScopeProject { + root = i.projectGitRoot + } + return filepath.Join(root, source.Host, source.Path) +} + +// validatePackage checks that the cloned repo contains valid .go extension files. +func (i *Installer) validatePackage(dir string) error { + // Find all .go files in the directory + var goFiles []string + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && strings.HasSuffix(info.Name(), ".go") { + goFiles = append(goFiles, path) + } + return nil + }) + if err != nil { + return fmt.Errorf("walking directory: %w", err) + } + + if len(goFiles) == 0 { + return fmt.Errorf("no .go files found in package") + } + + // Try to load the first .go file to validate it's a valid extension + // We don't fail if validation fails - the extension might be fine but + // have dependencies that aren't available during install time + _, err = loadSingleExtension(goFiles[0]) + if err != nil { + // Log but don't fail - the extension might need runtime deps + // User can use `kit extensions validate` to check later + return nil + } + + return nil +} + +// addToManifest adds an entry to the manifest. +func (i *Installer) addToManifest(entry ManifestEntry, scope InstallScope) error { + manifest, err := i.loadManifest(scope) + if err != nil { + return err + } + + // Remove any existing entry with same identity + identity := entry.Host + "/" + entry.Path + filtered := make([]ManifestEntry, 0, len(manifest.Packages)) + for _, p := range manifest.Packages { + if p.Host+"/"+p.Path != identity { + filtered = append(filtered, p) + } + } + filtered = append(filtered, entry) + manifest.Packages = filtered + + return i.saveManifest(manifest, scope) +} + +// removeFromManifest removes an entry from the manifest by identity. +func (i *Installer) removeFromManifest(identity string, scope InstallScope) error { + manifest, err := i.loadManifest(scope) + if err != nil { + return err + } + + filtered := make([]ManifestEntry, 0, len(manifest.Packages)) + for _, p := range manifest.Packages { + if p.Host+"/"+p.Path != identity { + filtered = append(filtered, p) + } + } + manifest.Packages = filtered + + return i.saveManifest(manifest, scope) +} + +// loadManifest loads the manifest for the given scope. +func (i *Installer) loadManifest(scope InstallScope) (*Manifest, error) { + path := i.manifestPath(scope) + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &Manifest{Packages: []ManifestEntry{}}, nil + } + return nil, err + } + + var manifest Manifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("parsing manifest: %w", err) + } + + return &manifest, nil +} + +// saveManifest saves the manifest for the given scope. +func (i *Installer) saveManifest(manifest *Manifest, scope InstallScope) error { + path := i.manifestPath(scope) + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating manifest directory: %w", err) + } + + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return fmt.Errorf("encoding manifest: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing manifest: %w", err) + } + + return nil +} + +// manifestPath returns the path to the manifest file. +func (i *Installer) manifestPath(scope InstallScope) string { + if scope == ScopeProject { + return filepath.Join(i.projectGitRoot, "packages.json") + } + return filepath.Join(i.globalGitRoot, "packages.json") +} + +// globalGitInstallRoot returns the global git install root. +func globalGitInstallRoot() string { + base := os.Getenv("XDG_DATA_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + base = filepath.Join(home, ".local", "share") + } + return filepath.Join(base, "kit", "git") +} + +// GetInstalledPackages returns all installed packages from both scopes. +func (i *Installer) GetInstalledPackages() ([]ManifestEntry, error) { + var all []ManifestEntry + + global, err := i.loadManifest(ScopeGlobal) + if err != nil { + return nil, fmt.Errorf("loading global manifest: %w", err) + } + all = append(all, global.Packages...) + + project, err := i.loadManifest(ScopeProject) + if err != nil { + return nil, fmt.Errorf("loading project manifest: %w", err) + } + all = append(all, project.Packages...) + + return all, nil +} + +// IsInstalled checks if a package is installed in either scope. +// Returns (scope, true) if installed, ("", false) otherwise. +func (i *Installer) IsInstalled(source *GitSource) (InstallScope, bool) { + globalPath := i.getInstallPath(source, ScopeGlobal) + if _, err := os.Stat(globalPath); err == nil { + return ScopeGlobal, true + } + + projectPath := i.getInstallPath(source, ScopeProject) + if _, err := os.Stat(projectPath); err == nil { + return ScopeProject, true + } + + return "", false +} + +// PreviewExtensions clones a repo to a temporary directory and scans for extensions. +// Returns the preview list and the temp directory path (caller should clean up). +func (i *Installer) PreviewExtensions(source *GitSource) ([]ExtensionPreview, string, error) { + // Create temp directory + tempDir, err := os.MkdirTemp("", "kit-install-preview-*") + if err != nil { + return nil, "", fmt.Errorf("creating temp directory: %w", err) + } + + // Clone to temp + cloneDir := filepath.Join(tempDir, "repo") + cmd := exec.Command("git", "clone", "--depth=1", source.Repo, cloneDir) + if output, err := cmd.CombinedOutput(); err != nil { + _ = os.RemoveAll(tempDir) + return nil, "", fmt.Errorf("git clone failed: %w\n%s", err, string(output)) + } + + // Checkout specific ref if pinned + if source.Pinned && source.Ref != "" { + checkoutCmd := exec.Command("git", "checkout", source.Ref) + checkoutCmd.Dir = cloneDir + if output, err := checkoutCmd.CombinedOutput(); err != nil { + _ = os.RemoveAll(tempDir) + return nil, "", fmt.Errorf("git checkout failed: %w\n%s", err, string(output)) + } + } + + // Scan for extensions + previews, err := ScanForExtensions(cloneDir) + if err != nil { + _ = os.RemoveAll(tempDir) + return nil, "", fmt.Errorf("scanning extensions: %w", err) + } + + return previews, tempDir, nil +} + +// InstallWithInclude clones a repo and installs only the specified extensions. +// includePaths are relative paths like "./git/main.go" - if empty, installs all. +func (i *Installer) InstallWithInclude(source *GitSource, scope InstallScope, includePaths []string) error { + // First, do a regular install + if err := i.Install(source, scope); err != nil { + return err + } + + // If specific includes were requested, update the manifest + if len(includePaths) > 0 { + entry := ManifestEntry{ + Source: source.String(), + Repo: source.Repo, + Host: source.Host, + Path: source.Path, + Ref: source.Ref, + Pinned: source.Pinned, + Scope: scope, + Include: includePaths, + } + + if err := addEntryToManifest(entry, scope); err != nil { + return fmt.Errorf("updating manifest with includes: %w", err) + } + } + + return nil +} + +// CleanupTempDir removes a temporary directory used for preview. +func CleanupTempDir(tempDir string) { + if tempDir != "" { + _ = os.RemoveAll(tempDir) + } +} diff --git a/internal/extensions/installer_test.go b/internal/extensions/installer_test.go new file mode 100644 index 00000000..eae01c9c --- /dev/null +++ b/internal/extensions/installer_test.go @@ -0,0 +1,392 @@ +package extensions + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseGitSource(t *testing.T) { + tests := []struct { + name string + source string + wantRepo string + wantHost string + wantPath string + wantRef string + wantPinned bool + wantErr bool + }{ + { + name: "github shorthand", + source: "github.com/user/repo", + wantRepo: "https://github.com/user/repo.git", + wantHost: "github.com", + wantPath: "user/repo", + wantRef: "", + wantPinned: false, + }, + { + name: "github shorthand with version", + source: "github.com/user/repo@v1.0.0", + wantRepo: "https://github.com/user/repo.git", + wantHost: "github.com", + wantPath: "user/repo", + wantRef: "v1.0.0", + wantPinned: true, + }, + { + name: "git prefix shorthand", + source: "git:github.com/user/repo", + wantRepo: "https://github.com/user/repo.git", + wantHost: "github.com", + wantPath: "user/repo", + wantRef: "", + wantPinned: false, + }, + { + name: "https URL", + source: "https://github.com/user/repo", + wantRepo: "https://github.com/user/repo.git", + wantHost: "github.com", + wantPath: "user/repo", + wantRef: "", + wantPinned: false, + }, + { + name: "https URL with .git suffix", + source: "https://github.com/user/repo.git", + wantRepo: "https://github.com/user/repo.git", + wantHost: "github.com", + wantPath: "user/repo", + wantRef: "", + wantPinned: false, + }, + { + name: "ssh shorthand", + source: "git@github.com:user/repo", + wantRepo: "git@github.com:user/repo", + wantHost: "github.com", + wantPath: "user/repo", + wantRef: "", + wantPinned: false, + }, + { + name: "ssh URL", + source: "ssh://git@github.com/user/repo", + wantRepo: "ssh://git@github.com/user/repo", + wantHost: "github.com", + wantPath: "user/repo", + wantRef: "", + wantPinned: false, + }, + { + name: "gitlab shorthand", + source: "gitlab.com/user/repo", + wantRepo: "https://gitlab.com/user/repo.git", + wantHost: "gitlab.com", + wantPath: "user/repo", + wantRef: "", + wantPinned: false, + }, + { + name: "bitbucket shorthand", + source: "bitbucket.org/user/repo", + wantRepo: "https://bitbucket.org/user/repo.git", + wantHost: "bitbucket.org", + wantPath: "user/repo", + wantRef: "", + wantPinned: false, + }, + { + name: "generic host", + source: "gitea.example.com/user/repo", + wantRepo: "https://gitea.example.com/user/repo.git", + wantHost: "gitea.example.com", + wantPath: "user/repo", + wantRef: "", + wantPinned: false, + }, + { + name: "with branch ref", + source: "github.com/user/repo@main", + wantRepo: "https://github.com/user/repo.git", + wantHost: "github.com", + wantPath: "user/repo", + wantRef: "main", + wantPinned: true, + }, + { + name: "with commit ref", + source: "github.com/user/repo@abc1234", + wantRepo: "https://github.com/user/repo.git", + wantHost: "github.com", + wantPath: "user/repo", + wantRef: "abc1234", + wantPinned: true, + }, + { + name: "local path should error", + source: "./local/path", + wantErr: true, + }, + { + name: "absolute path should error", + source: "/absolute/path", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseGitSource(tt.source) + if (err != nil) != tt.wantErr { + t.Errorf("ParseGitSource() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil { + return + } + if got.Repo != tt.wantRepo { + t.Errorf("ParseGitSource() Repo = %v, want %v", got.Repo, tt.wantRepo) + } + if got.Host != tt.wantHost { + t.Errorf("ParseGitSource() Host = %v, want %v", got.Host, tt.wantHost) + } + if got.Path != tt.wantPath { + t.Errorf("ParseGitSource() Path = %v, want %v", got.Path, tt.wantPath) + } + if got.Ref != tt.wantRef { + t.Errorf("ParseGitSource() Ref = %v, want %v", got.Ref, tt.wantRef) + } + if got.Pinned != tt.wantPinned { + t.Errorf("ParseGitSource() Pinned = %v, want %v", got.Pinned, tt.wantPinned) + } + }) + } +} + +func TestGitSourceIdentity(t *testing.T) { + source := &GitSource{ + Host: "github.com", + Path: "user/repo", + } + if got := source.Identity(); got != "github.com/user/repo" { + t.Errorf("Identity() = %v, want %v", got, "github.com/user/repo") + } +} + +func TestGitSourceString(t *testing.T) { + tests := []struct { + name string + source GitSource + want string + }{ + { + name: "unpinned", + source: GitSource{ + Host: "github.com", + Path: "user/repo", + Pinned: false, + }, + want: "git:github.com/user/repo", + }, + { + name: "pinned", + source: GitSource{ + Host: "github.com", + Path: "user/repo", + Ref: "v1.0.0", + Pinned: true, + }, + want: "git:github.com/user/repo@v1.0.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.source.String(); got != tt.want { + t.Errorf("String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestInstallerGetInstallPath(t *testing.T) { + tempDir := t.TempDir() + installer := NewInstaller(tempDir) + + source := &GitSource{ + Host: "github.com", + Path: "user/repo", + } + + // Test global scope + globalPath := installer.getInstallPath(source, ScopeGlobal) + if !filepath.IsAbs(globalPath) { + t.Error("Global install path should be absolute") + } + + // Test project scope + projectPath := installer.getInstallPath(source, ScopeProject) + expectedProjectPath := filepath.Join(tempDir, ".kit", "git", "github.com", "user", "repo") + if projectPath != expectedProjectPath { + t.Errorf("Project path = %v, want %v", projectPath, expectedProjectPath) + } +} + +func TestManifestEntryIdentity(t *testing.T) { + entry := ManifestEntry{ + Host: "github.com", + Path: "user/repo", + } + if got := entry.Identity(); got != "github.com/user/repo" { + t.Errorf("Identity() = %v, want %v", got, "github.com/user/repo") + } +} + +func TestLoadAndSaveManifest(t *testing.T) { + tempDir := t.TempDir() + manifestPath := filepath.Join(tempDir, "packages.json") + + // Test loading non-existent manifest + manifest, err := loadManifestFromPath(manifestPath) + if err != nil { + t.Fatalf("loadManifestFromPath() error = %v", err) + } + if len(manifest.Packages) != 0 { + t.Errorf("Expected empty packages, got %d", len(manifest.Packages)) + } + + // Create a manifest + manifest = &Manifest{ + Packages: []ManifestEntry{ + { + Source: "git:github.com/user/repo", + Repo: "https://github.com/user/repo.git", + Host: "github.com", + Path: "user/repo", + Pinned: false, + Scope: ScopeGlobal, + }, + }, + } + + // Save it + err = saveManifestToPath(manifest, manifestPath) + if err != nil { + t.Fatalf("saveManifestToPath() error = %v", err) + } + + // Load it back + loaded, err := loadManifestFromPath(manifestPath) + if err != nil { + t.Fatalf("loadManifestFromPath() error = %v", err) + } + if len(loaded.Packages) != 1 { + t.Errorf("Expected 1 package, got %d", len(loaded.Packages)) + } + if loaded.Packages[0].Host != "github.com" { + t.Errorf("Expected host github.com, got %s", loaded.Packages[0].Host) + } +} + +func TestAddAndRemoveFromManifest(t *testing.T) { + tempDir := t.TempDir() + + // Set up environment for manifest path + if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil { + t.Fatalf("Setenv() error = %v", err) + } + defer func() { + if err := os.Unsetenv("XDG_DATA_HOME"); err != nil { + t.Logf("Unsetenv() error = %v", err) + } + }() + + // The manifest path when XDG_DATA_HOME is set + manifestPath := filepath.Join(tempDir, "kit", "git", "packages.json") + + // Add an entry + entry := ManifestEntry{ + Source: "git:github.com/user/repo", + Host: "github.com", + Path: "user/repo", + Scope: ScopeGlobal, + } + + err := addEntryToManifest(entry, ScopeGlobal) + if err != nil { + t.Fatalf("addEntryToManifest() error = %v", err) + } + + // Verify it was added + manifest, err := loadManifestFromPath(manifestPath) + if err != nil { + t.Fatalf("loadManifestFromPath() error = %v", err) + } + if len(manifest.Packages) != 1 { + t.Errorf("Expected 1 package, got %d", len(manifest.Packages)) + } + + // Remove it + err = removeEntryFromManifest("github.com/user/repo", ScopeGlobal) + if err != nil { + t.Fatalf("removeEntryFromManifest() error = %v", err) + } + + // Verify it was removed + manifest, err = loadManifestFromPath(manifestPath) + if err != nil { + t.Fatalf("loadManifestFromPath() error = %v", err) + } + if len(manifest.Packages) != 0 { + t.Errorf("Expected 0 packages, got %d", len(manifest.Packages)) + } +} + +func TestFindInManifest(t *testing.T) { + tempDir := t.TempDir() + if err := os.Setenv("XDG_DATA_HOME", tempDir); err != nil { + t.Fatalf("Setenv() error = %v", err) + } + defer func() { + if err := os.Unsetenv("XDG_DATA_HOME"); err != nil { + t.Logf("Unsetenv() error = %v", err) + } + }() + + // Add an entry to global manifest + entry := ManifestEntry{ + Source: "git:github.com/user/repo", + Host: "github.com", + Path: "user/repo", + Scope: ScopeGlobal, + } + + err := addEntryToManifest(entry, ScopeGlobal) + if err != nil { + t.Fatalf("addEntryToManifest() error = %v", err) + } + + // Find it + found, scope, err := FindInManifest("github.com/user/repo") + if err != nil { + t.Fatalf("FindInManifest() error = %v", err) + } + if found == nil { + t.Fatal("Expected to find entry, got nil") + } + if scope != ScopeGlobal { + t.Errorf("Expected scope global, got %s", scope) + } + + // Try to find non-existent + notFound, _, err := FindInManifest("github.com/other/repo") + if err != nil { + t.Fatalf("FindInManifest() error = %v", err) + } + if notFound != nil { + t.Error("Expected nil for non-existent entry") + } +} diff --git a/internal/extensions/loader.go b/internal/extensions/loader.go index 1c36f361..bb8dd920 100644 --- a/internal/extensions/loader.go +++ b/internal/extensions/loader.go @@ -71,12 +71,24 @@ func discoverExtensionPaths(extraPaths []string) []string { add(p) } + // Global installed git packages: $XDG_DATA_HOME/kit/git/ + globalGitDir := globalGitInstallRoot() + for _, p := range findExtensionsInGitPackages(globalGitDir) { + add(p) + } + // Project-local extensions: .kit/extensions/ localDir := filepath.Join(".kit", "extensions") for _, p := range findExtensionsInDir(localDir) { add(p) } + // Project-local installed git packages: .kit/git/ + projectGitDir := filepath.Join(".kit", "git") + for _, p := range findExtensionsInGitPackages(projectGitDir) { + add(p) + } + // Explicit paths (highest precedence) for _, p := range extraPaths { info, err := os.Stat(p) @@ -123,6 +135,93 @@ func findExtensionsInDir(dir string) []string { return results } +// findExtensionsInGitPackages scans installed git packages for extension files. +// Each git package is stored at //// and can contain +// .go files or a main.go in subdirectories. +// If a package has a manifest with Include field, only those paths are loaded. +func findExtensionsInGitPackages(gitRoot string) []string { + info, err := os.Stat(gitRoot) + if err != nil || !info.IsDir() { + return nil + } + + var results []string + + // Load the manifest if it exists + manifestPath := filepath.Join(gitRoot, "packages.json") + manifest, _ := loadManifestFromPath(manifestPath) + // Build a map of package identity -> include list + includeMap := make(map[string][]string) + if manifest != nil { + for _, entry := range manifest.Packages { + if len(entry.Include) > 0 { + identity := fmt.Sprintf("%s/%s", entry.Host, entry.Path) + includeMap[identity] = entry.Include + } + } + } + + // Walk through host directories (e.g., github.com/) + hosts, err := os.ReadDir(gitRoot) + if err != nil { + return nil + } + + for _, host := range hosts { + if !host.IsDir() { + continue + } + hostPath := filepath.Join(gitRoot, host.Name()) + + // Walk through owner directories (e.g., github.com/user/) + owners, err := os.ReadDir(hostPath) + if err != nil { + continue + } + + for _, owner := range owners { + if !owner.IsDir() { + continue + } + ownerPath := filepath.Join(hostPath, owner.Name()) + + // Walk through repo directories (e.g., github.com/user/repo/) + repos, err := os.ReadDir(ownerPath) + if err != nil { + continue + } + + for _, repo := range repos { + if !repo.IsDir() { + continue + } + repoPath := filepath.Join(ownerPath, repo.Name()) + + // Check if there's an include filter for this package + identity := fmt.Sprintf("%s/%s/%s", host.Name(), owner.Name(), repo.Name()) + includes, hasFilter := includeMap[identity] + + if hasFilter { + // Only include specific paths + for _, include := range includes { + // Convert relative path to absolute + include = strings.TrimPrefix(include, "./") + fullPath := filepath.Join(repoPath, filepath.FromSlash(include)) + if _, err := os.Stat(fullPath); err == nil { + results = append(results, fullPath) + } + } + } else { + // Find all extensions within this repo + results = append(results, findExtensionsInDir(repoPath)...) + } + } + } + } + + return results +} + // globalExtensionsDir returns the global extensions directory, respecting // $XDG_CONFIG_HOME. Defaults to ~/.config/kit/extensions. func globalExtensionsDir() string { diff --git a/internal/extensions/manifest.go b/internal/extensions/manifest.go new file mode 100644 index 00000000..89e22c72 --- /dev/null +++ b/internal/extensions/manifest.go @@ -0,0 +1,291 @@ +package extensions + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// Manifest tracks installed git packages. +type Manifest struct { + Packages []ManifestEntry `json:"packages"` +} + +// ManifestEntry represents a single installed package. +type ManifestEntry struct { + // Source is the canonical string representation (e.g., "git:github.com/user/repo@v1.0.0") + Source string `json:"source"` + // Repo is the clone URL + Repo string `json:"repo"` + // Host is the git host (e.g., github.com) + Host string `json:"host"` + // Path is the path on the host (e.g., user/repo) + Path string `json:"path"` + // Ref is the optional pinned ref (tag/branch/commit) + Ref string `json:"ref,omitempty"` + // Pinned indicates if the ref is pinned + Pinned bool `json:"pinned"` + // Scope is where the package is installed (global or project) + Scope InstallScope `json:"scope"` + // Installed is when the package was first installed + Installed time.Time `json:"installed"` + // Updated is when the package was last updated (only for unpinned, zero time means never updated) + Updated time.Time `json:"updated,omitzero"` + // Include is a list of relative paths to extensions that should be loaded. + // If empty, all extensions in the package are loaded. + // Paths are relative to the package root (e.g., "./git/main.go", "./weather.go") + Include []string `json:"include,omitempty"` +} + +// Identity returns the normalized identity for deduplication. +func (e ManifestEntry) Identity() string { + return fmt.Sprintf("%s/%s", e.Host, e.Path) +} + +// loadManifest loads the manifest from the given scope. +func loadManifestFromScope(scope InstallScope) (*Manifest, error) { + path := manifestPathForScope(scope) + return loadManifestFromPath(path) +} + +// loadManifestFromPath loads a manifest from a specific file path. +func loadManifestFromPath(path string) (*Manifest, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &Manifest{Packages: []ManifestEntry{}}, nil + } + return nil, fmt.Errorf("reading manifest: %w", err) + } + + var manifest Manifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("parsing manifest: %w", err) + } + + return &manifest, nil +} + +// saveManifestToScope saves the manifest to the given scope. +func saveManifestToScope(manifest *Manifest, scope InstallScope) error { + path := manifestPathForScope(scope) + return saveManifestToPath(manifest, path) +} + +// saveManifestToPath saves a manifest to a specific file path. +func saveManifestToPath(manifest *Manifest, path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating manifest directory: %w", err) + } + + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return fmt.Errorf("encoding manifest: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing manifest: %w", err) + } + + return nil +} + +// manifestPathForScope returns the manifest file path for a scope. +func manifestPathForScope(scope InstallScope) string { + if scope == ScopeProject { + return filepath.Join(".kit", "git", "packages.json") + } + + base := os.Getenv("XDG_DATA_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + base = filepath.Join(home, ".local", "share") + } + return filepath.Join(base, "kit", "git", "packages.json") +} + +// GetGlobalManifest returns the global manifest. +func GetGlobalManifest() (*Manifest, error) { + return loadManifestFromScope(ScopeGlobal) +} + +// GetProjectManifest returns the project manifest. +func GetProjectManifest() (*Manifest, error) { + return loadManifestFromScope(ScopeProject) +} + +// addEntryToManifest adds or replaces an entry in the manifest for a scope. +func addEntryToManifest(entry ManifestEntry, scope InstallScope) error { + manifest, err := loadManifestFromScope(scope) + if err != nil { + return err + } + + // Remove any existing entry with same identity + identity := entry.Identity() + filtered := make([]ManifestEntry, 0, len(manifest.Packages)) + for _, p := range manifest.Packages { + if p.Identity() != identity { + filtered = append(filtered, p) + } + } + filtered = append(filtered, entry) + manifest.Packages = filtered + + return saveManifestToScope(manifest, scope) +} + +// removeEntryFromManifest removes an entry by identity from the manifest for a scope. +func removeEntryFromManifest(identity string, scope InstallScope) error { + manifest, err := loadManifestFromScope(scope) + if err != nil { + return err + } + + filtered := make([]ManifestEntry, 0, len(manifest.Packages)) + for _, p := range manifest.Packages { + if p.Identity() != identity { + filtered = append(filtered, p) + } + } + manifest.Packages = filtered + + return saveManifestToScope(manifest, scope) +} + +// FindInManifest finds an entry by identity in either global or project manifest. +// Returns the entry and its scope, or nil if not found. +func FindInManifest(identity string) (*ManifestEntry, InstallScope, error) { + global, err := loadManifestFromScope(ScopeGlobal) + if err != nil { + return nil, "", fmt.Errorf("loading global manifest: %w", err) + } + for _, p := range global.Packages { + if p.Identity() == identity { + return &p, ScopeGlobal, nil + } + } + + project, err := loadManifestFromScope(ScopeProject) + if err != nil { + return nil, "", fmt.Errorf("loading project manifest: %w", err) + } + for _, p := range project.Packages { + if p.Identity() == identity { + return &p, ScopeProject, nil + } + } + + return nil, "", nil +} + +// ExtensionPreview represents a discovered extension in a package before installation. +type ExtensionPreview struct { + // Path is the relative path from the package root (e.g., "./git/main.go") + Path string `json:"path"` + // Name is a display name for the extension (derived from path or metadata) + Name string `json:"name"` + // Description is an optional description (could be extracted from comments) + Description string `json:"description,omitempty"` + // IsMain indicates if this is a main.go in a subdirectory + IsMain bool `json:"is_main"` +} + +// ScanForExtensions discovers all extensions in a directory. +// Returns a list of ExtensionPreview for each .go file and main.go in subdirs. +func ScanForExtensions(dir string) ([]ExtensionPreview, error) { + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + return nil, fmt.Errorf("not a directory: %s", dir) + } + + var previews []ExtensionPreview + + err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories at the root level (they're handled by main.go check) + if info.IsDir() { + // Check for main.go in this directory + mainPath := filepath.Join(path, "main.go") + if _, err := os.Stat(mainPath); err == nil { + rel, _ := filepath.Rel(dir, mainPath) + previews = append(previews, ExtensionPreview{ + Path: "./" + filepath.ToSlash(rel), + Name: deriveExtensionName(rel, true), + IsMain: true, + }) + // Don't descend into this directory + return filepath.SkipDir + } + return nil + } + + // Only process .go files + if !strings.HasSuffix(info.Name(), ".go") { + return nil + } + + // Skip main.go at root level (we'll catch it above) + if info.Name() == "main.go" && filepath.Dir(path) == dir { + rel, _ := filepath.Rel(dir, path) + previews = append(previews, ExtensionPreview{ + Path: "./" + filepath.ToSlash(rel), + Name: deriveExtensionName(rel, true), + IsMain: true, + }) + return nil + } + + // Regular .go file + rel, _ := filepath.Rel(dir, path) + previews = append(previews, ExtensionPreview{ + Path: "./" + filepath.ToSlash(rel), + Name: deriveExtensionName(rel, false), + IsMain: false, + }) + + return nil + }) + + if err != nil { + return nil, err + } + + return previews, nil +} + +// deriveExtensionName creates a display name from a file path. +func deriveExtensionName(relPath string, isMain bool) string { + // Convert path to a readable name + // e.g., "git/main.go" -> "Git Extension" + // e.g., "weather.go" -> "Weather" + + dir := filepath.Dir(relPath) + base := filepath.Base(relPath) + + if isMain && dir != "." { + // Use directory name for main.go files + name := strings.ReplaceAll(dir, "/", " ") + name = strings.ReplaceAll(name, "_", " ") + name = strings.ReplaceAll(name, "-", " ") + return cases.Title(language.English).String(name) + " Extension" + } + + // Use filename without extension + name := strings.TrimSuffix(base, ".go") + name = strings.ReplaceAll(name, "_", " ") + name = strings.ReplaceAll(name, "-", " ") + return cases.Title(language.English).String(name) +} diff --git a/internal/extensions/symbols.go b/internal/extensions/symbols.go index 588d87fa..d4677903 100644 --- a/internal/extensions/symbols.go +++ b/internal/extensions/symbols.go @@ -90,12 +90,14 @@ func Symbols() interp.Exports { "EditorConfig": reflect.ValueOf((*EditorConfig)(nil)), // Prompt types - "PromptSelectConfig": reflect.ValueOf((*PromptSelectConfig)(nil)), - "PromptSelectResult": reflect.ValueOf((*PromptSelectResult)(nil)), - "PromptConfirmConfig": reflect.ValueOf((*PromptConfirmConfig)(nil)), - "PromptConfirmResult": reflect.ValueOf((*PromptConfirmResult)(nil)), - "PromptInputConfig": reflect.ValueOf((*PromptInputConfig)(nil)), - "PromptInputResult": reflect.ValueOf((*PromptInputResult)(nil)), + "PromptSelectConfig": reflect.ValueOf((*PromptSelectConfig)(nil)), + "PromptSelectResult": reflect.ValueOf((*PromptSelectResult)(nil)), + "PromptConfirmConfig": reflect.ValueOf((*PromptConfirmConfig)(nil)), + "PromptConfirmResult": reflect.ValueOf((*PromptConfirmResult)(nil)), + "PromptInputConfig": reflect.ValueOf((*PromptInputConfig)(nil)), + "PromptInputResult": reflect.ValueOf((*PromptInputResult)(nil)), + "PromptMultiSelectConfig": reflect.ValueOf((*PromptMultiSelectConfig)(nil)), + "PromptMultiSelectResult": reflect.ValueOf((*PromptMultiSelectResult)(nil)), // Context filtering types "ContextMessage": reflect.ValueOf((*ContextMessage)(nil)),