mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
feat: add skill subcommand to install kit-extensions skill via skills.sh
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// skillCmd installs the kit-extensions skill via the skills.sh CLI (npx skills).
|
||||
// This teaches AI agents how to create Kit extensions with full knowledge of
|
||||
// the extension API, lifecycle events, widgets, tools, commands, and Yaegi constraints.
|
||||
var skillCmd = &cobra.Command{
|
||||
Use: "skill",
|
||||
Short: "Install the Kit extensions skill via skills.sh",
|
||||
Long: `Install the kit-extensions skill that teaches AI agents how to create
|
||||
Kit extensions. Uses the skills.sh CLI (npx skills) to install the skill
|
||||
from the Kit repository.
|
||||
|
||||
The skill provides comprehensive documentation of Kit's extension API including
|
||||
lifecycle events, custom tools, slash commands, widgets, editor interceptors,
|
||||
tool renderers, and critical Yaegi interpreter constraints.
|
||||
|
||||
Example:
|
||||
kit skill`,
|
||||
RunE: runSkill,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(skillCmd)
|
||||
}
|
||||
|
||||
func runSkill(_ *cobra.Command, _ []string) error {
|
||||
npx, err := exec.LookPath("npx")
|
||||
if err != nil {
|
||||
return fmt.Errorf("npx not found in PATH — install Node.js to use this command: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"skills",
|
||||
"add",
|
||||
"mark3labs/kit",
|
||||
"--skill",
|
||||
"kit-extensions",
|
||||
}
|
||||
|
||||
cmd := exec.Command(npx, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("skills install failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
---
|
||||
name: kit-extensions
|
||||
description: Guide for creating Kit extensions. Use when the user asks to build, create, or modify a Kit extension, add a custom tool, slash command, widget, keyboard shortcut, editor interceptor, tool renderer, or hook into any Kit lifecycle event.
|
||||
---
|
||||
|
||||
# Kit Extensions Development Guide
|
||||
|
||||
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.
|
||||
|
||||
## Extension Structure
|
||||
|
||||
Every extension must export a `package main` with an `Init(api ext.API)` function:
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Register event handlers, tools, commands, etc.
|
||||
}
|
||||
```
|
||||
|
||||
The `//go:build ignore` tag prevents `go build` from compiling the file directly.
|
||||
|
||||
## Extension Locations
|
||||
|
||||
Extensions are auto-loaded from these directories:
|
||||
|
||||
- `~/.config/kit/extensions/*.go` (global, single files)
|
||||
- `~/.config/kit/extensions/*/main.go` (global, subdirectories)
|
||||
- `.kit/extensions/*.go` (project-local, single files)
|
||||
- `.kit/extensions/*/main.go` (project-local, subdirectories)
|
||||
|
||||
Or loaded explicitly:
|
||||
|
||||
```bash
|
||||
kit -e path/to/extension.go
|
||||
kit --extension path/to/extension.go
|
||||
```
|
||||
|
||||
## Import Path
|
||||
|
||||
Extensions import the Kit API as `"kit/ext"`. The full standard library is available plus `os/exec` for subprocess spawning.
|
||||
|
||||
## API Overview
|
||||
|
||||
The `Init` function receives an `ext.API` object for registering handlers, and event handlers receive an `ext.Context` with runtime capabilities.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle Events
|
||||
|
||||
Kit provides 18 lifecycle events. Each handler receives an event struct and a `Context`.
|
||||
|
||||
### Session Events
|
||||
|
||||
```go
|
||||
// Fired when session is loaded/created.
|
||||
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
|
||||
// e.SessionID string
|
||||
})
|
||||
|
||||
// Fired when Kit is shutting down. Use for cleanup.
|
||||
api.OnSessionShutdown(func(e ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
// No fields.
|
||||
})
|
||||
```
|
||||
|
||||
### Agent Turn Events
|
||||
|
||||
```go
|
||||
// Before agent starts processing. Can inject system prompt or text.
|
||||
api.OnBeforeAgentStart(func(e ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
// e.Prompt string
|
||||
// Return nil to pass through.
|
||||
// Return &ext.BeforeAgentStartResult{SystemPrompt: &s} to augment system prompt.
|
||||
// Return &ext.BeforeAgentStartResult{InjectText: &s} to inject text before prompt.
|
||||
return nil
|
||||
})
|
||||
|
||||
// Agent loop has started.
|
||||
api.OnAgentStart(func(e ext.AgentStartEvent, ctx ext.Context) {
|
||||
// e.Prompt string
|
||||
})
|
||||
|
||||
// Agent finished responding.
|
||||
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
// e.Response string
|
||||
// e.StopReason string — "completed", "cancelled", "error"
|
||||
})
|
||||
```
|
||||
|
||||
### Tool Events
|
||||
|
||||
```go
|
||||
// Before a tool executes. Can block the call.
|
||||
api.OnToolCall(func(e ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
// e.ToolName string
|
||||
// e.ToolCallID string
|
||||
// e.Input string — JSON-encoded parameters
|
||||
// e.Source string — "llm" or "user"
|
||||
// Return nil to allow.
|
||||
// Return &ext.ToolCallResult{Block: true, Reason: "..."} to block.
|
||||
return nil
|
||||
})
|
||||
|
||||
// Tool execution started (informational only).
|
||||
api.OnToolExecutionStart(func(e ext.ToolExecutionStartEvent, ctx ext.Context) {
|
||||
// e.ToolName string
|
||||
})
|
||||
|
||||
// Tool execution ended (informational only).
|
||||
api.OnToolExecutionEnd(func(e ext.ToolExecutionEndEvent, ctx ext.Context) {
|
||||
// e.ToolName string
|
||||
})
|
||||
|
||||
// After a tool returns. Can modify the result.
|
||||
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
// e.ToolName string
|
||||
// e.Input string
|
||||
// e.Content string
|
||||
// e.IsError bool
|
||||
// Return nil to pass through.
|
||||
// Return &ext.ToolResultResult{Content: &s} to replace content.
|
||||
// Return &ext.ToolResultResult{IsError: &b} to change error status.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Input Events
|
||||
|
||||
```go
|
||||
// User submitted input. Can handle or transform it.
|
||||
api.OnInput(func(e ext.InputEvent, ctx ext.Context) *ext.InputResult {
|
||||
// e.Text string
|
||||
// e.Source string — "interactive", "cli", "script", "queue"
|
||||
// Return nil to pass through to agent.
|
||||
// Return &ext.InputResult{Action: "handled"} to consume without sending to agent.
|
||||
// Return &ext.InputResult{Action: "transform", Text: "new text"} to rewrite.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Streaming Events
|
||||
|
||||
```go
|
||||
api.OnMessageStart(func(e ext.MessageStartEvent, ctx ext.Context) {})
|
||||
api.OnMessageUpdate(func(e ext.MessageUpdateEvent, ctx ext.Context) {
|
||||
// e.Chunk string — streaming text chunk
|
||||
})
|
||||
api.OnMessageEnd(func(e ext.MessageEndEvent, ctx ext.Context) {
|
||||
// e.Content string — full message content
|
||||
})
|
||||
```
|
||||
|
||||
### Model Events
|
||||
|
||||
```go
|
||||
api.OnModelChange(func(e ext.ModelChangeEvent, ctx ext.Context) {
|
||||
// e.NewModel string
|
||||
// e.PreviousModel string
|
||||
// e.Source string — "extension" or "user"
|
||||
})
|
||||
```
|
||||
|
||||
### Context Filtering
|
||||
|
||||
```go
|
||||
// Before messages are sent to the LLM. Can filter, reorder, or inject messages.
|
||||
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
// e.Messages []ext.ContextMessage
|
||||
// Each ContextMessage has: Index int, Role string, Content string
|
||||
// Index -1 means a new injected message (not from session).
|
||||
// Return nil to pass through.
|
||||
// Return &ext.ContextPrepareResult{Messages: msgs} to replace the context window.
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Session Control Events
|
||||
|
||||
```go
|
||||
// Before forking the session tree. Can cancel.
|
||||
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
|
||||
// e.TargetID string, e.IsUserMessage bool, e.UserText string
|
||||
return nil // or &ext.BeforeForkResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
|
||||
// Before switching/clearing session. Can cancel.
|
||||
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
|
||||
// e.Reason string — "new" or "clear"
|
||||
return nil // or &ext.BeforeSessionSwitchResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
|
||||
// Before context compaction. Can cancel.
|
||||
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
|
||||
// e.EstimatedTokens, e.ContextLimit int
|
||||
// e.UsagePercent float64, e.MessageCount int, e.IsAutomatic bool
|
||||
return nil // or &ext.BeforeCompactResult{Cancel: true, Reason: "..."}
|
||||
})
|
||||
```
|
||||
|
||||
### Custom Events
|
||||
|
||||
```go
|
||||
// Subscribe to custom events emitted by other extensions.
|
||||
api.OnCustomEvent("event-name", func(data string) {
|
||||
// data is arbitrary string payload
|
||||
})
|
||||
|
||||
// Emit from Context:
|
||||
ctx.EmitCustomEvent("event-name", "payload")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registering Tools
|
||||
|
||||
Tools are functions the LLM can invoke:
|
||||
|
||||
```go
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "current_time",
|
||||
Description: "Get the current date and time",
|
||||
Parameters: `{"type":"object","properties":{}}`,
|
||||
Execute: func(input string) (string, error) {
|
||||
return time.Now().Format(time.RFC3339), nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
For long-running tools with cancellation and progress:
|
||||
|
||||
```go
|
||||
api.RegisterTool(ext.ToolDef{
|
||||
Name: "slow_task",
|
||||
Description: "A long-running task with progress reporting",
|
||||
Parameters: `{"type":"object","properties":{"query":{"type":"string"}}}`,
|
||||
ExecuteWithContext: func(input string, tc ext.ToolContext) (string, error) {
|
||||
for i := 0; i < 10; i++ {
|
||||
if tc.IsCancelled() {
|
||||
return "cancelled", nil
|
||||
}
|
||||
tc.OnProgress(fmt.Sprintf("Step %d/10...", i+1))
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return "done", nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Parameters must be a JSON Schema string. The `input` argument is the JSON-encoded parameters from the LLM.
|
||||
|
||||
---
|
||||
|
||||
## Registering Slash Commands
|
||||
|
||||
Commands are user-facing actions invoked with `/name` in the input:
|
||||
|
||||
```go
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "echo",
|
||||
Description: "Echo back the provided text",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
ctx.PrintInfo("You said: " + args)
|
||||
return "", nil
|
||||
},
|
||||
// Optional tab-completion:
|
||||
Complete: func(prefix string, ctx ext.Context) []string {
|
||||
return []string{"hello", "world"}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Slash commands run in a dedicated goroutine (not a `tea.Cmd`), so they can safely block on prompts, I/O, etc.
|
||||
|
||||
---
|
||||
|
||||
## Registering Keyboard Shortcuts
|
||||
|
||||
```go
|
||||
api.RegisterShortcut(ext.ShortcutDef{
|
||||
Key: "ctrl+alt+p",
|
||||
Description: "Toggle plan mode",
|
||||
}, func(ctx ext.Context) {
|
||||
// handler runs when shortcut is pressed
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registering Options
|
||||
|
||||
Options are configurable values resolved from env vars, config, or defaults:
|
||||
|
||||
```go
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "my-setting",
|
||||
Description: "Controls something",
|
||||
Default: "false",
|
||||
})
|
||||
|
||||
// Read at runtime (resolution: env KIT_OPT_MY_SETTING > config options.my-setting > default):
|
||||
val := ctx.GetOption("my-setting")
|
||||
|
||||
// Set at runtime:
|
||||
ctx.SetOption("my-setting", "true")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context API Reference
|
||||
|
||||
The `ext.Context` struct provides runtime capabilities via function fields.
|
||||
|
||||
### Output
|
||||
|
||||
```go
|
||||
ctx.Print("plain text") // plain output
|
||||
ctx.PrintInfo("styled info block") // bordered info block
|
||||
ctx.PrintError("styled error block") // red error block
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{ // custom styled block
|
||||
Text: "content",
|
||||
BorderColor: "#a6e3a1",
|
||||
Subtitle: "my-ext",
|
||||
})
|
||||
ctx.RenderMessage("renderer-name", "content") // use a registered message renderer
|
||||
```
|
||||
|
||||
### Message Injection
|
||||
|
||||
```go
|
||||
ctx.SendMessage("prompt text") // inject message and trigger agent turn (queued)
|
||||
ctx.CancelAndSend("new prompt") // cancel current turn, clear queue, send new message
|
||||
```
|
||||
|
||||
### Widgets
|
||||
|
||||
Persistent UI elements displayed above or below the input area:
|
||||
|
||||
```go
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "my-widget",
|
||||
Placement: ext.WidgetAbove, // or ext.WidgetBelow
|
||||
Content: ext.WidgetContent{
|
||||
Text: "Status: Active",
|
||||
Markdown: false, // set true for markdown rendering
|
||||
},
|
||||
Style: ext.WidgetStyle{
|
||||
BorderColor: "#a6e3a1", // hex color
|
||||
NoBorder: false,
|
||||
},
|
||||
Priority: 0, // lower values render first
|
||||
})
|
||||
|
||||
ctx.RemoveWidget("my-widget")
|
||||
```
|
||||
|
||||
### Header and Footer
|
||||
|
||||
```go
|
||||
ctx.SetHeader(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: "My Header"},
|
||||
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
})
|
||||
ctx.RemoveHeader()
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: "My Footer"},
|
||||
Style: ext.WidgetStyle{BorderColor: "#585b70"},
|
||||
})
|
||||
ctx.RemoveFooter()
|
||||
```
|
||||
|
||||
### Status Bar
|
||||
|
||||
```go
|
||||
ctx.SetStatus("key", "PLAN MODE", 10) // key, text, priority (lower = further left)
|
||||
ctx.RemoveStatus("key")
|
||||
```
|
||||
|
||||
### Interactive Prompts
|
||||
|
||||
These block until the user responds (safe in slash commands and goroutines):
|
||||
|
||||
```go
|
||||
// Selection list
|
||||
result := ctx.PromptSelect(ext.PromptSelectConfig{
|
||||
Message: "Pick one:",
|
||||
Options: []string{"Option A", "Option B", "Option C"},
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value string, result.Index int
|
||||
}
|
||||
|
||||
// Yes/No confirmation
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Are you sure?",
|
||||
DefaultValue: false,
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value bool
|
||||
}
|
||||
|
||||
// Text input
|
||||
result := ctx.PromptInput(ext.PromptInputConfig{
|
||||
Message: "Enter name:",
|
||||
Placeholder: "my-project",
|
||||
Default: "",
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Value string
|
||||
}
|
||||
```
|
||||
|
||||
### Overlay Dialogs
|
||||
|
||||
Modal dialogs with optional action buttons:
|
||||
|
||||
```go
|
||||
result := ctx.ShowOverlay(ext.OverlayConfig{
|
||||
Title: "Confirmation",
|
||||
Content: ext.WidgetContent{Text: "Are you sure you want to proceed?", Markdown: true},
|
||||
Style: ext.OverlayStyle{BorderColor: "#f38ba8"},
|
||||
Width: 60, // 0 = 60% of terminal width
|
||||
MaxHeight: 20, // 0 = 80% of terminal height
|
||||
Anchor: ext.OverlayCenter, // or ext.OverlayTopCenter, ext.OverlayBottomCenter
|
||||
Actions: []string{"Confirm", "Cancel"},
|
||||
})
|
||||
if !result.Cancelled {
|
||||
// result.Action string, result.Index int
|
||||
}
|
||||
```
|
||||
|
||||
### Editor Interceptor
|
||||
|
||||
Wrap the built-in text input with custom key handling and rendering:
|
||||
|
||||
```go
|
||||
ctx.SetEditor(ext.EditorConfig{
|
||||
HandleKey: func(key string, currentText string) ext.EditorKeyAction {
|
||||
if key == "ctrl+s" {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: currentText}
|
||||
}
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
},
|
||||
Render: func(width int, defaultContent string) string {
|
||||
return "[custom] " + defaultContent
|
||||
},
|
||||
})
|
||||
|
||||
ctx.ResetEditor() // remove interceptor
|
||||
ctx.SetEditorText("prefilled") // set editor text content
|
||||
```
|
||||
|
||||
**EditorKeyAction types:**
|
||||
- `ext.EditorKeyPassthrough` — let the default editor handle the key
|
||||
- `ext.EditorKeyConsumed` — swallow the key, do nothing
|
||||
- `ext.EditorKeyRemap` — remap to a different key: `EditorKeyAction{Type: ext.EditorKeyRemap, RemappedKey: "up"}`
|
||||
- `ext.EditorKeySubmit` — submit text: `EditorKeyAction{Type: ext.EditorKeySubmit, SubmitText: "text"}`
|
||||
|
||||
### UI Visibility
|
||||
|
||||
```go
|
||||
ctx.SetUIVisibility(ext.UIVisibility{
|
||||
HideStartupMessage: true,
|
||||
HideStatusBar: true,
|
||||
HideSeparator: true,
|
||||
HideInputHint: true,
|
||||
})
|
||||
```
|
||||
|
||||
### Session Data
|
||||
|
||||
```go
|
||||
stats := ctx.GetContextStats() // .EstimatedTokens, .ContextLimit, .UsagePercent, .MessageCount
|
||||
msgs := ctx.GetMessages() // []ext.SessionMessage on current branch
|
||||
path := ctx.GetSessionPath() // file path of session JSONL
|
||||
|
||||
// Persist custom data in the session tree:
|
||||
id, err := ctx.AppendEntry("my-type", "data string")
|
||||
entries := ctx.GetEntries("my-type") // []ext.ExtensionEntry{ID, EntryType, Data, Timestamp}
|
||||
```
|
||||
|
||||
### Model Management
|
||||
|
||||
```go
|
||||
err := ctx.SetModel("anthropic/claude-sonnet-4-20250514")
|
||||
models := ctx.GetAvailableModels() // []ext.ModelInfoEntry
|
||||
```
|
||||
|
||||
### Tool Management
|
||||
|
||||
```go
|
||||
tools := ctx.GetAllTools() // []ext.ToolInfo{Name, Description, Source, Enabled}
|
||||
ctx.SetActiveTools([]string{"read", "grep"}) // restrict to these tools only
|
||||
ctx.SetActiveTools(nil) // re-enable all tools
|
||||
```
|
||||
|
||||
### LLM Completions
|
||||
|
||||
Make standalone LLM calls (bypasses the agent tool loop):
|
||||
|
||||
```go
|
||||
resp, err := ctx.Complete(ext.CompleteRequest{
|
||||
Model: "", // empty = current model
|
||||
System: "You are ...", // optional system prompt
|
||||
Prompt: "Summarize...", // the prompt
|
||||
MaxTokens: 1000, // 0 = provider default
|
||||
OnChunk: func(chunk string) { /* streaming */ },
|
||||
})
|
||||
// resp.Text, resp.InputTokens, resp.OutputTokens, resp.Model
|
||||
```
|
||||
|
||||
### TUI Suspension
|
||||
|
||||
Temporarily release the terminal for interactive subprocesses:
|
||||
|
||||
```go
|
||||
ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command("vim", "file.go")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
```
|
||||
|
||||
### Application Control
|
||||
|
||||
```go
|
||||
ctx.Exit() // graceful shutdown
|
||||
err := ctx.ReloadExtensions() // hot-reload all extensions from disk
|
||||
```
|
||||
|
||||
### Context Fields
|
||||
|
||||
```go
|
||||
ctx.SessionID // string
|
||||
ctx.CWD // string — current working directory
|
||||
ctx.Model // string — active model name
|
||||
ctx.Interactive // bool — true if running in TUI mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Renderers
|
||||
|
||||
Customize how tool calls are displayed in the TUI:
|
||||
|
||||
```go
|
||||
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
||||
ToolName: "bash",
|
||||
DisplayName: "Shell", // replaces auto-capitalized name
|
||||
BorderColor: "#89b4fa",
|
||||
Background: "",
|
||||
BodyMarkdown: true, // render body through markdown
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args struct{ Command string `json:"command"` }
|
||||
json.Unmarshal([]byte(toolArgs), &args)
|
||||
return "$ " + args.Command
|
||||
},
|
||||
RenderBody: func(toolResult string, isError bool, width int) string {
|
||||
if isError {
|
||||
return "ERROR: " + toolResult
|
||||
}
|
||||
return toolResult
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Message Renderers
|
||||
|
||||
Define named output styles for `ctx.RenderMessage()`:
|
||||
|
||||
```go
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "success",
|
||||
Render: func(content string, width int) string {
|
||||
return " " + content // green checkmark prefix
|
||||
},
|
||||
})
|
||||
|
||||
// Usage in handlers:
|
||||
ctx.RenderMessage("success", "All tests passed")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Yaegi Constraints
|
||||
|
||||
### No Named Function References in Struct Fields
|
||||
|
||||
Yaegi has a bug where named function references assigned to struct fields return zero values across the interpreter boundary. Always use anonymous closure literals:
|
||||
|
||||
```go
|
||||
// WRONG - will silently return zero values:
|
||||
func myHandler(key, text string) ext.EditorKeyAction {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
}
|
||||
ctx.SetEditor(ext.EditorConfig{HandleKey: myHandler})
|
||||
|
||||
// CORRECT - use anonymous closure:
|
||||
ctx.SetEditor(ext.EditorConfig{
|
||||
HandleKey: func(key, text string) ext.EditorKeyAction {
|
||||
return ext.EditorKeyAction{Type: ext.EditorKeyPassthrough}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
This applies to ALL struct fields that take function values: `ToolDef.Execute`, `CommandDef.Execute`, `EditorConfig.HandleKey`, `EditorConfig.Render`, `ToolRenderConfig.RenderHeader`, `ToolRenderConfig.RenderBody`, etc.
|
||||
|
||||
### No Interfaces Across the Boundary
|
||||
|
||||
All extension-facing API types are concrete structs, never interfaces. Yaegi crashes on interface wrapper generation.
|
||||
|
||||
### Package-Level Variables for State
|
||||
|
||||
Yaegi supports package-level variables captured in closures. This is the standard way to maintain state across event callbacks:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
var callCount int
|
||||
var lastTool string
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
callCount++
|
||||
lastTool = e.ToolName
|
||||
return nil
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Tool Call Blocking
|
||||
|
||||
Block dangerous operations by intercepting tool calls:
|
||||
|
||||
```go
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if tc.ToolName == "bash" {
|
||||
var input struct{ Command string `json:"command"` }
|
||||
json.Unmarshal([]byte(tc.Input), &input)
|
||||
if strings.Contains(input.Command, "rm -rf") {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "Dangerous command blocked",
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: System Prompt Injection
|
||||
|
||||
Augment the agent's behavior by injecting instructions:
|
||||
|
||||
```go
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
prompt := "Always respond with bullet points."
|
||||
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Background Processing with SendMessage
|
||||
|
||||
Run work in a goroutine and inject results back:
|
||||
|
||||
```go
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "run",
|
||||
Description: "Run a command in the background",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
go func() {
|
||||
out, err := exec.Command("sh", "-c", args).CombinedOutput()
|
||||
if err != nil {
|
||||
ctx.SendMessage(fmt.Sprintf("Command failed: %s\n%s", err, out))
|
||||
return
|
||||
}
|
||||
ctx.SendMessage(fmt.Sprintf("Command output:\n```\n%s\n```", out))
|
||||
}()
|
||||
return "Running in background...", nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Ephemeral Context Injection
|
||||
|
||||
Inject information into every LLM turn without persisting in session history:
|
||||
|
||||
```go
|
||||
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
data, err := os.ReadFile(".kit/context.md")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
injected := ext.ContextMessage{
|
||||
Index: -1, // -1 = new message, not from session
|
||||
Role: "system",
|
||||
Content: string(data),
|
||||
}
|
||||
msgs := append([]ext.ContextMessage{injected}, e.Messages...)
|
||||
return &ext.ContextPrepareResult{Messages: msgs}
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Live Widget Updates
|
||||
|
||||
Update a widget periodically from a goroutine:
|
||||
|
||||
```go
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "clock",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{Text: time.Now().Format("15:04:05")},
|
||||
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
})
|
||||
}
|
||||
}()
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern: Spawning Kit as a Sub-Agent
|
||||
|
||||
Extensions can spawn Kit as a subprocess for delegation:
|
||||
|
||||
```bash
|
||||
kit --quiet --no-session --no-extensions --system-prompt "You are a reviewer" --model anthropic/claude-sonnet-4-20250514 "Review this code"
|
||||
```
|
||||
|
||||
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursion), `--system-prompt` (string or file path).
|
||||
|
||||
---
|
||||
|
||||
## Testing Extensions
|
||||
|
||||
```bash
|
||||
# Validate syntax of all discovered extensions
|
||||
kit extensions validate
|
||||
|
||||
# List loaded extensions
|
||||
kit extensions list
|
||||
|
||||
# Run with a specific extension
|
||||
kit -e path/to/extension.go
|
||||
|
||||
# Run with multiple extensions
|
||||
kit -e ext1.go -e ext2.go
|
||||
|
||||
# Disable all extensions
|
||||
kit --no-extensions
|
||||
|
||||
# Generate an example extension scaffold
|
||||
kit extensions init
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
readOnlyTools := []string{"read", "grep", "find", "ls"}
|
||||
var planActive bool
|
||||
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "plan",
|
||||
Description: "Start in plan mode (read-only tools)",
|
||||
Default: "false",
|
||||
})
|
||||
|
||||
api.RegisterShortcut(ext.ShortcutDef{
|
||||
Key: "ctrl+alt+p",
|
||||
Description: "Toggle plan/explore mode",
|
||||
}, func(ctx ext.Context) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "plan",
|
||||
Description: "Toggle plan/explore mode",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
if strings.ToLower(ctx.GetOption("plan")) == "true" {
|
||||
planActive = true
|
||||
applyMode(ctx, true, readOnlyTools)
|
||||
}
|
||||
})
|
||||
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
if !planActive {
|
||||
return nil
|
||||
}
|
||||
prompt := `You are in PLAN MODE (read-only). You can ONLY read and search.
|
||||
Focus on understanding, analysis, and generating plans.`
|
||||
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
|
||||
})
|
||||
}
|
||||
|
||||
func applyMode(ctx ext.Context, active bool, tools []string) {
|
||||
if active {
|
||||
ctx.SetActiveTools(tools)
|
||||
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
|
||||
ctx.PrintInfo("Plan mode ON")
|
||||
} else {
|
||||
ctx.SetActiveTools(nil)
|
||||
ctx.RemoveStatus("plan-mode")
|
||||
ctx.PrintInfo("Plan mode OFF")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Files for Reference
|
||||
|
||||
- `internal/extensions/api.go` — Complete API type definitions
|
||||
- `internal/extensions/runner.go` — Event dispatch and state management
|
||||
- `internal/extensions/loader.go` — Yaegi interpreter setup
|
||||
- `internal/extensions/symbols.go` — All types exported to extensions
|
||||
- `examples/extensions/` — 25+ working example extensions
|
||||
Reference in New Issue
Block a user