mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-17 13:06:32 +00:00
Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f79601feb1 | |||
| eb3219e7ca | |||
| 7e7632ad3c | |||
| 0ef46a75f2 | |||
| 7f9a9da40a | |||
| 7ff9e84894 | |||
| 017eb99d44 | |||
| 15a1550205 | |||
| 2d14b3461f | |||
| b99aafaeaa | |||
| a55f6d3d9a | |||
| 027c2de849 | |||
| d24540693c | |||
| f7c8e7757b | |||
| 0d5374b17b | |||
| 25f17a104d | |||
| 20125f939b | |||
| d3b67ffd14 | |||
| 915dc066dd | |||
| 3b14814740 | |||
| a1decf9cff | |||
| ec4ac64343 | |||
| a95117686e | |||
| c0880e1ef6 | |||
| 4e66c0b4f7 | |||
| 131ce8f2cc | |||
| 3d0f3358cb | |||
| 25da02fa65 | |||
| 4ae03aab7c | |||
| 93895392e6 | |||
| 473070e78b | |||
| 12268a777f | |||
| 351c10d814 | |||
| 9de3843605 | |||
| 1d5473e111 | |||
| b6adcf159e | |||
| b1da4a28e6 | |||
| 95abb6fa6e | |||
| a9970cf346 | |||
| 13060a20f9 | |||
| adf603e944 | |||
| af486133a5 | |||
| a97cd47ced | |||
| 68518a2bdb | |||
| fd61db3e12 | |||
| e49066a119 | |||
| efaff7f44f | |||
| d3c970b607 | |||
| 23254fee64 | |||
| fe072ad2e1 | |||
| 8840cbfabc | |||
| a11b41cda4 | |||
| 8b7be8b735 | |||
| caa6d1c178 | |||
| 001156053d | |||
| 54717e32bc | |||
| 5b214b9fdf | |||
| c5e6ca6e4d | |||
| 419a139137 | |||
| 7b963624c1 | |||
| 66f2ba543b | |||
| 6dd052b990 | |||
| ef8628eecc | |||
| 3167222b72 | |||
| e3b37191b1 | |||
| 41d5f5e0fb | |||
| 3ad0b3616d | |||
| 8831b49b51 | |||
| c94edc929b | |||
| e49194a0d4 | |||
| 46b1acf444 | |||
| 6a6d201a50 | |||
| 930cbcb4f2 | |||
| 12e1ef2036 | |||
| a05da5f3ab | |||
| fefbf19b42 | |||
| 93905d4d77 | |||
| 7268ccdf4d | |||
| 9f59fa42dc | |||
| 8af7ca8455 | |||
| 424847f0db | |||
| 4c126ca41b | |||
| 4bdc4f75cc | |||
| bbd8975ca0 | |||
| e613a07773 | |||
| 1d3b4f8d56 | |||
| 118af2e152 | |||
| c46687fc44 | |||
| aeaa5368af | |||
| 4966c0ca2a | |||
| f3ea18ae3a | |||
| 24ea2c94e3 | |||
| 4577d218d3 | |||
| bd48457b27 | |||
| 84298a0743 | |||
| 393074447b | |||
| 879723fe90 | |||
| 57250a3a3d | |||
| 7e1686e572 | |||
| 4a8b10cde7 | |||
| cc5611eff7 | |||
| 51c70b63a7 | |||
| c9ee80d98a | |||
| 3ecedcbc2d | |||
| dbfa410fc1 | |||
| 512ecb92dc | |||
| aede76d807 | |||
| 9e1df38836 | |||
| 8f5efee837 | |||
| a392d3e572 | |||
| c40dc2f4fb | |||
| 37e82781b1 | |||
| 23c16bb197 | |||
| 9449f1fcdf | |||
| dc59cfc81e | |||
| 8407d924b9 | |||
| 91474af503 | |||
| e252791b3a |
@@ -1,64 +0,0 @@
|
||||
---
|
||||
name: btca-cli
|
||||
description: Operate the btca CLI for local resources and source-first answers. Use when setting up btca in a project, connecting a provider, adding or managing resources, and asking questions via btca commands. Invoke this skill when the user says "use btca" or needs to do more detailed research on a specific library or framework.
|
||||
---
|
||||
|
||||
# btca CLI
|
||||
|
||||
`btca` is a source-first research CLI. It hydrates resources (git, local, npm) into searchable context, then answers questions grounded in those sources. Use configured resources for ongoing work, or one-off anonymous resources directly in `btca ask`.
|
||||
|
||||
Full CLI reference: https://docs.btca.dev/guides/cli-reference
|
||||
|
||||
Add resources:
|
||||
|
||||
```bash
|
||||
# Git resource
|
||||
btca add -n svelte-dev https://github.com/sveltejs/svelte.dev
|
||||
|
||||
# Local directory
|
||||
btca add -n my-docs -t local /absolute/path/to/docs
|
||||
|
||||
# npm package
|
||||
btca add npm:@types/node@22.10.1 -n node-types -t npm
|
||||
```
|
||||
|
||||
Verify resources:
|
||||
|
||||
```bash
|
||||
btca resources
|
||||
```
|
||||
|
||||
Ask a question:
|
||||
|
||||
```bash
|
||||
btca ask -r svelte-dev -q "How do I define remote functions?"
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
- Ask with multiple resources:
|
||||
|
||||
```bash
|
||||
btca ask -r react -r typescript -q "How do I type useState?"
|
||||
```
|
||||
|
||||
- Ask with anonymous one-off resources (not saved to config):
|
||||
|
||||
```bash
|
||||
# One-off git repo
|
||||
btca ask -r https://github.com/sveltejs/svelte -q "Where is the implementation of writable stores?"
|
||||
|
||||
# One-off npm package
|
||||
btca ask -r npm:react@19.0.0 -q "How is useTransition exported?"
|
||||
```
|
||||
|
||||
## Config Overview
|
||||
|
||||
- Config lives in `btca.config.jsonc` (project) and `~/.config/btca/btca.config.jsonc` (global).
|
||||
- Project config overrides global and controls provider/model and resources.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- "No resources configured": add resources with `btca add ...` and re-run `btca resources`.
|
||||
- "Provider not connected": run `btca connect` and follow the prompts.
|
||||
- "Unknown resource": use `btca resources` for configured names, or pass a valid HTTPS git URL / `npm:<package>` as an anonymous one-off in `btca ask`.
|
||||
@@ -1,3 +0,0 @@
|
||||
interface:
|
||||
display_name: "BTCA CLI"
|
||||
short_description: "Help with BTCA CLI setup and usage workflows"
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Build and Deploy Docs to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install Dependencies
|
||||
working-directory: ./www
|
||||
run: bun install
|
||||
|
||||
- name: Build
|
||||
working-directory: ./www
|
||||
run: bun run build
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: JamesIves/github-pages-deploy-action@v4
|
||||
with:
|
||||
folder: www/out
|
||||
branch: gh-pages
|
||||
@@ -39,12 +39,42 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
npm-publish:
|
||||
runs-on: ubuntu-latest
|
||||
needs: goreleaser
|
||||
if: ${{ always() && (needs.goreleaser.result == 'success' || needs.goreleaser.result == 'skipped') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Set version from tag
|
||||
working-directory: npm
|
||||
run: |
|
||||
TAG=${{ inputs.tag || github.ref_name }}
|
||||
VERSION=${TAG#v}
|
||||
echo "Setting npm version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: npm
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [goreleaser, npm-publish]
|
||||
if: ${{ always() && (needs.goreleaser.result == 'success' || needs.goreleaser.result == 'skipped') && (needs.npm-publish.result == 'success') }}
|
||||
steps:
|
||||
- name: Send Discord Notification
|
||||
if: success()
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.RELEASES_WEBHOOK }}
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}
|
||||
TAG_NAME: ${{ inputs.tag || github.ref_name }}
|
||||
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
curl -H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
@@ -73,29 +103,3 @@ jobs:
|
||||
}]
|
||||
}" \
|
||||
$DISCORD_WEBHOOK
|
||||
|
||||
npm-publish:
|
||||
runs-on: ubuntu-latest
|
||||
needs: goreleaser
|
||||
if: ${{ always() && (needs.goreleaser.result == 'success' || needs.goreleaser.result == 'skipped') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Set version from tag
|
||||
working-directory: npm
|
||||
run: |
|
||||
TAG=${{ inputs.tag || github.ref_name }}
|
||||
VERSION=${TAG#v}
|
||||
echo "Setting npm version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: npm
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
+4
-2
@@ -1,14 +1,16 @@
|
||||
.aider*
|
||||
.task/
|
||||
.env
|
||||
.kit/
|
||||
.kit/*
|
||||
!.kit/extensions/
|
||||
aidocs/
|
||||
*.log
|
||||
/kit
|
||||
.idea
|
||||
test/
|
||||
build/
|
||||
dist/
|
||||
contribute/output/
|
||||
CONTEXT.md
|
||||
output/
|
||||
.agents/
|
||||
skills-lock.json
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
const (
|
||||
diagnosticsTimeout = 20 * time.Second
|
||||
maxOutputBytes = 12_000
|
||||
)
|
||||
|
||||
type toolPathInput struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type lintResult struct {
|
||||
Output string
|
||||
Err error
|
||||
}
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.Print("go-edit-lint extension loaded - will run gopls and golangci-lint on Go file edits")
|
||||
})
|
||||
|
||||
api.OnToolResult(func(e ext.ToolResultEvent, ctx ext.Context) *ext.ToolResultResult {
|
||||
if e.IsError || !isEditOrWrite(e.ToolName) {
|
||||
return nil
|
||||
}
|
||||
|
||||
absPath, ok := resolveGoFilePath(e.Input, ctx.CWD)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
report := runGoDiagnostics(ctx.CWD, absPath)
|
||||
|
||||
// Check if there are issues and add explicit prompt for the LLM to react
|
||||
goplsIssues, lintIssues := countIssues(report)
|
||||
hasIssues := goplsIssues > 0 || lintIssues > 0
|
||||
|
||||
var enhanced string
|
||||
if hasIssues {
|
||||
enhanced = e.Content + "\n\n" + report + "\n\n⚠️ DIAGNOSTICS FOUND: Please review the issues above and fix them before proceeding."
|
||||
} else {
|
||||
enhanced = e.Content + "\n\n" + report
|
||||
}
|
||||
|
||||
// Show TUI message block for diagnostics visibility (only if there are issues)
|
||||
if hasIssues {
|
||||
var msgLines []string
|
||||
msgLines = append(msgLines, fmt.Sprintf("File: %s", filepath.Base(absPath)))
|
||||
if goplsIssues > 0 {
|
||||
msgLines = append(msgLines, fmt.Sprintf("gopls: %d issue(s)", goplsIssues))
|
||||
}
|
||||
if lintIssues > 0 {
|
||||
msgLines = append(msgLines, fmt.Sprintf("golangci-lint: %d issue(s)", lintIssues))
|
||||
}
|
||||
msgLines = append(msgLines, "", "⚠️ Please fix these issues before proceeding.")
|
||||
|
||||
borderColor := "#f9e2af" // yellow
|
||||
if goplsIssues > 0 && lintIssues > 0 {
|
||||
borderColor = "#f38ba8" // red
|
||||
}
|
||||
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: strings.Join(msgLines, "\n"),
|
||||
BorderColor: borderColor,
|
||||
Subtitle: "go-edit-lint",
|
||||
})
|
||||
}
|
||||
|
||||
return &ext.ToolResultResult{Content: &enhanced}
|
||||
})
|
||||
}
|
||||
|
||||
func isEditOrWrite(toolName string) bool {
|
||||
return strings.EqualFold(toolName, "edit") || strings.EqualFold(toolName, "write")
|
||||
}
|
||||
|
||||
func resolveGoFilePath(inputJSON, cwd string) (string, bool) {
|
||||
var args toolPathInput
|
||||
if err := json.Unmarshal([]byte(inputJSON), &args); err != nil || args.Path == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
absPath := args.Path
|
||||
if !filepath.IsAbs(absPath) {
|
||||
absPath = filepath.Join(cwd, absPath)
|
||||
}
|
||||
|
||||
if strings.ToLower(filepath.Ext(absPath)) != ".go" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return absPath, true
|
||||
}
|
||||
|
||||
func runGoDiagnostics(cwd, absPath string) string {
|
||||
target := absPath
|
||||
if rel, err := filepath.Rel(cwd, absPath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
target = rel
|
||||
}
|
||||
|
||||
gopls := runGopls(cwd, absPath)
|
||||
lint := runGolangCILint(cwd, target)
|
||||
|
||||
return fmt.Sprintf(
|
||||
"<go_diagnostics file=%q>\n[gopls]\n%s\n\n[golangci-lint]\n%s\n</go_diagnostics>",
|
||||
filepath.Base(absPath),
|
||||
formatToolResult(gopls, "No diagnostics."),
|
||||
formatToolResult(lint, "No lint issues."),
|
||||
)
|
||||
}
|
||||
|
||||
func runGopls(cwd, absPath string) lintResult {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), diagnosticsTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "gopls", "check", absPath)
|
||||
cmd.Dir = cwd
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return lintResult{Err: fmt.Errorf("timed out after %s", diagnosticsTimeout)}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return lintResult{Output: truncate(string(out), maxOutputBytes), Err: fmt.Errorf("failed to run gopls check: %w", err)}
|
||||
}
|
||||
|
||||
return lintResult{Output: truncate(string(out), maxOutputBytes)}
|
||||
}
|
||||
|
||||
func runGolangCILint(cwd, target string) lintResult {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), diagnosticsTimeout)
|
||||
defer cancel()
|
||||
|
||||
args := []string{
|
||||
"run",
|
||||
target,
|
||||
"--show-stats=false",
|
||||
"--output.text.path", "stdout",
|
||||
"--output.text.colors=false",
|
||||
"--output.text.print-issued-lines=false",
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "golangci-lint", args...)
|
||||
cmd.Dir = cwd
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return lintResult{Err: fmt.Errorf("timed out after %s", diagnosticsTimeout)}
|
||||
}
|
||||
|
||||
trimmed := truncate(string(out), maxOutputBytes)
|
||||
if err == nil {
|
||||
return lintResult{Output: trimmed}
|
||||
}
|
||||
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
if ok && exitErr.ExitCode() == 1 {
|
||||
return lintResult{Output: trimmed}
|
||||
}
|
||||
|
||||
return lintResult{Output: trimmed, Err: fmt.Errorf("failed to run golangci-lint: %w", err)}
|
||||
}
|
||||
|
||||
func formatToolResult(res lintResult, emptyFallback string) string {
|
||||
var lines []string
|
||||
if res.Err != nil {
|
||||
lines = append(lines, "ERROR: "+res.Err.Error())
|
||||
}
|
||||
out := strings.TrimSpace(res.Output)
|
||||
if out == "" {
|
||||
if res.Err == nil {
|
||||
lines = append(lines, emptyFallback)
|
||||
}
|
||||
} else {
|
||||
lines = append(lines, out)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return emptyFallback
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "\n... output truncated ..."
|
||||
}
|
||||
|
||||
func countIssues(report string) (goplsCount, lintCount int) {
|
||||
// Extract gopls section
|
||||
goplsStart := strings.Index(report, "[gopls]")
|
||||
lintStart := strings.Index(report, "[golangci-lint]")
|
||||
endTag := strings.Index(report, "</go_diagnostics>")
|
||||
|
||||
if goplsStart != -1 && lintStart != -1 {
|
||||
goplsSection := report[goplsStart:lintStart]
|
||||
// Count non-empty lines excluding the header and "No diagnostics." message
|
||||
for _, line := range strings.Split(goplsSection, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && line != "[gopls]" && line != "No diagnostics." {
|
||||
goplsCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lintStart != -1 && endTag != -1 {
|
||||
lintSection := report[lintStart:endTag]
|
||||
// Count non-empty lines excluding the header and "No lint issues." message
|
||||
for _, line := range strings.Split(lintSection, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && line != "[golangci-lint]" && line != "No lint issues." {
|
||||
lintCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return goplsCount, lintCount
|
||||
}
|
||||
@@ -83,9 +83,9 @@ tmux kill-session -t kittest # cleanup
|
||||
### Non-Interactive Kit (Subprocess Spawning)
|
||||
Extensions can spawn Kit as a subprocess for sub-agent patterns:
|
||||
```bash
|
||||
kit --prompt "question" --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model
|
||||
kit --quiet --no-session --no-extensions --system-prompt /path/to/prompt.txt --model provider/model "question"
|
||||
```
|
||||
Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path).
|
||||
Positional args are the prompt. `@file` args attach file content. Key flags: `--quiet` (stdout only, no TUI), `--no-session` (ephemeral), `--no-extensions` (prevent recursive loading), `--system-prompt` (string or file path).
|
||||
|
||||
## External Repo Research
|
||||
- **ALWAYS use `btca`** to search external repos (e.g. iteratr, other reference codebases)
|
||||
|
||||
@@ -13,4 +13,752 @@
|
||||
|
||||
# KIT (Knowledge Inference Tool)
|
||||
|
||||
TBD
|
||||
A powerful, extensible AI coding agent CLI with multi-provider support, built-in tools, and a rich extension system.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Provider LLM Support**: Anthropic, OpenAI, Google Gemini, Ollama, Azure OpenAI, AWS Bedrock, OpenRouter, and more
|
||||
- **Built-in Core Tools**: bash, read, write, edit, grep, find, ls, spawn_subagent - no MCP overhead
|
||||
- **MCP Integration**: Connect external MCP servers for expanded capabilities
|
||||
- **Extension System**: Write custom tools, commands, widgets, and UI modifications in Go
|
||||
- **Theming**: 22 built-in color themes (KITT, Catppuccin, Dracula, Nord, etc.) with runtime switching, persistence, and custom theme files
|
||||
- **Model Persistence**: Model and thinking level selections are automatically saved and restored across sessions
|
||||
- **Prompt Templates**: Create reusable prompt templates with shell-style argument substitution
|
||||
- **Interactive TUI**: Rich terminal interface powered by Bubble Tea with streaming, syntax highlighting, and custom rendering
|
||||
- **Session Management**: Tree-based conversation history with branching support
|
||||
- **Non-Interactive Mode**: Script-friendly positional args with JSON output
|
||||
- **ACP Server**: Run Kit as an [Agent Client Protocol](https://agentclientprotocol.com) agent over stdio
|
||||
- **Go SDK**: Embed Kit in your own applications
|
||||
|
||||
## Installation
|
||||
|
||||
### Using npm / bun / pnpm
|
||||
|
||||
```bash
|
||||
npm install -g @mark3labs/kit
|
||||
# or
|
||||
bun install -g @mark3labs/kit
|
||||
# or
|
||||
pnpm install -g @mark3labs/kit
|
||||
```
|
||||
|
||||
### Using Go
|
||||
|
||||
```bash
|
||||
go install github.com/mark3labs/kit/cmd/kit@latest
|
||||
```
|
||||
|
||||
### Building from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mark3labs/kit.git
|
||||
cd kit
|
||||
go build -o kit ./cmd/kit
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Start interactive session
|
||||
kit
|
||||
|
||||
# Run a one-off prompt
|
||||
kit "List files in src/"
|
||||
|
||||
# Attach files as context
|
||||
kit @main.go @test.go "Review these files"
|
||||
|
||||
# Continue the most recent session
|
||||
kit --continue
|
||||
|
||||
# Model and thinking level selections are automatically persisted
|
||||
# across sessions and restored on next launch
|
||||
|
||||
# Use specific model
|
||||
kit --model anthropic/claude-sonnet-latest
|
||||
```
|
||||
|
||||
### Non-Interactive Mode
|
||||
|
||||
```bash
|
||||
# Get JSON output for scripting
|
||||
kit "Explain main.go" --json
|
||||
|
||||
# Quiet mode (final response only)
|
||||
kit "Run tests" --quiet
|
||||
|
||||
# Ephemeral mode (no session file)
|
||||
kit "Quick question" --no-session
|
||||
```
|
||||
|
||||
### ACP Server Mode
|
||||
|
||||
Kit can run as an [ACP (Agent Client Protocol)](https://agentclientprotocol.com) agent server, enabling ACP-compatible clients (such as [OpenCode](https://github.com/sst/opencode)) to drive Kit as a remote coding agent over stdio.
|
||||
|
||||
```bash
|
||||
# Start Kit as an ACP server (communicates via JSON-RPC 2.0 on stdin/stdout)
|
||||
kit acp
|
||||
|
||||
# With debug logging to stderr
|
||||
kit acp --debug
|
||||
```
|
||||
|
||||
The ACP server exposes Kit's full capabilities — LLM execution, tool calls (bash, read, write, edit, grep, etc.), and session persistence — over the standard ACP protocol. Sessions are persisted to Kit's normal JSONL session files, so they can be resumed later.
|
||||
|
||||
## Configuration
|
||||
|
||||
Kit looks for configuration in the following locations (in order of priority):
|
||||
|
||||
1. CLI flags
|
||||
2. Environment variables (with `KIT_` prefix)
|
||||
3. `./.kit.yml` / `./.kit.yaml` / `./.kit.json` (project-local)
|
||||
4. `~/.kit.yml` / `~/.kit.yaml` / `~/.kit.json` (global)
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
Create `~/.kit.yml`:
|
||||
|
||||
```yaml
|
||||
model: anthropic/claude-sonnet-latest
|
||||
max-tokens: 4096
|
||||
temperature: 0.7
|
||||
stream: true
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="sk-..."
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
export KIT_MODEL="openai/gpt-4o"
|
||||
```
|
||||
|
||||
### MCP Server Configuration
|
||||
|
||||
Add external MCP servers to `.kit.yml`:
|
||||
|
||||
```yaml
|
||||
mcpServers:
|
||||
filesystem:
|
||||
type: local
|
||||
command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed"]
|
||||
environment:
|
||||
LOG_LEVEL: "info"
|
||||
allowedTools: ["read_file", "write_file"]
|
||||
|
||||
search:
|
||||
type: remote
|
||||
url: "https://mcp.example.com/search"
|
||||
```
|
||||
|
||||
## CLI Reference
|
||||
|
||||
### Global Flags
|
||||
|
||||
```bash
|
||||
# Model and provider
|
||||
--model, -m Model to use (provider/model format)
|
||||
--provider-api-key API key for the provider
|
||||
--provider-url Base URL for provider API
|
||||
--tls-skip-verify Skip TLS certificate verification
|
||||
|
||||
# Session management
|
||||
--session, -s Open specific JSONL session file
|
||||
--continue, -c Resume most recent session for current directory
|
||||
--resume, -r Interactive session picker
|
||||
--no-session Ephemeral mode, no persistence
|
||||
|
||||
# Behavior (non-interactive: pass prompt as positional arg)
|
||||
--quiet Suppress all output (non-interactive only)
|
||||
--json Output response as JSON (non-interactive only)
|
||||
--no-exit Enter interactive mode after prompt completes
|
||||
--max-steps Maximum agent steps (0 for unlimited)
|
||||
--stream Enable streaming output (default: true)
|
||||
--compact Enable compact output mode
|
||||
--auto-compact Auto-compact conversation near context limit
|
||||
|
||||
# Extensions
|
||||
--extension, -e Load additional extension file(s) (repeatable)
|
||||
--no-extensions Disable all extensions
|
||||
--prompt-template Load a specific prompt template by name
|
||||
--no-prompt-templates Disable prompt template loading
|
||||
|
||||
# Generation parameters
|
||||
--max-tokens Maximum tokens in response (default: 4096)
|
||||
--temperature Randomness 0.0-1.0 (default: 0.7)
|
||||
--top-p Nucleus sampling 0.0-1.0 (default: 0.95)
|
||||
--top-k Limit top K tokens (default: 40)
|
||||
--stop-sequences Custom stop sequences (comma-separated)
|
||||
--thinking-level Extended thinking level: off, minimal, low, medium, high (default: off)
|
||||
|
||||
# System
|
||||
--config Config file path (default: ~/.kit.yml)
|
||||
--system-prompt System prompt text or file path
|
||||
--debug Enable debug logging
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# Authentication (for OAuth-enabled providers)
|
||||
kit auth login [provider] # Start OAuth flow (e.g., anthropic)
|
||||
kit auth logout [provider] # Remove credentials for provider
|
||||
kit auth status # Check authentication status
|
||||
|
||||
# Model database
|
||||
kit models [provider] # List available models (optionally filter by provider)
|
||||
kit models --all # Show all providers (not just Fantasy-compatible)
|
||||
kit update-models [source] # Update model database (from models.dev, URL, file, or 'embedded')
|
||||
|
||||
# Extension management
|
||||
kit extensions list # List discovered extensions
|
||||
kit extensions validate # Validate extension files
|
||||
kit extensions init # Generate example extension template
|
||||
kit install <git-url> # Install extensions from git repositories
|
||||
kit install -l <git-url> # Install to project-local .kit/git/ directory
|
||||
kit install -u <git-url> # Update an already-installed package
|
||||
kit install --uninstall <pkg> # Remove an installed package
|
||||
|
||||
# Skills
|
||||
kit skill # Install the Kit extensions skill via skills.sh
|
||||
|
||||
# ACP server
|
||||
kit acp # Start as ACP agent (stdio JSON-RPC)
|
||||
kit acp --debug # With debug logging to stderr
|
||||
```
|
||||
|
||||
## Themes
|
||||
|
||||
Kit ships with 22 built-in color themes that control all UI elements. Switch at runtime:
|
||||
|
||||
```
|
||||
/theme dracula
|
||||
/theme catppuccin
|
||||
/theme tokyonight
|
||||
```
|
||||
|
||||
Theme selections are automatically saved and restored on next launch (stored in `~/.config/kit/preferences.yml`). This persistence also applies to **model** and **thinking level** selections — all are saved together and restored on startup.
|
||||
|
||||
### Custom themes
|
||||
|
||||
Drop a `.yml` file in `~/.config/kit/themes/` (user) or `.kit/themes/` (project):
|
||||
|
||||
```yaml
|
||||
# ~/.config/kit/themes/my-theme.yml
|
||||
primary:
|
||||
light: "#8839ef"
|
||||
dark: "#cba6f7"
|
||||
success:
|
||||
light: "#40a02b"
|
||||
dark: "#a6e3a1"
|
||||
```
|
||||
|
||||
Built-in themes: `kitt`, `catppuccin`, `dracula`, `tokyonight`, `nord`, `gruvbox`, `monokai`, `solarized`, `github`, `one-dark`, `rose-pine`, `ayu`, `material`, `everforest`, `kanagawa`, `amoled`, `synthwave`, `vesper`, `flexoki`, `matrix`, `vercel`, `zenburn`
|
||||
|
||||
## Extension System
|
||||
|
||||
Extensions are Go source files that run via Yaegi interpreter. They can add custom tools, slash commands, widgets, keyboard shortcuts, themes, and intercept lifecycle events.
|
||||
|
||||
### Minimal Extension
|
||||
|
||||
```go
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: "Custom Footer"},
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
kit -e examples/extensions/minimal.go
|
||||
```
|
||||
|
||||
### Extension Capabilities
|
||||
|
||||
**Lifecycle Events**: OnSessionStart, OnSessionShutdown, OnBeforeAgentStart, OnAgentStart, OnAgentEnd, OnToolCall, OnToolExecutionStart, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact
|
||||
|
||||
**Custom Components**:
|
||||
- **Tools**: Add new tools the LLM can invoke
|
||||
- **Commands**: Register slash commands (e.g., `/mycommand`)
|
||||
- **Options**: Register configurable extension options
|
||||
- **Widgets**: Persistent status displays above/below input
|
||||
- **Headers/Footers**: Persistent content above/below the conversation
|
||||
- **Status Bar**: Custom status bar entries
|
||||
- **Shortcuts**: Global keyboard shortcuts
|
||||
- **Overlays**: Modal dialogs with markdown content
|
||||
- **Tool Renderers**: Customize how tool calls display
|
||||
- **Message Renderers**: Custom rendering for assistant messages
|
||||
- **Editor Interceptors**: Handle key events and wrap rendering
|
||||
- **Interactive Prompts**: Select, confirm, input, and multi-select dialogs
|
||||
- **Subagents**: Spawn in-process child Kit instances
|
||||
- **LLM Completion**: Direct model calls via `Complete()`
|
||||
- **Themes**: Register and switch color themes via `RegisterTheme`, `SetTheme`, `ListThemes`
|
||||
- **Custom Events**: Inter-extension communication via `EmitCustomEvent`
|
||||
|
||||
### Extension Examples
|
||||
|
||||
See the `examples/extensions/` directory:
|
||||
|
||||
- `minimal.go` - Clean UI with custom footer
|
||||
- `auto-commit.go` - Auto-commit on shutdown
|
||||
- `bookmark.go` - Bookmark conversations
|
||||
- `branded-output.go` - Branded output rendering
|
||||
- `compact-notify.go` - Notification on compaction
|
||||
- `confirm-destructive.go` - Confirm destructive operations
|
||||
- `context-inject.go` - Inject context into conversations
|
||||
- `custom-editor-demo.go` - Vim-like modal editor
|
||||
- `dev-reload.go` - Development live-reload
|
||||
- `header-footer-demo.go` - Custom headers and footers
|
||||
- `inline-bash.go` - Inline bash execution
|
||||
- `interactive-shell.go` - Interactive shell integration
|
||||
- `kit-kit.go` - Kit-in-Kit (sub-agent spawning)
|
||||
- `lsp-diagnostics.go` - LSP diagnostic integration
|
||||
- `notify.go` - Desktop notifications
|
||||
- `overlay-demo.go` - Modal dialogs
|
||||
- `permission-gate.go` - Permission gating for tools
|
||||
- `pirate.go` - Pirate-themed personality
|
||||
- `plan-mode.go` - Read-only planning mode
|
||||
- `project-rules.go` - Project-specific rules
|
||||
- `prompt-demo.go` - Interactive prompts (select/confirm/input)
|
||||
- `protected-paths.go` - Path protection for sensitive files
|
||||
- `subagent-widget.go` - Multi-agent orchestration with status widget
|
||||
- `subagent-test.go` - Subagent testing utilities
|
||||
- `summarize.go` - Conversation summarization
|
||||
- `go-edit-lint.go` - LSP diagnostic integration with TUI visibility
|
||||
- `tool-logger.go` - Log all tool calls
|
||||
- `neon-theme.go` - Custom theme registration and switching
|
||||
- `tool-renderer-demo.go` - Custom tool call rendering
|
||||
- `widget-status.go` - Persistent status widgets
|
||||
|
||||
### Loading Extensions
|
||||
|
||||
**Auto-discovery** (loads automatically):
|
||||
- `~/.config/kit/extensions/*.go` (global single files)
|
||||
- `~/.config/kit/extensions/*/main.go` (global subdirectory extensions)
|
||||
- `.kit/extensions/*.go` (project-local single files)
|
||||
- `.kit/extensions/*/main.go` (project-local subdirectory extensions)
|
||||
- `~/.local/share/kit/git/` (global git-installed packages)
|
||||
- `.kit/git/` (project-local git-installed packages)
|
||||
|
||||
**Explicit loading**:
|
||||
```bash
|
||||
kit -e path/to/extension.go
|
||||
kit -e ext1.go -e ext2.go # Multiple extensions
|
||||
```
|
||||
|
||||
**Disable auto-load**:
|
||||
```bash
|
||||
kit --no-extensions
|
||||
```
|
||||
|
||||
### Testing Extensions
|
||||
|
||||
Kit provides a testing package to help you write unit tests for your extensions:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/mark3labs/kit/pkg/extensions/test"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
)
|
||||
|
||||
func TestMyExtension(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Emit events and verify behavior
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify the extension printed something
|
||||
test.AssertPrinted(t, harness, "session started")
|
||||
}
|
||||
```
|
||||
|
||||
**Available assertions:**
|
||||
- `AssertBlocked()`, `AssertNotBlocked()` — Verify tool blocking
|
||||
- `AssertWidgetSet()`, `AssertWidgetText()` — Verify widget content
|
||||
- `AssertPrinted()`, `AssertPrintedContains()` — Verify output
|
||||
- `AssertToolRegistered()`, `AssertCommandRegistered()` — Verify registration
|
||||
|
||||
See `examples/extensions/tool-logger_test.go` for a complete example with 14 test cases covering tool calls, input handling, and session lifecycle.
|
||||
|
||||
### Prompt Templates
|
||||
|
||||
Create reusable prompt templates with shell-style argument substitution. Templates are loaded from `~/.kit/prompts/*.md` and `.kit/prompts/*.md`.
|
||||
|
||||
**Example template** (`~/.kit/prompts/review.md`):
|
||||
```markdown
|
||||
---
|
||||
description: Review code for issues
|
||||
---
|
||||
Review the following code for bugs and security issues.
|
||||
Focus on $1 specifically.
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
/review error handling
|
||||
```
|
||||
|
||||
**Argument placeholders:**
|
||||
- `$1`, `$2`, etc. — Individual arguments
|
||||
- `$@` or `$ARGUMENTS` — All arguments
|
||||
- `${@:2}` — Arguments from position 2 onwards
|
||||
- `${@:1:3}` — 3 arguments starting at position 1
|
||||
|
||||
Disable templates with `--no-prompt-templates` or load a specific template with `--prompt-template <name>`.
|
||||
|
||||
## Session Management
|
||||
|
||||
Kit uses a tree-based session model that supports branching and forking conversations.
|
||||
|
||||
### Session Locations
|
||||
|
||||
- Default: `~/.kit/sessions/<cwd-path>/<timestamp>_<id>.jsonl`
|
||||
- Path separators in the working directory are replaced with `--` (e.g., `/home/user/project` becomes `home--user--project`)
|
||||
- Each line is a session entry (messages, tool calls, extension data)
|
||||
- Supports branching from any message to explore alternate paths
|
||||
|
||||
### Session Commands
|
||||
|
||||
```bash
|
||||
# Resume most recent session for current directory
|
||||
kit --continue
|
||||
kit -c
|
||||
|
||||
# Interactive session picker
|
||||
kit --resume
|
||||
kit -r
|
||||
|
||||
# Open specific session file
|
||||
kit --session path/to/session.jsonl
|
||||
kit -s path/to/session.jsonl
|
||||
|
||||
# Ephemeral mode (no file persistence)
|
||||
kit --no-session
|
||||
```
|
||||
|
||||
### Interactive Session Commands
|
||||
|
||||
During an interactive session, use these slash commands:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/name [name]` | Set or display the session's display name |
|
||||
| `/session` | Show session info (path, ID, message count) |
|
||||
| `/resume` | Open the session picker to switch sessions |
|
||||
| `/export [path]` | Export session as JSONL (auto-generates path if omitted) |
|
||||
| `/import <path>` | Import and switch to a session from a JSONL file |
|
||||
| `/share` | Upload session to GitHub Gist and get a shareable viewer URL |
|
||||
| `/tree` | Navigate the session tree |
|
||||
| `/fork` | Branch from an earlier message |
|
||||
| `/new` | Start a fresh session |
|
||||
|
||||
## Go SDK
|
||||
|
||||
Embed Kit in your Go applications:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create Kit instance with default configuration
|
||||
host, err := kit.New(ctx, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer host.Close()
|
||||
|
||||
// Send a prompt
|
||||
response, err := host.Prompt(ctx, "What is 2+2?")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
println(response)
|
||||
}
|
||||
```
|
||||
|
||||
### With Options
|
||||
|
||||
```go
|
||||
host, err := kit.New(ctx, &kit.Options{
|
||||
Model: "ollama/llama3",
|
||||
SystemPrompt: "You are a helpful bot",
|
||||
ConfigFile: "/path/to/config.yml",
|
||||
MaxSteps: 10,
|
||||
Streaming: true,
|
||||
Quiet: true,
|
||||
|
||||
// Session options
|
||||
SessionPath: "./session.jsonl", // Open specific session
|
||||
Continue: true, // Resume most recent session
|
||||
NoSession: true, // Ephemeral mode
|
||||
|
||||
// Tool options
|
||||
ExtraTools: []kit.Tool{...}, // Additional tools alongside defaults
|
||||
|
||||
// Compaction
|
||||
AutoCompact: true, // Auto-compact near context limit
|
||||
|
||||
Debug: true, // Debug logging
|
||||
})
|
||||
```
|
||||
|
||||
### With Callbacks
|
||||
|
||||
```go
|
||||
response, err := host.PromptWithCallbacks(
|
||||
ctx,
|
||||
"List files in current directory",
|
||||
func(name, args string) {
|
||||
// Tool call started
|
||||
println("Calling tool:", name)
|
||||
},
|
||||
func(name, args, result string, isError bool) {
|
||||
// Tool call completed
|
||||
if isError {
|
||||
println("Tool failed:", name)
|
||||
}
|
||||
},
|
||||
func(chunk string) {
|
||||
// Streaming text chunk
|
||||
print(chunk)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### Session Management
|
||||
|
||||
```go
|
||||
// Multi-turn conversations retain context automatically
|
||||
host.Prompt(ctx, "My name is Alice")
|
||||
response, _ := host.Prompt(ctx, "What's my name?")
|
||||
|
||||
// Sessions are persisted automatically to JSONL files.
|
||||
// Access session info:
|
||||
path := host.GetSessionPath()
|
||||
id := host.GetSessionID()
|
||||
|
||||
// Clear conversation history
|
||||
host.ClearSession()
|
||||
```
|
||||
|
||||
Session persistence is configured via `Options`:
|
||||
|
||||
```go
|
||||
host, _ := kit.New(ctx, &kit.Options{
|
||||
SessionPath: "./my-session.jsonl", // Open specific session
|
||||
Continue: true, // Resume most recent session
|
||||
NoSession: true, // Ephemeral mode
|
||||
})
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Subagent Pattern
|
||||
|
||||
Spawn Kit as a subprocess for multi-agent orchestration:
|
||||
|
||||
```bash
|
||||
kit "Analyze codebase" \
|
||||
--json \
|
||||
--no-session \
|
||||
--no-extensions \
|
||||
--quiet \
|
||||
--model anthropic/claude-haiku-3-5-20241022
|
||||
```
|
||||
|
||||
Parse the JSON output:
|
||||
|
||||
```json
|
||||
{
|
||||
"response": "Final assistant response text",
|
||||
"model": "anthropic/claude-haiku-3-5-20241022",
|
||||
"stop_reason": "end_turn",
|
||||
"session_id": "a1b2c3d4e5f6",
|
||||
"usage": {
|
||||
"input_tokens": 1024,
|
||||
"output_tokens": 512,
|
||||
"total_tokens": 1536,
|
||||
"cache_read_tokens": 0,
|
||||
"cache_creation_tokens": 0
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{"type": "text", "data": "..."},
|
||||
{"type": "tool_call", "data": {"name": "...", "args": "..."}},
|
||||
{"type": "tool_result", "data": {"name": "...", "result": "..."}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Testing with tmux
|
||||
|
||||
Test the TUI non-interactively:
|
||||
|
||||
```bash
|
||||
# Start Kit in detached tmux session
|
||||
tmux new-session -d -s kittest -x 120 -y 40 \
|
||||
"kit -e ext.go --no-session 2>kit.log"
|
||||
|
||||
# Wait for startup
|
||||
sleep 3
|
||||
|
||||
# Capture screen
|
||||
tmux capture-pane -t kittest -p
|
||||
|
||||
# Send input
|
||||
tmux send-keys -t kittest '/command' Enter
|
||||
|
||||
# Cleanup
|
||||
tmux kill-session -t kittest
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Build and Test
|
||||
|
||||
```bash
|
||||
# Build
|
||||
go build -o output/kit ./cmd/kit
|
||||
|
||||
# Run tests
|
||||
go test -race ./...
|
||||
|
||||
# Run specific test
|
||||
go test -race ./cmd -run TestScriptExecution
|
||||
|
||||
# Lint
|
||||
go vet ./...
|
||||
|
||||
# Format
|
||||
go fmt ./...
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
cmd/kit/ - CLI entry point (main.go)
|
||||
cmd/ - CLI command implementations (root, auth, models, etc.)
|
||||
pkg/kit/ - Go SDK for embedding Kit
|
||||
internal/app/ - Application orchestrator (agent loop, message store, queue)
|
||||
internal/agent/ - Agent execution and tool dispatch
|
||||
internal/auth/ - OAuth authentication and credential storage
|
||||
internal/acpserver/ - ACP (Agent Client Protocol) server
|
||||
internal/clipboard/ - Cross-platform clipboard operations
|
||||
internal/compaction/ - Conversation compaction and summarization
|
||||
internal/config/ - Configuration management
|
||||
internal/core/ - Built-in tools (bash, read, write, edit, grep, find, ls)
|
||||
internal/extensions/ - Yaegi extension system
|
||||
internal/kitsetup/ - Initial setup wizard
|
||||
internal/message/ - Message content types and structured content blocks
|
||||
internal/models/ - Provider and model management
|
||||
internal/session/ - Session persistence (tree-based JSONL)
|
||||
internal/skills/ - Skill loading and system prompt composition
|
||||
internal/tools/ - MCP tool integration
|
||||
internal/ui/ - Bubble Tea TUI components
|
||||
examples/extensions/ - Example extension files
|
||||
npm/ - NPM package wrapper for distribution
|
||||
```
|
||||
|
||||
## Supported Providers
|
||||
|
||||
- **Anthropic** - Claude models (native, prompt caching, OAuth)
|
||||
- **OpenAI** - GPT models
|
||||
- **Google** - Gemini models
|
||||
- **Ollama** - Local models
|
||||
- **Azure OpenAI** - Azure-hosted OpenAI
|
||||
- **AWS Bedrock** - Bedrock models
|
||||
- **Google Vertex** - Claude on Vertex AI
|
||||
- **OpenRouter** - Multi-provider router
|
||||
- **Vercel AI** - Vercel AI SDK models
|
||||
- **Auto-routed** - Any provider from models.dev database
|
||||
|
||||
### Model String Format
|
||||
|
||||
```bash
|
||||
provider/model # Standard format
|
||||
anthropic/claude-sonnet-latest
|
||||
openai/gpt-4o
|
||||
ollama/llama3
|
||||
google/gemini-2.0-flash-exp
|
||||
```
|
||||
|
||||
### Model Aliases
|
||||
|
||||
```bash
|
||||
# Anthropic Claude
|
||||
claude-opus-latest → claude-opus-4-6
|
||||
claude-sonnet-latest → claude-sonnet-4-6
|
||||
claude-haiku-latest → claude-haiku-4-5
|
||||
claude-4-opus-latest → claude-opus-4-6
|
||||
claude-4-sonnet-latest → claude-sonnet-4-6
|
||||
claude-4-haiku-latest → claude-haiku-4-5
|
||||
claude-3-7-sonnet-latest → claude-3-7-sonnet-20250219
|
||||
claude-3-5-sonnet-latest → claude-3-5-sonnet-20241022
|
||||
claude-3-5-haiku-latest → claude-3-5-haiku-20241022
|
||||
claude-3-opus-latest → claude-3-opus-20240229
|
||||
|
||||
# OpenAI GPT
|
||||
o1-latest → o1
|
||||
o3-latest → o3
|
||||
o4-latest → o4-mini
|
||||
gpt-5-latest → gpt-5.4
|
||||
gpt-5-chat-latest → gpt-5.4
|
||||
gpt-4-latest → gpt-4o
|
||||
gpt-4 → gpt-4o
|
||||
gpt-3.5-latest → gpt-3.5-turbo
|
||||
gpt-3.5 → gpt-3.5-turbo
|
||||
codex-latest → codex-mini-latest
|
||||
|
||||
# Google Gemini
|
||||
gemini-pro-latest → gemini-2.5-pro
|
||||
gemini-flash-latest → gemini-2.5-flash
|
||||
gemini-flash → gemini-2.5-flash
|
||||
gemini-pro → gemini-2.5-pro
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please see the [contribution guide](contribute/contribute.md) for guidelines.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
## Community
|
||||
|
||||
- [Discord](https://discord.gg/RqSS2NQVsY)
|
||||
- [GitHub Issues](https://github.com/mark3labs/kit/issues)
|
||||
- [Documentation](https://github.com/mark3labs/kit/wiki)
|
||||
|
||||
+13
-1
@@ -64,8 +64,20 @@
|
||||
"name": "yaegi",
|
||||
"url": "https://github.com/traefik/yaegi",
|
||||
"branch": "master"
|
||||
},
|
||||
{
|
||||
"type": "git",
|
||||
"name": "acp-go-sdk",
|
||||
"url": "https://github.com/coder/acp-go-sdk",
|
||||
"branch": "main"
|
||||
},
|
||||
{
|
||||
"type": "git",
|
||||
"name": "opencode",
|
||||
"url": "https://github.com/anomalyco/opencode",
|
||||
"branch": "dev"
|
||||
}
|
||||
],
|
||||
"model": "claude-haiku-4-5",
|
||||
"provider": "opencode"
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
acp "github.com/coder/acp-go-sdk"
|
||||
|
||||
"github.com/mark3labs/kit/internal/acpserver"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var acpCmd = &cobra.Command{
|
||||
Use: "acp",
|
||||
Short: "Start Kit as an ACP agent server",
|
||||
Long: `Start Kit as an ACP (Agent Client Protocol) agent server.
|
||||
|
||||
Communicates over stdio (stdin/stdout) using JSON-RPC 2.0 with
|
||||
newline-delimited JSON, compatible with OpenCode and other ACP clients.
|
||||
|
||||
The server exposes Kit's LLM execution, tool system, and session
|
||||
management via the Agent Client Protocol. Sessions are persisted
|
||||
to Kit's standard JSONL session files.`,
|
||||
RunE: runACP,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(acpCmd)
|
||||
}
|
||||
|
||||
func runACP(cmd *cobra.Command, _ []string) error {
|
||||
// Create the ACP agent implementation.
|
||||
agent := acpserver.NewAgent()
|
||||
defer agent.Close()
|
||||
|
||||
// Create the stdio connection. The SDK reads JSON-RPC from stdin and
|
||||
// writes responses to stdout. We wrap stdin with a normalizer that
|
||||
// fills in optional fields the SDK's generated validation requires
|
||||
// (e.g. mcpServers) so clients that omit them still work.
|
||||
conn := acp.NewAgentSideConnection(agent, os.Stdout, newACPNormalizer(os.Stdin))
|
||||
|
||||
// Wire the connection back to the agent so it can send session updates.
|
||||
agent.SetAgentConnection(conn)
|
||||
|
||||
// Enable debug logging to stderr if requested.
|
||||
if debugMode {
|
||||
conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
})))
|
||||
}
|
||||
|
||||
// Wait for either the client to disconnect or a signal.
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case <-conn.Done():
|
||||
fmt.Fprintln(os.Stderr, "kit: ACP client disconnected")
|
||||
case sig := <-sigCh:
|
||||
fmt.Fprintf(os.Stderr, "kit: received %s, shutting down\n", sig)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// acpNormalizer wraps an io.Reader carrying newline-delimited JSON-RPC and
|
||||
// patches incoming messages so that fields the SDK validates as required —
|
||||
// but that some clients (e.g. Zed) omit — are defaulted. This avoids
|
||||
// InvalidParams errors without forking the SDK.
|
||||
type acpNormalizer struct {
|
||||
scanner *bufio.Scanner
|
||||
buf bytes.Buffer // leftover bytes from the last normalized line
|
||||
}
|
||||
|
||||
func newACPNormalizer(r io.Reader) *acpNormalizer {
|
||||
const maxMsg = 10 * 1024 * 1024 // 10 MB, matches SDK buffer
|
||||
s := bufio.NewScanner(r)
|
||||
s.Buffer(make([]byte, 0, 1024*1024), maxMsg)
|
||||
return &acpNormalizer{scanner: s}
|
||||
}
|
||||
|
||||
// Read satisfies io.Reader. It feeds one normalized JSON line (plus newline)
|
||||
// per underlying scan, buffering across short caller reads.
|
||||
func (n *acpNormalizer) Read(p []byte) (int, error) {
|
||||
// Drain any leftover bytes from the previous line first.
|
||||
if n.buf.Len() > 0 {
|
||||
return n.buf.Read(p)
|
||||
}
|
||||
|
||||
if !n.scanner.Scan() {
|
||||
if err := n.scanner.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
line := n.scanner.Bytes()
|
||||
normalized := normalizeACPLine(line)
|
||||
n.buf.Write(normalized)
|
||||
n.buf.WriteByte('\n')
|
||||
return n.buf.Read(p)
|
||||
}
|
||||
|
||||
// normalizeACPLine ensures session/new and session/load params contain an
|
||||
// mcpServers array. Returns the original line unchanged for all other methods.
|
||||
func normalizeACPLine(line []byte) []byte {
|
||||
// Quick check: if it already contains mcpServers, nothing to do.
|
||||
if bytes.Contains(line, []byte(`"mcpServers"`)) {
|
||||
return line
|
||||
}
|
||||
|
||||
// Only bother parsing if the method could be session/new or session/load.
|
||||
if !bytes.Contains(line, []byte(`"session/new"`)) &&
|
||||
!bytes.Contains(line, []byte(`"session/load"`)) {
|
||||
return line
|
||||
}
|
||||
|
||||
var msg struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
return line
|
||||
}
|
||||
if msg.Method != "session/new" && msg.Method != "session/load" {
|
||||
return line
|
||||
}
|
||||
|
||||
// Patch params to include mcpServers: [].
|
||||
var params map[string]json.RawMessage
|
||||
if err := json.Unmarshal(msg.Params, ¶ms); err != nil {
|
||||
return line
|
||||
}
|
||||
if _, ok := params["mcpServers"]; ok {
|
||||
return line
|
||||
}
|
||||
params["mcpServers"] = json.RawMessage(`[]`)
|
||||
|
||||
patched, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
msg.Params = patched
|
||||
|
||||
out, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
return out
|
||||
}
|
||||
+25
-21
@@ -1,11 +1,11 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/mark3labs/kit/internal/auth"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -171,14 +171,15 @@ func loginAnthropic() error {
|
||||
|
||||
// Check if already authenticated
|
||||
if hasAuth, err := cm.HasAnthropicCredentials(); err == nil && hasAuth {
|
||||
fmt.Print("You are already authenticated with Anthropic. Do you want to re-authenticate? (y/N): ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
if response != "y" && response != "yes" {
|
||||
var reauth bool
|
||||
err := huh.NewConfirm().
|
||||
Title("You are already authenticated with Anthropic").
|
||||
Description("Do you want to re-authenticate?").
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Value(&reauth).
|
||||
Run()
|
||||
if err != nil || !reauth {
|
||||
fmt.Println("Authentication cancelled.")
|
||||
return nil
|
||||
}
|
||||
@@ -204,10 +205,13 @@ func loginAnthropic() error {
|
||||
|
||||
// Wait for user to complete OAuth flow
|
||||
fmt.Println("After authorizing the application, you'll receive an authorization code.")
|
||||
fmt.Print("Please enter the authorization code: ")
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
code, err := reader.ReadString('\n')
|
||||
var code string
|
||||
err = huh.NewInput().
|
||||
Title("Authorization code").
|
||||
Description("Paste the code from your browser").
|
||||
Value(&code).
|
||||
Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read authorization code: %w", err)
|
||||
}
|
||||
@@ -255,15 +259,15 @@ func logoutAnthropic() error {
|
||||
}
|
||||
|
||||
// Confirm logout
|
||||
fmt.Print("Are you sure you want to remove your Anthropic credentials? (y/N): ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
if response != "y" && response != "yes" {
|
||||
var confirm bool
|
||||
err = huh.NewConfirm().
|
||||
Title("Remove Anthropic credentials").
|
||||
Description("Are you sure you want to remove your stored credentials?").
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Value(&confirm).
|
||||
Run()
|
||||
if err != nil || !confirm {
|
||||
fmt.Println("Logout cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
installLocalFlag bool
|
||||
installUpdateFlag bool
|
||||
installUninstallFlag bool
|
||||
installAllFlag bool
|
||||
)
|
||||
|
||||
var installCmd = &cobra.Command{
|
||||
Use: "install <git-url>",
|
||||
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.
|
||||
|
||||
When a repo contains multiple extensions, an interactive multi-select is shown
|
||||
so you can choose which to install. Use --all to skip selection and install everything.
|
||||
|
||||
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
|
||||
|
||||
Examples:
|
||||
kit install github.com/user/my-extension
|
||||
kit install github.com/user/my-extension@v1.0.0
|
||||
kit install github.com/user/my-extension --local
|
||||
kit install github.com/user/collection --all`,
|
||||
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().BoolVar(&installAllFlag, "all", false, "Install all extensions without prompting")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Preview extensions to decide if we need multi-select
|
||||
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())
|
||||
}
|
||||
|
||||
scopeStr := "globally"
|
||||
if scope == extensions.ScopeProject {
|
||||
scopeStr = "locally in .kit/git/"
|
||||
}
|
||||
|
||||
// Single extension or --all flag: install everything directly
|
||||
if len(previews) == 1 || installAllFlag {
|
||||
if err := installer.Install(source, scope); err != nil {
|
||||
return fmt.Errorf("install failed: %w", err)
|
||||
}
|
||||
|
||||
if source.Pinned {
|
||||
fmt.Printf("Installed %s at %s %s\n", source.String(), source.Ref, scopeStr)
|
||||
} else {
|
||||
fmt.Printf("Installed %d extension(s) from %s %s\n", len(previews), source.String(), scopeStr)
|
||||
}
|
||||
|
||||
log.Info("extension installed", "source", source.String(), "scope", scope)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Multiple extensions: show interactive selection
|
||||
includePaths, err := multiSelectForInstall(previews)
|
||||
if err != nil {
|
||||
if err.Error() == "selection cancelled" || err.Error() == "no extensions selected" {
|
||||
fmt.Println("Install cancelled.")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("selection failed: %w", err)
|
||||
}
|
||||
|
||||
if err := installer.InstallWithInclude(source, scope, includePaths); err != nil {
|
||||
return fmt.Errorf("install failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Installed %d extension(s) from %s %s\n", len(includePaths), source.String(), scopeStr)
|
||||
for _, path := range includePaths {
|
||||
fmt.Printf(" - %s\n", path)
|
||||
}
|
||||
|
||||
log.Info("extension installed", "source", source.String(), "scope", scope, "selected", len(includePaths))
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
)
|
||||
|
||||
// multiSelectForInstall runs a multi-select prompt for extension selection.
|
||||
// Returns the selected extension paths, or an error if cancelled.
|
||||
func multiSelectForInstall(previews []extensions.ExtensionPreview) ([]string, error) {
|
||||
if len(previews) == 0 {
|
||||
return nil, fmt.Errorf("no extensions to select")
|
||||
}
|
||||
|
||||
// Non-interactive: select all
|
||||
if !isInteractive() {
|
||||
log.Info("Non-interactive mode, selecting all extensions")
|
||||
paths := make([]string, len(previews))
|
||||
for i, p := range previews {
|
||||
paths[i] = p.Path
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// Single extension: just return it
|
||||
if len(previews) == 1 {
|
||||
return []string{previews[0].Path}, nil
|
||||
}
|
||||
|
||||
// Build options for huh MultiSelect
|
||||
options := make([]huh.Option[string], len(previews))
|
||||
for i, p := range previews {
|
||||
label := fmt.Sprintf("%s %s", p.Name, p.Path)
|
||||
options[i] = huh.NewOption(label, p.Path).Selected(true)
|
||||
}
|
||||
|
||||
var selected []string
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewMultiSelect[string]().
|
||||
Title("Select extensions to install").
|
||||
Options(options...).
|
||||
Value(&selected),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return nil, fmt.Errorf("selection cancelled")
|
||||
}
|
||||
|
||||
if len(selected) == 0 {
|
||||
return nil, fmt.Errorf("no extensions selected")
|
||||
}
|
||||
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
// isInteractive checks if the terminal is interactive.
|
||||
func isInteractive() bool {
|
||||
fi, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
+691
-69
@@ -2,7 +2,9 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -13,6 +15,8 @@ import (
|
||||
"github.com/mark3labs/kit/internal/app"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
"github.com/mark3labs/kit/internal/prompts"
|
||||
"github.com/mark3labs/kit/internal/ui"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -27,8 +31,9 @@ var (
|
||||
providerURL string
|
||||
providerAPIKey string
|
||||
debugMode bool
|
||||
promptFlag string
|
||||
positionalPrompt string // set by processPositionalArgs from CLI positional args
|
||||
quietFlag bool
|
||||
jsonFlag bool
|
||||
noExitFlag bool
|
||||
maxSteps int
|
||||
streamFlag bool // Enable streaming output
|
||||
@@ -49,6 +54,7 @@ var (
|
||||
topP float32
|
||||
topK int32
|
||||
stopSequences []string
|
||||
thinkingLevel string
|
||||
|
||||
// Ollama-specific parameters
|
||||
numGPU int32
|
||||
@@ -60,6 +66,15 @@ var (
|
||||
|
||||
// TLS configuration
|
||||
tlsSkipVerify bool
|
||||
|
||||
// Prompt templates
|
||||
promptTemplatePaths []string
|
||||
noPromptTemplates bool
|
||||
|
||||
// Preference restoration flags — set in RunE after cobra parses, used
|
||||
// in runNormalMode to decide whether to apply saved preferences.
|
||||
modelFlagChanged bool
|
||||
thinkingFlagChanged bool
|
||||
)
|
||||
|
||||
// kitUIAdapter adapts *kit.Kit to ui.AgentInterface so the CLI setup layer
|
||||
@@ -98,10 +113,27 @@ func (a *kitUIAdapter) GetExtensionToolCount() int {
|
||||
// an interface to interact with various AI models through a unified interface
|
||||
// with support for MCP servers and tool integration.
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "kit",
|
||||
Use: "kit [@file...] [prompt]",
|
||||
Short: "Chat with AI models through a unified interface",
|
||||
Long: `KIT (Knowledge Inference Tool) — A lightweight AI agent for coding`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Parse positional args: @-prefixed args are file attachments,
|
||||
// remaining args form the prompt (like Pi: kit @code.ts "Review this").
|
||||
if len(args) > 0 {
|
||||
processPositionalArgs(args)
|
||||
}
|
||||
// Record whether --model / --thinking-level were explicitly set by the
|
||||
// user so that runNormalMode can fall back to saved preferences when
|
||||
// they weren't. Must be captured here (after cobra parses) and before
|
||||
// runKit because rootCmd can't be referenced inside runNormalMode
|
||||
// without creating an initialization cycle.
|
||||
if f := cmd.PersistentFlags().Lookup("model"); f != nil {
|
||||
modelFlagChanged = f.Changed
|
||||
}
|
||||
if f := cmd.PersistentFlags().Lookup("thinking-level"); f != nil {
|
||||
thinkingFlagChanged = f.Changed
|
||||
}
|
||||
return runKit(context.Background())
|
||||
},
|
||||
}
|
||||
@@ -131,24 +163,58 @@ func LoadConfigWithEnvSubstitution(configPath string) error {
|
||||
return kit.LoadConfigWithEnvSubstitution(configPath)
|
||||
}
|
||||
|
||||
func configToUiTheme(theme config.Theme) ui.Theme {
|
||||
// adaptiveOrDefault converts a config.AdaptiveColor to a resolved color.Color,
|
||||
// falling back to fallback when both Light and Dark are empty.
|
||||
func adaptiveOrDefault(ac config.AdaptiveColor, fallback color.Color) color.Color {
|
||||
if ac.Light == "" && ac.Dark == "" {
|
||||
return fallback
|
||||
}
|
||||
return ui.AdaptiveColor(ac.Light, ac.Dark)
|
||||
}
|
||||
|
||||
func configToUiTheme(cfg config.Theme) ui.Theme {
|
||||
def := ui.DefaultTheme()
|
||||
return ui.Theme{
|
||||
Primary: ui.AdaptiveColor(theme.Primary.Light, theme.Primary.Dark),
|
||||
Secondary: ui.AdaptiveColor(theme.Secondary.Light, theme.Secondary.Dark),
|
||||
Success: ui.AdaptiveColor(theme.Success.Light, theme.Success.Dark),
|
||||
Warning: ui.AdaptiveColor(theme.Warning.Light, theme.Warning.Dark),
|
||||
Error: ui.AdaptiveColor(theme.Error.Light, theme.Error.Dark),
|
||||
Info: ui.AdaptiveColor(theme.Info.Light, theme.Info.Dark),
|
||||
Text: ui.AdaptiveColor(theme.Text.Light, theme.Text.Dark),
|
||||
Muted: ui.AdaptiveColor(theme.Muted.Light, theme.Muted.Dark),
|
||||
VeryMuted: ui.AdaptiveColor(theme.VeryMuted.Light, theme.VeryMuted.Dark),
|
||||
Background: ui.AdaptiveColor(theme.Background.Light, theme.Background.Dark),
|
||||
Border: ui.AdaptiveColor(theme.Border.Light, theme.Border.Dark),
|
||||
MutedBorder: ui.AdaptiveColor(theme.MutedBorder.Light, theme.MutedBorder.Dark),
|
||||
System: ui.AdaptiveColor(theme.System.Light, theme.System.Dark),
|
||||
Tool: ui.AdaptiveColor(theme.Tool.Light, theme.Tool.Dark),
|
||||
Accent: ui.AdaptiveColor(theme.Accent.Light, theme.Accent.Dark),
|
||||
Highlight: ui.AdaptiveColor(theme.Highlight.Light, theme.Highlight.Dark),
|
||||
Primary: adaptiveOrDefault(cfg.Primary, def.Primary),
|
||||
Secondary: adaptiveOrDefault(cfg.Secondary, def.Secondary),
|
||||
Success: adaptiveOrDefault(cfg.Success, def.Success),
|
||||
Warning: adaptiveOrDefault(cfg.Warning, def.Warning),
|
||||
Error: adaptiveOrDefault(cfg.Error, def.Error),
|
||||
Info: adaptiveOrDefault(cfg.Info, def.Info),
|
||||
Text: adaptiveOrDefault(cfg.Text, def.Text),
|
||||
Muted: adaptiveOrDefault(cfg.Muted, def.Muted),
|
||||
VeryMuted: adaptiveOrDefault(cfg.VeryMuted, def.VeryMuted),
|
||||
Background: adaptiveOrDefault(cfg.Background, def.Background),
|
||||
Border: adaptiveOrDefault(cfg.Border, def.Border),
|
||||
MutedBorder: adaptiveOrDefault(cfg.MutedBorder, def.MutedBorder),
|
||||
System: adaptiveOrDefault(cfg.System, def.System),
|
||||
Tool: adaptiveOrDefault(cfg.Tool, def.Tool),
|
||||
Accent: adaptiveOrDefault(cfg.Accent, def.Accent),
|
||||
Highlight: adaptiveOrDefault(cfg.Highlight, def.Highlight),
|
||||
|
||||
DiffInsertBg: adaptiveOrDefault(cfg.DiffInsertBg, def.DiffInsertBg),
|
||||
DiffDeleteBg: adaptiveOrDefault(cfg.DiffDeleteBg, def.DiffDeleteBg),
|
||||
DiffEqualBg: adaptiveOrDefault(cfg.DiffEqualBg, def.DiffEqualBg),
|
||||
DiffMissingBg: adaptiveOrDefault(cfg.DiffMissingBg, def.DiffMissingBg),
|
||||
|
||||
CodeBg: adaptiveOrDefault(cfg.CodeBg, def.CodeBg),
|
||||
GutterBg: adaptiveOrDefault(cfg.GutterBg, def.GutterBg),
|
||||
WriteBg: adaptiveOrDefault(cfg.WriteBg, def.WriteBg),
|
||||
|
||||
Markdown: ui.MarkdownThemeColors{
|
||||
Text: adaptiveOrDefault(cfg.Markdown.Text, def.Markdown.Text),
|
||||
Muted: adaptiveOrDefault(cfg.Markdown.Muted, def.Markdown.Muted),
|
||||
Heading: adaptiveOrDefault(cfg.Markdown.Heading, def.Markdown.Heading),
|
||||
Emph: adaptiveOrDefault(cfg.Markdown.Emph, def.Markdown.Emph),
|
||||
Strong: adaptiveOrDefault(cfg.Markdown.Strong, def.Markdown.Strong),
|
||||
Link: adaptiveOrDefault(cfg.Markdown.Link, def.Markdown.Link),
|
||||
Code: adaptiveOrDefault(cfg.Markdown.Code, def.Markdown.Code),
|
||||
Error: adaptiveOrDefault(cfg.Markdown.Error, def.Markdown.Error),
|
||||
Keyword: adaptiveOrDefault(cfg.Markdown.Keyword, def.Markdown.Keyword),
|
||||
String: adaptiveOrDefault(cfg.Markdown.String, def.Markdown.String),
|
||||
Number: adaptiveOrDefault(cfg.Markdown.Number, def.Markdown.Number),
|
||||
Comment: adaptiveOrDefault(cfg.Markdown.Comment, def.Markdown.Comment),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +253,9 @@ func init() {
|
||||
if err == nil && viper.InConfig("theme") {
|
||||
uiTheme := configToUiTheme(theme)
|
||||
ui.SetTheme(uiTheme)
|
||||
} else if pref := ui.LoadThemePreference(); pref != "" {
|
||||
// No explicit theme in config — fall back to persisted preference.
|
||||
_ = ui.ApplyThemeWithoutSave(pref)
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().
|
||||
@@ -199,12 +268,13 @@ func init() {
|
||||
"model to use (format: provider/model)")
|
||||
rootCmd.PersistentFlags().
|
||||
BoolVar(&debugMode, "debug", false, "enable debug logging")
|
||||
|
||||
rootCmd.PersistentFlags().
|
||||
StringVarP(&promptFlag, "prompt", "p", "", "run in non-interactive mode with the given prompt")
|
||||
BoolVar(&quietFlag, "quiet", false, "suppress all output (non-interactive mode only)")
|
||||
rootCmd.PersistentFlags().
|
||||
BoolVar(&quietFlag, "quiet", false, "suppress all output (only works with --prompt)")
|
||||
BoolVar(&jsonFlag, "json", false, "output response as JSON (non-interactive mode only)")
|
||||
rootCmd.PersistentFlags().
|
||||
BoolVar(&noExitFlag, "no-exit", false, "prevent non-interactive mode from exiting, show input prompt instead")
|
||||
BoolVar(&noExitFlag, "no-exit", false, "enter interactive mode after non-interactive prompt completes")
|
||||
rootCmd.PersistentFlags().
|
||||
IntVar(&maxSteps, "max-steps", 0, "maximum number of agent steps (0 for unlimited)")
|
||||
rootCmd.PersistentFlags().
|
||||
@@ -231,12 +301,17 @@ func init() {
|
||||
flags.StringVar(&providerAPIKey, "provider-api-key", "", "API key for the provider (applies to OpenAI, Anthropic, and Google)")
|
||||
flags.BoolVar(&tlsSkipVerify, "tls-skip-verify", false, "skip TLS certificate verification (WARNING: insecure, use only for self-signed certificates)")
|
||||
|
||||
// Prompt template flags
|
||||
flags.StringArrayVar(&promptTemplatePaths, "prompt-template", nil, "load prompt template file or directory (repeatable)")
|
||||
flags.BoolVar(&noPromptTemplates, "no-prompt-templates", false, "disable prompt template discovery")
|
||||
|
||||
// Model generation parameters
|
||||
flags.IntVar(&maxTokens, "max-tokens", 4096, "maximum number of tokens in the response")
|
||||
flags.Float32Var(&temperature, "temperature", 0.7, "controls randomness in responses (0.0-1.0)")
|
||||
flags.Float32Var(&topP, "top-p", 0.95, "controls diversity via nucleus sampling (0.0-1.0)")
|
||||
flags.Int32Var(&topK, "top-k", 40, "controls diversity by limiting top K tokens to sample from")
|
||||
flags.StringSliceVar(&stopSequences, "stop-sequences", nil, "custom stop sequences (comma-separated)")
|
||||
flags.StringVar(&thinkingLevel, "thinking-level", "off", "extended thinking level: off, minimal, low, medium, high")
|
||||
|
||||
// Ollama-specific parameters
|
||||
flags.Int32Var(&numGPU, "num-gpu-layers", -1, "number of model layers to offload to GPU for Ollama models (-1 for auto-detect)")
|
||||
@@ -247,7 +322,6 @@ func init() {
|
||||
_ = viper.BindPFlag("system-prompt", rootCmd.PersistentFlags().Lookup("system-prompt"))
|
||||
_ = viper.BindPFlag("model", rootCmd.PersistentFlags().Lookup("model"))
|
||||
_ = viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug"))
|
||||
_ = viper.BindPFlag("prompt", rootCmd.PersistentFlags().Lookup("prompt"))
|
||||
_ = viper.BindPFlag("max-steps", rootCmd.PersistentFlags().Lookup("max-steps"))
|
||||
_ = viper.BindPFlag("stream", rootCmd.PersistentFlags().Lookup("stream"))
|
||||
_ = viper.BindPFlag("compact", rootCmd.PersistentFlags().Lookup("compact"))
|
||||
@@ -260,11 +334,14 @@ func init() {
|
||||
_ = viper.BindPFlag("top-p", rootCmd.PersistentFlags().Lookup("top-p"))
|
||||
_ = viper.BindPFlag("top-k", rootCmd.PersistentFlags().Lookup("top-k"))
|
||||
_ = viper.BindPFlag("stop-sequences", rootCmd.PersistentFlags().Lookup("stop-sequences"))
|
||||
_ = viper.BindPFlag("thinking-level", rootCmd.PersistentFlags().Lookup("thinking-level"))
|
||||
_ = viper.BindPFlag("num-gpu-layers", rootCmd.PersistentFlags().Lookup("num-gpu-layers"))
|
||||
_ = viper.BindPFlag("main-gpu", rootCmd.PersistentFlags().Lookup("main-gpu"))
|
||||
_ = viper.BindPFlag("tls-skip-verify", rootCmd.PersistentFlags().Lookup("tls-skip-verify"))
|
||||
_ = viper.BindPFlag("no-extensions", rootCmd.PersistentFlags().Lookup("no-extensions"))
|
||||
_ = viper.BindPFlag("extension", rootCmd.PersistentFlags().Lookup("extension"))
|
||||
_ = viper.BindPFlag("prompt-template", rootCmd.PersistentFlags().Lookup("prompt-template"))
|
||||
_ = viper.BindPFlag("no-prompt-templates", rootCmd.PersistentFlags().Lookup("no-prompt-templates"))
|
||||
|
||||
// Defaults are already set in flag definitions, no need to duplicate in viper
|
||||
|
||||
@@ -272,6 +349,62 @@ func init() {
|
||||
rootCmd.AddCommand(authCmd)
|
||||
}
|
||||
|
||||
// processPositionalArgs separates positional CLI arguments into @file
|
||||
// attachments and prompt text. File content is read and prepended to
|
||||
// positionalPrompt so the agent receives it. Positional args are the primary
|
||||
// way to run non-interactive mode:
|
||||
//
|
||||
// kit "Explain this codebase"
|
||||
// kit @code.ts @test.ts "Review these files"
|
||||
func processPositionalArgs(args []string) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
cwd = "."
|
||||
}
|
||||
|
||||
var fileTokens []string
|
||||
var promptParts []string
|
||||
|
||||
for _, arg := range args {
|
||||
if strings.HasPrefix(arg, "@") && len(arg) > 1 {
|
||||
fileTokens = append(fileTokens, arg)
|
||||
} else {
|
||||
promptParts = append(promptParts, arg)
|
||||
}
|
||||
}
|
||||
|
||||
// Build file content prefix from @file arguments.
|
||||
var fileContent strings.Builder
|
||||
for _, token := range fileTokens {
|
||||
expanded := ui.ProcessFileAttachments(token, cwd)
|
||||
if expanded != token {
|
||||
// File was resolved — add it.
|
||||
fileContent.WriteString(expanded)
|
||||
fileContent.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Combine: positional prompt text is appended to any existing --prompt
|
||||
// value (for backward compat with subprocess invocations).
|
||||
if len(promptParts) > 0 {
|
||||
extra := strings.Join(promptParts, " ")
|
||||
if positionalPrompt != "" {
|
||||
positionalPrompt = positionalPrompt + " " + extra
|
||||
} else {
|
||||
positionalPrompt = extra
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend file content to the prompt.
|
||||
if fileContent.Len() > 0 {
|
||||
if positionalPrompt == "" {
|
||||
positionalPrompt = strings.TrimSpace(fileContent.String())
|
||||
} else {
|
||||
positionalPrompt = strings.TrimSpace(fileContent.String()) + "\n\n" + positionalPrompt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runKit(ctx context.Context) error {
|
||||
return runNormalMode(ctx)
|
||||
}
|
||||
@@ -291,13 +424,19 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand {
|
||||
if len(name) > 0 && name[0] != '/' {
|
||||
name = "/" + name
|
||||
}
|
||||
cmds = append(cmds, ui.ExtensionCommand{
|
||||
ec := ui.ExtensionCommand{
|
||||
Name: name,
|
||||
Description: d.Description,
|
||||
Execute: func(args string) (string, error) {
|
||||
return d.Execute(args, k.GetExtensionContext())
|
||||
},
|
||||
})
|
||||
}
|
||||
if d.Complete != nil {
|
||||
ec.Complete = func(prefix string) []string {
|
||||
return d.Complete(prefix, k.GetExtensionContext())
|
||||
}
|
||||
}
|
||||
cmds = append(cmds, ec)
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
@@ -411,6 +550,27 @@ func editorInterceptorProviderForUI(k *kit.Kit) func() *ui.EditorInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
// uiVisibilityProviderForUI returns a function that converts extension UI
|
||||
// visibility overrides to a *ui.UIVisibility for the TUI. Returns nil if
|
||||
// extensions are disabled — the UI treats nil as "show everything".
|
||||
func uiVisibilityProviderForUI(k *kit.Kit) func() *ui.UIVisibility {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func() *ui.UIVisibility {
|
||||
v := k.GetExtensionUIVisibility()
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return &ui.UIVisibility{
|
||||
HideStartupMessage: v.HideStartupMessage,
|
||||
HideStatusBar: v.HideStatusBar,
|
||||
HideSeparator: v.HideSeparator,
|
||||
HideInputHint: v.HideInputHint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// footerProviderForUI returns a function that converts the extension footer
|
||||
// to a *ui.WidgetData for the TUI. Returns nil if extensions are disabled,
|
||||
// which is safe — the UI treats a nil GetFooter as "no footer".
|
||||
@@ -432,13 +592,74 @@ func footerProviderForUI(k *kit.Kit) func() *ui.WidgetData {
|
||||
}
|
||||
}
|
||||
|
||||
// statusBarProviderForUI returns a function that fetches extension status bar
|
||||
// entries and converts them to ui.StatusBarEntryData for the TUI. Returns nil
|
||||
// if extensions are disabled, which is safe — the TUI treats a nil
|
||||
// GetStatusBarEntries as "no extension entries".
|
||||
func statusBarProviderForUI(k *kit.Kit) func() []ui.StatusBarEntryData {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return func() []ui.StatusBarEntryData {
|
||||
entries := k.GetExtensionStatusEntries()
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]ui.StatusBarEntryData, len(entries))
|
||||
for i, e := range entries {
|
||||
result[i] = ui.StatusBarEntryData{
|
||||
Key: e.Key,
|
||||
Text: e.Text,
|
||||
Priority: e.Priority,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// beforeForkProviderForUI returns a callback that emits a BeforeFork event
|
||||
// and returns (cancelled, reason). Returns nil if extensions are disabled —
|
||||
// the UI treats nil as "no hook".
|
||||
func beforeForkProviderForUI(k *kit.Kit) func(string, bool, string) (bool, string) {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return k.EmitBeforeFork
|
||||
}
|
||||
|
||||
// beforeSessionSwitchProviderForUI returns a callback that emits a
|
||||
// BeforeSessionSwitch event and returns (cancelled, reason). Returns nil
|
||||
// if extensions are disabled — the UI treats nil as "no hook".
|
||||
func beforeSessionSwitchProviderForUI(k *kit.Kit) func(string) (bool, string) {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return k.EmitBeforeSessionSwitch
|
||||
}
|
||||
|
||||
// globalShortcutsProviderForUI returns a callback that queries the extension
|
||||
// runner for registered keyboard shortcuts. Returns nil if extensions are
|
||||
// disabled — the UI treats nil as "no shortcuts".
|
||||
func globalShortcutsProviderForUI(k *kit.Kit) func() map[string]func() {
|
||||
if !k.HasExtensions() {
|
||||
return nil
|
||||
}
|
||||
return k.GetExtensionShortcuts
|
||||
}
|
||||
|
||||
func runNormalMode(ctx context.Context) error {
|
||||
// Validate flag combinations
|
||||
if quietFlag && promptFlag == "" {
|
||||
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
|
||||
if quietFlag && positionalPrompt == "" {
|
||||
return fmt.Errorf("--quiet requires a prompt (e.g. kit \"your question\" --quiet)")
|
||||
}
|
||||
if noExitFlag && promptFlag == "" {
|
||||
return fmt.Errorf("--no-exit flag can only be used with --prompt/-p")
|
||||
if jsonFlag && positionalPrompt == "" {
|
||||
return fmt.Errorf("--json requires a prompt (e.g. kit \"your question\" --json)")
|
||||
}
|
||||
if jsonFlag && noExitFlag {
|
||||
return fmt.Errorf("--json and --no-exit flags cannot be used together")
|
||||
}
|
||||
if noExitFlag && positionalPrompt == "" {
|
||||
return fmt.Errorf("--no-exit requires a prompt (e.g. kit \"your question\" --no-exit)")
|
||||
}
|
||||
|
||||
// Set up logging
|
||||
@@ -452,6 +673,22 @@ func runNormalMode(ctx context.Context) error {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
}
|
||||
|
||||
// Restore persisted model preference when no explicit --model flag or
|
||||
// config file model is set. Precedence: CLI flag > config file > saved
|
||||
// preference > built-in default. This mirrors how themes are persisted.
|
||||
if !modelFlagChanged && !viper.InConfig("model") {
|
||||
if pref := ui.LoadModelPreference(); pref != "" {
|
||||
viper.Set("model", pref)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore persisted thinking level preference (same precedence chain).
|
||||
if !thinkingFlagChanged && !viper.InConfig("thinking-level") {
|
||||
if pref := ui.LoadThinkingLevelPreference(); pref != "" {
|
||||
viper.Set("thinking-level", pref)
|
||||
}
|
||||
}
|
||||
|
||||
// Load MCP configuration.
|
||||
mcpConfig, err := config.LoadAndValidateConfig()
|
||||
if err != nil {
|
||||
@@ -487,11 +724,16 @@ func runNormalMode(ctx context.Context) error {
|
||||
},
|
||||
}
|
||||
if resumeFlag {
|
||||
// TODO: TUI session picker.
|
||||
sessions, _ := kit.ListSessions("")
|
||||
if len(sessions) > 0 {
|
||||
kitOpts.SessionPath = sessions[0].Path
|
||||
// When --resume is combined with interactive mode, the TUI session
|
||||
// picker will be shown at startup. For non-interactive mode, fall
|
||||
// back to auto-selecting the most recent session.
|
||||
if positionalPrompt != "" {
|
||||
sessions, _ := kit.ListSessions("")
|
||||
if len(sessions) > 0 {
|
||||
kitOpts.SessionPath = sessions[0].Path
|
||||
}
|
||||
}
|
||||
// Interactive mode: ShowSessionPicker is set below on AppModelOptions.
|
||||
}
|
||||
|
||||
kitInstance, err := kit.New(ctx, kitOpts)
|
||||
@@ -505,7 +747,7 @@ func runNormalMode(ctx context.Context) error {
|
||||
|
||||
// Create CLI for non-interactive mode only.
|
||||
var cli *ui.CLI
|
||||
if promptFlag != "" {
|
||||
if positionalPrompt != "" {
|
||||
cli, err = SetupCLIForNonInteractive(kitInstance)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup CLI: %v", err)
|
||||
@@ -550,14 +792,16 @@ func runNormalMode(ctx context.Context) error {
|
||||
if kitInstance.HasExtensions() {
|
||||
cwd, _ := os.Getwd()
|
||||
kitInstance.SetExtensionContext(extensions.Context{
|
||||
CWD: cwd,
|
||||
Model: modelName,
|
||||
Interactive: promptFlag == "",
|
||||
Print: func(text string) { appInstance.PrintFromExtension("", text) },
|
||||
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
|
||||
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
|
||||
PrintBlock: appInstance.PrintBlockFromExtension,
|
||||
SendMessage: func(text string) { appInstance.Run(text) },
|
||||
CWD: cwd,
|
||||
Model: modelName,
|
||||
Interactive: positionalPrompt == "",
|
||||
Print: func(text string) { appInstance.PrintFromExtension("", text) },
|
||||
PrintInfo: func(text string) { appInstance.PrintFromExtension("info", text) },
|
||||
PrintError: func(text string) { appInstance.PrintFromExtension("error", text) },
|
||||
PrintBlock: appInstance.PrintBlockFromExtension,
|
||||
SendMessage: func(text string) { appInstance.Run(text) },
|
||||
CancelAndSend: func(text string) { appInstance.Steer(text) },
|
||||
Exit: func() { appInstance.QuitFromExtension() },
|
||||
SetWidget: func(config extensions.WidgetConfig) {
|
||||
kitInstance.SetExtensionWidget(config)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
@@ -629,6 +873,19 @@ func runNormalMode(ctx context.Context) error {
|
||||
}
|
||||
return extensions.PromptInputResult{Value: resp.Value}
|
||||
},
|
||||
SetUIVisibility: func(v extensions.UIVisibility) {
|
||||
kitInstance.SetExtensionUIVisibility(v)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
GetContextStats: func() extensions.ContextStats {
|
||||
s := kitInstance.GetContextStats()
|
||||
return extensions.ContextStats{
|
||||
EstimatedTokens: s.EstimatedTokens,
|
||||
ContextLimit: s.ContextLimit,
|
||||
UsagePercent: s.UsagePercent,
|
||||
MessageCount: s.MessageCount,
|
||||
}
|
||||
},
|
||||
SetEditor: func(config extensions.EditorConfig) {
|
||||
kitInstance.SetExtensionEditor(config)
|
||||
// Use a goroutine for NotifyWidgetUpdate because this may be
|
||||
@@ -641,6 +898,117 @@ func runNormalMode(ctx context.Context) error {
|
||||
kitInstance.ResetExtensionEditor()
|
||||
go appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
GetMessages: func() []extensions.SessionMessage {
|
||||
return kitInstance.GetSessionMessages()
|
||||
},
|
||||
GetSessionPath: func() string {
|
||||
return kitInstance.GetSessionFilePath()
|
||||
},
|
||||
AppendEntry: func(entryType string, data string) (string, error) {
|
||||
return kitInstance.AppendExtensionEntry(entryType, data)
|
||||
},
|
||||
GetEntries: func(entryType string) []extensions.ExtensionEntry {
|
||||
return kitInstance.GetExtensionEntries(entryType)
|
||||
},
|
||||
SetEditorText: func(text string) {
|
||||
appInstance.SetEditorTextFromExtension(text)
|
||||
},
|
||||
SetStatus: func(key string, text string, priority int) {
|
||||
kitInstance.SetExtensionStatus(extensions.StatusBarEntry{
|
||||
Key: key,
|
||||
Text: text,
|
||||
Priority: priority,
|
||||
})
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
RemoveStatus: func(key string) {
|
||||
kitInstance.RemoveExtensionStatus(key)
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
},
|
||||
GetOption: func(name string) string {
|
||||
return kitInstance.GetExtensionOption(name)
|
||||
},
|
||||
SetOption: func(name string, value string) {
|
||||
kitInstance.SetExtensionOption(name, value)
|
||||
},
|
||||
SetModel: func(modelString string) error {
|
||||
// Capture previous model for the ModelChange event.
|
||||
previousModel := kitInstance.GetExtensionContext().Model
|
||||
err := kitInstance.SetModel(context.Background(), modelString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Notify TUI so it updates model in status bar.
|
||||
p, m, _ := models.ParseModelString(modelString)
|
||||
appInstance.NotifyModelChanged(p, m)
|
||||
// Update the context's Model field so handlers see it.
|
||||
kitInstance.UpdateExtensionContextModel(modelString)
|
||||
// Fire OnModelChange event to extensions.
|
||||
kitInstance.EmitModelChange(modelString, previousModel, "extension")
|
||||
return nil
|
||||
},
|
||||
GetAvailableModels: func() []extensions.ModelInfoEntry {
|
||||
return kitInstance.GetAvailableModels()
|
||||
},
|
||||
EmitCustomEvent: func(name string, data string) {
|
||||
kitInstance.EmitExtensionCustomEvent(name, data)
|
||||
},
|
||||
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
|
||||
return kitInstance.ExecuteCompletion(context.Background(), req)
|
||||
},
|
||||
SuspendTUI: func(callback func()) error {
|
||||
return appInstance.SuspendTUI(callback)
|
||||
},
|
||||
RenderMessage: func(rendererName, content string) {
|
||||
renderer := kitInstance.GetExtensionMessageRenderer(rendererName)
|
||||
if renderer == nil || renderer.Render == nil {
|
||||
appInstance.PrintFromExtension("", content)
|
||||
return
|
||||
}
|
||||
w, _, _ := term.GetSize(int(os.Stdout.Fd()))
|
||||
if w == 0 {
|
||||
w = 80
|
||||
}
|
||||
rendered := renderer.Render(content, w)
|
||||
appInstance.PrintFromExtension("", rendered)
|
||||
},
|
||||
ReloadExtensions: func() error {
|
||||
err := kitInstance.ReloadExtensions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Notify TUI that widgets/status/commands may have changed.
|
||||
appInstance.NotifyWidgetUpdate()
|
||||
return nil
|
||||
},
|
||||
GetAllTools: func() []extensions.ToolInfo {
|
||||
return kitInstance.GetExtensionToolInfos()
|
||||
},
|
||||
SetActiveTools: func(names []string) {
|
||||
kitInstance.SetExtensionActiveTools(names)
|
||||
},
|
||||
RegisterTheme: func(name string, config extensions.ThemeColorConfig) {
|
||||
tc := func(c extensions.ThemeColor) [2]string { return [2]string{c.Light, c.Dark} }
|
||||
ui.RegisterThemeFromConfig(name,
|
||||
tc(config.Primary), tc(config.Secondary),
|
||||
tc(config.Success), tc(config.Warning),
|
||||
tc(config.Error), tc(config.Info),
|
||||
tc(config.Text), tc(config.Muted),
|
||||
tc(config.VeryMuted), tc(config.Background),
|
||||
tc(config.Border), tc(config.MutedBorder),
|
||||
tc(config.System), tc(config.Tool),
|
||||
tc(config.Accent), tc(config.Highlight),
|
||||
tc(config.MdHeading), tc(config.MdLink),
|
||||
tc(config.MdKeyword), tc(config.MdString),
|
||||
tc(config.MdNumber), tc(config.MdComment),
|
||||
)
|
||||
},
|
||||
SetTheme: func(name string) error {
|
||||
return ui.ApplyTheme(name)
|
||||
},
|
||||
ListThemes: func() []string {
|
||||
return ui.ListThemes()
|
||||
},
|
||||
ShowOverlay: func(config extensions.OverlayConfig) extensions.OverlayResult {
|
||||
ch := make(chan app.OverlayResponse, 1)
|
||||
appInstance.SendOverlayRequest(app.OverlayRequestEvent{
|
||||
@@ -664,6 +1032,42 @@ func runNormalMode(ctx context.Context) error {
|
||||
Index: resp.Index,
|
||||
}
|
||||
},
|
||||
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
|
||||
// In-process subagent via SDK.
|
||||
sdkCfg := kit.SubagentConfig{
|
||||
Prompt: config.Prompt,
|
||||
Model: config.Model,
|
||||
SystemPrompt: config.SystemPrompt,
|
||||
Timeout: config.Timeout,
|
||||
NoSession: config.NoSession,
|
||||
}
|
||||
// Bridge SDK events to extension SubagentEvents.
|
||||
if config.OnEvent != nil {
|
||||
sdkCfg.OnEvent = func(e kit.Event) {
|
||||
se := sdkEventToSubagentEvent(e)
|
||||
if se.Type != "" {
|
||||
config.OnEvent(se)
|
||||
}
|
||||
}
|
||||
}
|
||||
result, err := kitInstance.Subagent(ctx, sdkCfg)
|
||||
if result == nil {
|
||||
return nil, &extensions.SubagentResult{Error: err}, err
|
||||
}
|
||||
extResult := &extensions.SubagentResult{
|
||||
Response: result.Response,
|
||||
Error: result.Error,
|
||||
SessionID: result.SessionID,
|
||||
Elapsed: result.Elapsed,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
extResult.Usage = &extensions.SubagentUsage{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
return nil, extResult, err
|
||||
},
|
||||
})
|
||||
kitInstance.EmitSessionStart()
|
||||
}
|
||||
@@ -671,6 +1075,27 @@ func runNormalMode(ctx context.Context) error {
|
||||
// Convert extension commands to UI-layer type for the interactive TUI.
|
||||
extCommands := extensionCommandsForUI(kitInstance)
|
||||
|
||||
// Load prompt templates from standard locations and explicit paths.
|
||||
var promptTemplates []*prompts.PromptTemplate
|
||||
if !noPromptTemplates {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
cwd, _ := os.Getwd()
|
||||
tpls, diags, err := prompts.LoadAll(prompts.LoadOptions{
|
||||
Cwd: cwd,
|
||||
HomeDir: homeDir,
|
||||
ExtraPaths: promptTemplatePaths,
|
||||
ConfigPaths: viper.GetStringSlice("prompts"),
|
||||
IncludeDefaults: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to load some prompt templates: %v", err)
|
||||
}
|
||||
promptTemplates = tpls
|
||||
for _, d := range diags {
|
||||
log.Printf("Prompt template collision: /%s kept from %s, dropped from %s", d.Name, d.KeptPath, d.DroppedPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Build context/skills display metadata for the startup banner.
|
||||
var contextPaths []string
|
||||
for _, cf := range kitInstance.GetContextFiles() {
|
||||
@@ -696,18 +1121,61 @@ func runNormalMode(ctx context.Context) error {
|
||||
getFooter := footerProviderForUI(kitInstance)
|
||||
getToolRenderer := toolRendererProviderForUI(kitInstance)
|
||||
getEditorInterceptor := editorInterceptorProviderForUI(kitInstance)
|
||||
getUIVisibility := uiVisibilityProviderForUI(kitInstance)
|
||||
getStatusBarEntries := statusBarProviderForUI(kitInstance)
|
||||
emitBeforeFork := beforeForkProviderForUI(kitInstance)
|
||||
emitBeforeSessionSwitch := beforeSessionSwitchProviderForUI(kitInstance)
|
||||
getGlobalShortcuts := globalShortcutsProviderForUI(kitInstance)
|
||||
getExtensionCommands := func() []ui.ExtensionCommand {
|
||||
return extensionCommandsForUI(kitInstance)
|
||||
}
|
||||
|
||||
// Build model switching callbacks for the /model command.
|
||||
setModelForUI := func(modelString string) error {
|
||||
err := kitInstance.SetModel(context.Background(), modelString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Update the extension context's Model field so handlers see it.
|
||||
kitInstance.UpdateExtensionContextModel(modelString)
|
||||
// NOTE: We do NOT call appInstance.NotifyModelChanged() here because
|
||||
// this callback runs synchronously inside BubbleTea's Update(), and
|
||||
// NotifyModelChanged calls prog.Send() which deadlocks. The UI layer
|
||||
// updates m.providerName and m.modelName directly after setModel returns.
|
||||
return nil
|
||||
}
|
||||
emitModelChangeForUI := func(newModel, previousModel, source string) {
|
||||
kitInstance.EmitModelChange(newModel, previousModel, source)
|
||||
}
|
||||
|
||||
// Build thinking level callback.
|
||||
setThinkingLevelForUI := func(level string) error {
|
||||
return kitInstance.SetThinkingLevel(context.Background(), level)
|
||||
}
|
||||
|
||||
// Build session-switching callback. Opens a JSONL session file and
|
||||
// replaces the active tree session on both the Kit SDK and App layer.
|
||||
switchSessionForUI := func(path string) error {
|
||||
ts, err := kit.OpenTreeSession(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open session: %w", err)
|
||||
}
|
||||
kitInstance.SetTreeSession(ts)
|
||||
appInstance.SwitchTreeSession(ts)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if running in non-interactive mode
|
||||
if promptFlag != "" {
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, promptFlag, quietFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
|
||||
if positionalPrompt != "" {
|
||||
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
|
||||
}
|
||||
|
||||
// Quiet mode is not allowed in interactive mode
|
||||
if quietFlag {
|
||||
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
|
||||
return fmt.Errorf("--quiet requires a prompt")
|
||||
}
|
||||
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI)
|
||||
}
|
||||
|
||||
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
|
||||
@@ -720,8 +1188,25 @@ func runNormalMode(ctx context.Context) error {
|
||||
//
|
||||
// When --no-exit is set, after the prompt completes the interactive BubbleTea
|
||||
// TUI is started so the user can continue the conversation.
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor) error {
|
||||
if quiet {
|
||||
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error {
|
||||
// Expand @file references in the prompt before sending to the agent.
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
prompt = ui.ProcessFileAttachments(prompt, cwd)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
// JSON mode: no intermediate display, structured JSON output.
|
||||
result, err := appInstance.RunOnceResult(ctx, prompt)
|
||||
if err != nil {
|
||||
writeJSONError(err)
|
||||
return err
|
||||
}
|
||||
data, err := buildJSONOutput(result, modelName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal JSON output: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
} else if quiet {
|
||||
// Quiet mode: no intermediate display, just print final response.
|
||||
if err := appInstance.RunOnce(ctx, prompt); err != nil {
|
||||
return err
|
||||
@@ -746,12 +1231,93 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
|
||||
|
||||
// If --no-exit was requested, hand off to the interactive TUI.
|
||||
if noExit {
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor)
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON output helpers (--json mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// buildJSONOutput converts a TurnResult into a structured JSON byte slice
|
||||
// suitable for machine consumption.
|
||||
func buildJSONOutput(result *kit.TurnResult, model string) ([]byte, error) {
|
||||
type jsonPart struct {
|
||||
Type string `json:"type"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
type jsonMessage struct {
|
||||
Role string `json:"role"`
|
||||
Parts []jsonPart `json:"parts"`
|
||||
}
|
||||
type jsonUsage struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
CacheCreationTokens int64 `json:"cache_creation_tokens"`
|
||||
}
|
||||
type jsonEnvelope struct {
|
||||
Response string `json:"response"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Usage *jsonUsage `json:"usage,omitempty"`
|
||||
Messages []jsonMessage `json:"messages"`
|
||||
}
|
||||
|
||||
out := jsonEnvelope{
|
||||
Response: result.Response,
|
||||
Model: model,
|
||||
StopReason: result.StopReason,
|
||||
SessionID: result.SessionID,
|
||||
}
|
||||
|
||||
if result.TotalUsage != nil {
|
||||
out.Usage = &jsonUsage{
|
||||
InputTokens: result.TotalUsage.InputTokens,
|
||||
OutputTokens: result.TotalUsage.OutputTokens,
|
||||
TotalTokens: result.TotalUsage.TotalTokens,
|
||||
CacheReadTokens: result.TotalUsage.CacheReadTokens,
|
||||
CacheCreationTokens: result.TotalUsage.CacheCreationTokens,
|
||||
}
|
||||
}
|
||||
|
||||
for _, fmsg := range result.Messages {
|
||||
converted := kit.ConvertFromFantasyMessage(fmsg)
|
||||
m := jsonMessage{Role: string(converted.Role)}
|
||||
for _, p := range converted.Parts {
|
||||
switch c := p.(type) {
|
||||
case kit.TextContent:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "text", Data: c})
|
||||
case kit.ToolCall:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "tool_call", Data: c})
|
||||
case kit.ToolResult:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "tool_result", Data: c})
|
||||
case kit.ReasoningContent:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "reasoning", Data: c})
|
||||
case kit.Finish:
|
||||
m.Parts = append(m.Parts, jsonPart{Type: "finish", Data: c})
|
||||
}
|
||||
}
|
||||
out.Messages = append(out.Messages, m)
|
||||
}
|
||||
|
||||
return json.MarshalIndent(out, "", " ")
|
||||
}
|
||||
|
||||
// writeJSONError writes a JSON-formatted error object to stdout so that
|
||||
// callers using --json always receive parseable output.
|
||||
func writeJSONError(err error) {
|
||||
type jsonError struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
data, _ := json.MarshalIndent(jsonError{Error: err.Error()}, "", " ")
|
||||
fmt.Fprintln(os.Stderr, string(data))
|
||||
}
|
||||
|
||||
// runInteractiveModeBubbleTea starts the new unified Bubble Tea interactive TUI.
|
||||
//
|
||||
// It:
|
||||
@@ -763,7 +1329,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
|
||||
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
|
||||
//
|
||||
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor) error {
|
||||
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []ui.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []ui.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error) error {
|
||||
// Determine terminal size; fall back gracefully.
|
||||
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || termWidth == 0 {
|
||||
@@ -771,26 +1337,43 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
termHeight = 24
|
||||
}
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
|
||||
CompactMode: viper.GetBool("compact"),
|
||||
ModelName: modelName,
|
||||
ProviderName: providerName,
|
||||
LoadingMessage: loadingMessage,
|
||||
Width: termWidth,
|
||||
Height: termHeight,
|
||||
ServerNames: serverNames,
|
||||
ToolNames: toolNames,
|
||||
MCPToolCount: mcpToolCount,
|
||||
ExtensionToolCount: extensionToolCount,
|
||||
UsageTracker: usageTracker,
|
||||
ExtensionCommands: extCommands,
|
||||
ContextPaths: contextPaths,
|
||||
SkillItems: skillItems,
|
||||
GetWidgets: getWidgets,
|
||||
GetHeader: getHeader,
|
||||
GetFooter: getFooter,
|
||||
GetToolRenderer: getToolRenderer,
|
||||
GetEditorInterceptor: getEditorInterceptor,
|
||||
CompactMode: viper.GetBool("compact"),
|
||||
ModelName: modelName,
|
||||
ProviderName: providerName,
|
||||
LoadingMessage: loadingMessage,
|
||||
Cwd: cwd,
|
||||
Width: termWidth,
|
||||
Height: termHeight,
|
||||
ServerNames: serverNames,
|
||||
ToolNames: toolNames,
|
||||
MCPToolCount: mcpToolCount,
|
||||
ExtensionToolCount: extensionToolCount,
|
||||
UsageTracker: usageTracker,
|
||||
ExtensionCommands: extCommands,
|
||||
PromptTemplates: promptTemplates,
|
||||
ContextPaths: contextPaths,
|
||||
SkillItems: skillItems,
|
||||
GetWidgets: getWidgets,
|
||||
GetHeader: getHeader,
|
||||
GetFooter: getFooter,
|
||||
GetToolRenderer: getToolRenderer,
|
||||
GetEditorInterceptor: getEditorInterceptor,
|
||||
GetUIVisibility: getUIVisibility,
|
||||
GetStatusBarEntries: getStatusBarEntries,
|
||||
EmitBeforeFork: emitBeforeFork,
|
||||
EmitBeforeSessionSwitch: emitBeforeSessionSwitch,
|
||||
GetGlobalShortcuts: getGlobalShortcuts,
|
||||
GetExtensionCommands: getExtensionCommands,
|
||||
SetModel: setModel,
|
||||
EmitModelChange: emitModelChange,
|
||||
ThinkingLevel: thinkingLevel,
|
||||
IsReasoningModel: isReasoningModel,
|
||||
SetThinkingLevel: setThinkingLevel,
|
||||
SwitchSession: switchSession,
|
||||
ShowSessionPicker: resumeFlag,
|
||||
})
|
||||
|
||||
// Print startup info to stdout before Bubble Tea takes over the screen.
|
||||
@@ -804,3 +1387,42 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
|
||||
_, runErr := program.Run()
|
||||
return runErr
|
||||
}
|
||||
|
||||
// sdkEventToSubagentEvent converts an SDK event to an extension-facing
|
||||
// SubagentEvent. Returns a zero-value event (Type=="") for events that
|
||||
// don't map to anything useful.
|
||||
func sdkEventToSubagentEvent(e kit.Event) extensions.SubagentEvent {
|
||||
switch ev := e.(type) {
|
||||
case kit.MessageUpdateEvent:
|
||||
return extensions.SubagentEvent{Type: "text", Content: ev.Chunk}
|
||||
case kit.ReasoningDeltaEvent:
|
||||
return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta}
|
||||
case kit.ToolCallEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_call", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs,
|
||||
}
|
||||
case kit.ToolExecutionStartEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_start", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolExecutionEndEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_end", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolResultEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_result", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
ToolResult: ev.Result, IsError: ev.IsError,
|
||||
}
|
||||
case kit.TurnStartEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_start"}
|
||||
case kit.TurnEndEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_end"}
|
||||
default:
|
||||
return extensions.SubagentEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,197 @@
|
||||
# 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` |
|
||||
|
||||
### Integrations
|
||||
|
||||
| Extension | Description | Key API |
|
||||
|-----------|-------------|---------|
|
||||
| `kit-telegram/` | Telegram relay for remote monitoring & control | `RegisterCommand`, `OnAgentStart/End`, `SetStatus`, `SendMessage` |
|
||||
|
||||
### Themes
|
||||
|
||||
| Extension | Description | Key API |
|
||||
|-----------|-------------|---------|
|
||||
| `neon-theme.go` | Register and switch custom themes | `RegisterTheme`, `SetTheme` |
|
||||
|
||||
### 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
|
||||
|
||||
### kit-telegram/
|
||||
Full-featured Telegram integration:
|
||||
- Slash command with subcommands and tab completion
|
||||
- Interactive guided setup flow with prompts
|
||||
- Background long-polling goroutine
|
||||
- Progress message rendering edited in place
|
||||
- Message queue with edit-before-dispatch
|
||||
- Remote command handling from Telegram
|
||||
- Status bar and widget updates
|
||||
- Config persistence with atomic writes
|
||||
|
||||
## 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)
|
||||
@@ -0,0 +1,70 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init automatically commits staged changes when the session shuts down,
|
||||
// using the last assistant message as the commit message.
|
||||
//
|
||||
// Only commits if:
|
||||
// - There are staged changes (git diff --cached is non-empty)
|
||||
// - There is at least one assistant message to use as commit message
|
||||
//
|
||||
// The commit message is derived from the last assistant response, trimmed
|
||||
// to the first paragraph (max 72 chars for the subject line).
|
||||
//
|
||||
// Usage: kit -e examples/extensions/auto-commit.go
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
// Check for staged changes.
|
||||
err := exec.Command("git", "diff", "--cached", "--quiet").Run()
|
||||
if err == nil {
|
||||
return // exit code 0 means no staged changes
|
||||
}
|
||||
|
||||
// Get the last assistant message.
|
||||
msgs := ctx.GetMessages()
|
||||
var lastAssistant string
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
if msgs[i].Role == "assistant" {
|
||||
lastAssistant = msgs[i].Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastAssistant == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Build commit message: first paragraph, subject line max 72 chars.
|
||||
subject := firstParagraph(lastAssistant)
|
||||
if len(subject) > 72 {
|
||||
subject = subject[:69] + "..."
|
||||
}
|
||||
|
||||
// Commit.
|
||||
cmd := exec.Command("git", "commit", "-m", subject)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
ctx.PrintError("Auto-commit failed: " + string(output))
|
||||
return
|
||||
}
|
||||
ctx.PrintInfo("Auto-committed: " + subject)
|
||||
})
|
||||
}
|
||||
|
||||
// firstParagraph returns the first non-empty paragraph of text.
|
||||
func firstParagraph(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
// Split on double newlines (paragraph breaks).
|
||||
parts := strings.SplitN(text, "\n\n", 2)
|
||||
line := strings.TrimSpace(parts[0])
|
||||
// Collapse to single line.
|
||||
line = strings.ReplaceAll(line, "\n", " ")
|
||||
return line
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init adds bookmark commands for marking and recalling important points in
|
||||
// a conversation. Bookmarks are persisted in the session tree and survive
|
||||
// restarts.
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// /bookmark <label> — bookmark the current point with a label
|
||||
// /bookmarks — list all bookmarks in this session
|
||||
//
|
||||
// Usage: kit -e examples/extensions/bookmark.go
|
||||
func Init(api ext.API) {
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "bookmark",
|
||||
Description: "Bookmark the current point in the conversation",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
label := strings.TrimSpace(args)
|
||||
if label == "" {
|
||||
label = time.Now().Format("15:04:05")
|
||||
}
|
||||
|
||||
// Count existing messages to record position.
|
||||
msgs := ctx.GetMessages()
|
||||
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"label": label,
|
||||
"messages": len(msgs),
|
||||
})
|
||||
|
||||
_, err := ctx.AppendEntry("bookmark", string(data))
|
||||
if err != nil {
|
||||
ctx.PrintError("Failed to save bookmark: " + err.Error())
|
||||
return "", nil
|
||||
}
|
||||
|
||||
ctx.PrintInfo(fmt.Sprintf("Bookmarked: %s (at message %d)", label, len(msgs)))
|
||||
return "", nil
|
||||
},
|
||||
Complete: func(prefix string, ctx ext.Context) []string {
|
||||
// Suggest existing bookmark labels so the user can quickly
|
||||
// re-bookmark at the same label.
|
||||
entries := ctx.GetEntries("bookmark")
|
||||
var labels []string
|
||||
seen := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(e.Data), &data); err != nil {
|
||||
continue
|
||||
}
|
||||
label, _ := data["label"].(string)
|
||||
if label == "" || seen[label] {
|
||||
continue
|
||||
}
|
||||
if prefix == "" || strings.HasPrefix(strings.ToLower(label), strings.ToLower(prefix)) {
|
||||
labels = append(labels, label)
|
||||
seen[label] = true
|
||||
}
|
||||
}
|
||||
return labels
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "bookmarks",
|
||||
Description: "List all bookmarks in this session",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
entries := ctx.GetEntries("bookmark")
|
||||
if len(entries) == 0 {
|
||||
ctx.PrintInfo("No bookmarks yet. Use /bookmark <label> to create one.")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for i, e := range entries {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(e.Data), &data); err != nil {
|
||||
continue
|
||||
}
|
||||
label, _ := data["label"].(string)
|
||||
msgCount, _ := data["messages"].(float64)
|
||||
lines = append(lines, fmt.Sprintf(" %d. %s (msg %d, %s)",
|
||||
i+1, label, int(msgCount), e.Timestamp[:19]))
|
||||
}
|
||||
|
||||
ctx.PrintInfo("Bookmarks:\n" + strings.Join(lines, "\n"))
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//go:build ignore
|
||||
|
||||
// branded-output.go — Custom Message Rendering example extension for Kit.
|
||||
//
|
||||
// Demonstrates api.RegisterMessageRenderer() and ctx.RenderMessage() which
|
||||
// let extensions define reusable visual styles for output. Each renderer has
|
||||
// a name and a render function that receives content and terminal width.
|
||||
//
|
||||
// This extension registers three renderers:
|
||||
// "success" — green-bordered block for success messages
|
||||
// "warning" — yellow-bordered block for warnings
|
||||
// "metric" — compact key=value display for metrics
|
||||
//
|
||||
// Commands:
|
||||
// /demo-render — shows all three renderers in action
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Register a "success" renderer — green-accented block.
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "success",
|
||||
Render: func(content string, width int) string {
|
||||
maxW := width - 6
|
||||
if maxW < 20 {
|
||||
maxW = 20
|
||||
}
|
||||
bar := strings.Repeat("─", maxW)
|
||||
return fmt.Sprintf(" \033[32m┌%s┐\033[0m\n \033[32m│\033[0m \033[1;32m%s\033[0m\n \033[32m└%s┘\033[0m",
|
||||
bar, content, bar)
|
||||
},
|
||||
})
|
||||
|
||||
// Register a "warning" renderer — yellow-accented block.
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "warning",
|
||||
Render: func(content string, width int) string {
|
||||
maxW := width - 6
|
||||
if maxW < 20 {
|
||||
maxW = 20
|
||||
}
|
||||
bar := strings.Repeat("─", maxW)
|
||||
return fmt.Sprintf(" \033[33m┌%s┐\033[0m\n \033[33m│\033[0m \033[1;33m%s\033[0m\n \033[33m└%s┘\033[0m",
|
||||
bar, content, bar)
|
||||
},
|
||||
})
|
||||
|
||||
// Register a "metric" renderer — compact label: value format.
|
||||
api.RegisterMessageRenderer(ext.MessageRendererConfig{
|
||||
Name: "metric",
|
||||
Render: func(content string, width int) string {
|
||||
return fmt.Sprintf(" \033[36m▸\033[0m %s", content)
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "demo-render",
|
||||
Description: "Demonstrate custom message renderers",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
ctx.RenderMessage("success", "All 42 tests passed in 3.2s")
|
||||
ctx.RenderMessage("warning", "3 deprecation warnings detected")
|
||||
ctx.RenderMessage("metric", fmt.Sprintf("build_time=%.1fs tests=42 coverage=87%% timestamp=%s",
|
||||
3.2, time.Now().Format("15:04:05")))
|
||||
|
||||
return "Rendered three message styles.", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init registers a before-compact hook that notifies the user when
|
||||
// compaction is about to happen and optionally blocks automatic compaction.
|
||||
//
|
||||
// When automatic compaction is triggered (via --auto-compact), the extension
|
||||
// asks for user confirmation. Manual /compact commands are always allowed.
|
||||
//
|
||||
// This demonstrates the OnBeforeCompact event which allows extensions to
|
||||
// inspect context usage stats and gate the compaction process.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/compact-notify.go --auto-compact
|
||||
func Init(api ext.API) {
|
||||
api.OnBeforeCompact(func(e ext.BeforeCompactEvent, ctx ext.Context) *ext.BeforeCompactResult {
|
||||
pct := int(e.UsagePercent * 100)
|
||||
summary := fmt.Sprintf("Context: %dk/%dk tokens (%d%%), %d messages",
|
||||
e.EstimatedTokens/1000, e.ContextLimit/1000, pct, e.MessageCount)
|
||||
|
||||
if e.IsAutomatic {
|
||||
// Auto-compaction: ask user first.
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: "Auto-compaction triggered.\n" + summary,
|
||||
BorderColor: "#f9e2af",
|
||||
Subtitle: "compact-notify",
|
||||
})
|
||||
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Allow automatic compaction?",
|
||||
DefaultValue: true,
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.BeforeCompactResult{
|
||||
Cancel: true,
|
||||
Reason: "Auto-compaction skipped by user.",
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Manual /compact: just notify.
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: "Compacting conversation...\n" + summary,
|
||||
BorderColor: "#89b4fa",
|
||||
Subtitle: "compact-notify",
|
||||
})
|
||||
}
|
||||
|
||||
return nil // allow compaction
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init registers before-hooks for destructive session operations:
|
||||
// - Forks: Asks for confirmation before branching to a different tree node.
|
||||
// - New sessions: Checks for uncommitted git changes and warns before
|
||||
// starting a new branch if the working tree is dirty.
|
||||
//
|
||||
// This demonstrates the OnBeforeFork and OnBeforeSessionSwitch events
|
||||
// which allow extensions to cancel session lifecycle operations.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/confirm-destructive.go --continue
|
||||
func Init(api ext.API) {
|
||||
// Gate /new command: warn if there are uncommitted git changes.
|
||||
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
|
||||
if !isGitDirty() {
|
||||
return nil // clean repo, allow switch
|
||||
}
|
||||
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Working tree has uncommitted changes. Start new session anyway?",
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.BeforeSessionSwitchResult{
|
||||
Cancel: true,
|
||||
Reason: "Session switch cancelled: uncommitted git changes.",
|
||||
}
|
||||
}
|
||||
return nil // user approved
|
||||
})
|
||||
|
||||
// Gate fork: ask for confirmation before branching.
|
||||
api.OnBeforeFork(func(e ext.BeforeForkEvent, ctx ext.Context) *ext.BeforeForkResult {
|
||||
msg := "Branch to this point in the conversation?"
|
||||
if e.IsUserMessage && e.UserText != "" {
|
||||
// Show a preview of the user message being forked to.
|
||||
preview := e.UserText
|
||||
if len(preview) > 80 {
|
||||
preview = preview[:77] + "..."
|
||||
}
|
||||
msg = "Fork and edit: " + preview + "\n\nContinue?"
|
||||
}
|
||||
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: msg,
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.BeforeForkResult{
|
||||
Cancel: true,
|
||||
Reason: "Fork cancelled by user.",
|
||||
}
|
||||
}
|
||||
return nil // user approved
|
||||
})
|
||||
}
|
||||
|
||||
// isGitDirty returns true if the git working tree has uncommitted changes.
|
||||
func isGitDirty() bool {
|
||||
out, err := exec.Command("git", "status", "--porcelain").Output()
|
||||
if err != nil {
|
||||
return false // not a git repo or git not available
|
||||
}
|
||||
return len(strings.TrimSpace(string(out))) > 0
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//go:build ignore
|
||||
|
||||
// context-inject.go — Injects context from a local file into every LLM turn.
|
||||
//
|
||||
// Reads a context file (default: .kit/context.md) and prepends it as a system
|
||||
// message to every LLM context window via OnContextPrepare. This is useful for
|
||||
// injecting project-specific knowledge, coding standards, or RAG results that
|
||||
// should always be visible to the model — without cluttering the session history.
|
||||
//
|
||||
// The injected message does NOT persist in the session tree (it's ephemeral,
|
||||
// added at query time only). This means:
|
||||
// - Changing the context file immediately affects future turns
|
||||
// - No session bloat from repeated context injection
|
||||
// - The model always sees the latest version of the context
|
||||
//
|
||||
// Configuration:
|
||||
//
|
||||
// KIT_OPT_CONTEXT_FILE — path to context file (default: .kit/context.md)
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// kit -e examples/extensions/context-inject.go
|
||||
// echo "Always use error wrapping with fmt.Errorf" > .kit/context.md
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "context-file",
|
||||
Description: "Path to the context file to inject into every turn",
|
||||
Default: ".kit/context.md",
|
||||
})
|
||||
|
||||
api.OnContextPrepare(func(e ext.ContextPrepareEvent, ctx ext.Context) *ext.ContextPrepareResult {
|
||||
path := ctx.GetOption("context-file")
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// File doesn't exist or can't be read — skip silently.
|
||||
return nil
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prepend a system message with the context file contents.
|
||||
injected := ext.ContextMessage{
|
||||
Index: -1,
|
||||
Role: "system",
|
||||
Content: fmt.Sprintf("[Project Context from %s]\n\n%s", path, content),
|
||||
}
|
||||
|
||||
msgs := make([]ext.ContextMessage, 0, len(e.Messages)+1)
|
||||
msgs = append(msgs, injected)
|
||||
msgs = append(msgs, e.Messages...)
|
||||
|
||||
return &ext.ContextPrepareResult{Messages: msgs}
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "context",
|
||||
Description: "Show or edit the injected context file path",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
path := ctx.GetOption("context-file")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Context file: %s (not found or unreadable)", path), nil
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
preview := strings.Join(lines, "\n")
|
||||
if len(lines) > 10 {
|
||||
preview = strings.Join(lines[:10], "\n") + "\n..."
|
||||
}
|
||||
return fmt.Sprintf("Context file: %s (%d lines)\n\n%s", path, len(lines), preview), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build ignore
|
||||
|
||||
// dev-reload.go — Extension Hot-Reload example extension for Kit.
|
||||
//
|
||||
// Demonstrates ctx.ReloadExtensions() which hot-reloads all extensions
|
||||
// from disk without restarting Kit. This is invaluable during extension
|
||||
// development: edit your extension source, then type /reload to pick up
|
||||
// changes immediately.
|
||||
//
|
||||
// Event handlers, slash commands, tool renderers, message renderers, and
|
||||
// keyboard shortcuts update immediately. Extension-defined tools are NOT
|
||||
// updated (they are baked into the agent at creation time and require a
|
||||
// restart).
|
||||
//
|
||||
// Commands:
|
||||
// /reload — hot-reload all extensions from disk
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
var loadedAt string
|
||||
|
||||
func Init(api ext.API) {
|
||||
loadedAt = time.Now().Format("15:04:05")
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "reload",
|
||||
Description: "Hot-reload all extensions from disk",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
ctx.Print("Reloading extensions...")
|
||||
err := ctx.ReloadExtensions()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reload failed: %w", err)
|
||||
}
|
||||
return "Extensions reloaded successfully.", nil
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "load-time",
|
||||
Description: "Show when this extension was loaded",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
return fmt.Sprintf("This extension was loaded at %s", loadedAt), nil
|
||||
},
|
||||
})
|
||||
|
||||
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
|
||||
ctx.Print(fmt.Sprintf("[dev-reload] Extension loaded at %s", loadedAt))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Extension Test Template
|
||||
//
|
||||
// This is a template for writing tests for your Kit extension.
|
||||
// Copy this file to your extension directory, rename it to something like
|
||||
// "my-ext_test.go", and customize it for your extension.
|
||||
//
|
||||
// Run tests with: go test -v
|
||||
//
|
||||
// IMPORTANT: This file should be in the same directory as your extension
|
||||
// and use package main, NOT package test.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/pkg/extensions/test"
|
||||
)
|
||||
|
||||
// Test that your extension loads without errors
|
||||
func TestExtension_Loads(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
ext := harness.LoadFile("my-ext.go") // Change to your extension filename
|
||||
|
||||
// Verify the extension was loaded
|
||||
if ext == nil {
|
||||
t.Fatal("extension should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Test your event handlers are registered
|
||||
func TestExtension_EventHandlers(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Uncomment the handlers your extension uses:
|
||||
// test.AssertHasHandlers(t, harness, extensions.ToolCall)
|
||||
// test.AssertHasHandlers(t, harness, extensions.Input)
|
||||
// test.AssertHasHandlers(t, harness, extensions.SessionStart)
|
||||
// test.AssertHasHandlers(t, harness, extensions.AgentEnd)
|
||||
}
|
||||
|
||||
// Test tool registration
|
||||
func TestExtension_Tools(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Test that your tools are registered
|
||||
// test.AssertToolRegistered(t, harness, "my_tool")
|
||||
|
||||
// Or test all registered tools
|
||||
tools := harness.RegisteredTools()
|
||||
t.Logf("Registered %d tools", len(tools))
|
||||
for _, tool := range tools {
|
||||
t.Logf(" - %s: %s", tool.Name, tool.Description)
|
||||
}
|
||||
}
|
||||
|
||||
// Test command registration
|
||||
func TestExtension_Commands(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Test that your commands are registered
|
||||
// test.AssertCommandRegistered(t, harness, "mycommand")
|
||||
|
||||
// Or test all registered commands
|
||||
cmds := harness.RegisteredCommands()
|
||||
t.Logf("Registered %d commands", len(cmds))
|
||||
for _, cmd := range cmds {
|
||||
t.Logf(" - %s: %s", cmd.Name, cmd.Description)
|
||||
}
|
||||
}
|
||||
|
||||
// Test session start behavior
|
||||
func TestExtension_SessionStart(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Emit session start event
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{
|
||||
SessionID: "test-session",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify expected behavior:
|
||||
// - Did it print something?
|
||||
// test.AssertPrinted(t, harness, "expected output")
|
||||
|
||||
// - Did it set a widget?
|
||||
// test.AssertWidgetSet(t, harness, "my-widget")
|
||||
// test.AssertWidgetText(t, harness, "my-widget", "expected text")
|
||||
|
||||
// - Did it set the header/footer?
|
||||
// test.AssertHeaderSet(t, harness)
|
||||
// test.AssertFooterSet(t, harness)
|
||||
|
||||
// - Did it set a status?
|
||||
// test.AssertStatusSet(t, harness, "myext:status")
|
||||
}
|
||||
|
||||
// Test tool call handling
|
||||
func TestExtension_ToolCall(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Test a specific tool call
|
||||
result, err := harness.Emit(extensions.ToolCallEvent{
|
||||
ToolName: "some_tool",
|
||||
Input: `{"key": "value"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// If your extension blocks certain tools:
|
||||
// test.AssertNotBlocked(t, result)
|
||||
// OR
|
||||
// test.AssertBlocked(t, result, "expected reason")
|
||||
|
||||
// Suppress unused variable warning (remove this when using result)
|
||||
_ = result
|
||||
|
||||
// Check for print output
|
||||
// test.AssertPrinted(t, harness, "expected message")
|
||||
}
|
||||
|
||||
// Test input handling
|
||||
func TestExtension_InputHandling(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Test input that should be handled
|
||||
result, err := harness.Emit(extensions.InputEvent{
|
||||
Text: "test input",
|
||||
Source: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// If your extension handles/transforms input:
|
||||
// test.AssertInputHandled(t, result, "handled")
|
||||
// OR
|
||||
// test.AssertInputTransformed(t, result, "transformed text")
|
||||
|
||||
// Suppress unused variable warning (remove this when using result)
|
||||
_ = result
|
||||
}
|
||||
|
||||
// Test with configured prompt results
|
||||
func TestExtension_WithPrompts(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("my-ext.go")
|
||||
|
||||
// Configure what prompts should return
|
||||
harness.Context().SetPromptSelectResult(extensions.PromptSelectResult{
|
||||
Value: "option1",
|
||||
Index: 0,
|
||||
Cancelled: false,
|
||||
})
|
||||
|
||||
// Now when your extension calls ctx.PromptSelect(), it gets the configured result
|
||||
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
|
||||
// Verify behavior based on the selected options
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init expands inline bash expressions in user prompts before they reach the
|
||||
// LLM. Text like !{git branch --show-current} is replaced with the command's
|
||||
// stdout.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "Fix the tests on !{git branch --show-current}"
|
||||
// → "Fix the tests on main"
|
||||
//
|
||||
// "The current directory is !{pwd}"
|
||||
// → "The current directory is /home/user/project"
|
||||
//
|
||||
// Usage: kit -e examples/extensions/inline-bash.go
|
||||
func Init(api ext.API) {
|
||||
// Matches !{...} with non-greedy content.
|
||||
re := regexp.MustCompile(`!\{([^}]+)\}`)
|
||||
|
||||
api.OnInput(func(ev ext.InputEvent, ctx ext.Context) *ext.InputResult {
|
||||
if !re.MatchString(ev.Text) {
|
||||
return nil
|
||||
}
|
||||
|
||||
expanded := re.ReplaceAllStringFunc(ev.Text, func(match string) string {
|
||||
// Extract the command between !{ and }.
|
||||
cmd := re.FindStringSubmatch(match)[1]
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
|
||||
out, err := exec.Command("bash", "-c", cmd).Output()
|
||||
if err != nil {
|
||||
return match // keep original on error
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
})
|
||||
|
||||
return &ext.InputResult{
|
||||
Action: "transform",
|
||||
Text: expanded,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//go:build ignore
|
||||
|
||||
// interactive-shell.go — TUI Suspend example extension for Kit.
|
||||
//
|
||||
// Demonstrates ctx.SuspendTUI() which temporarily releases the terminal
|
||||
// from the TUI so interactive subprocesses can run with full terminal
|
||||
// control. The TUI is automatically restored when the callback returns.
|
||||
//
|
||||
// Commands:
|
||||
// /edit <file> — opens $EDITOR (or vi) to edit a file
|
||||
// /shell — drops into an interactive shell session
|
||||
// /run <cmd> — runs a command with full terminal I/O (no TUI capture)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
ext "kit/ext"
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "edit",
|
||||
Description: "Open $EDITOR to edit a file (TUI suspends)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
file := strings.TrimSpace(args)
|
||||
if file == "" {
|
||||
return "", fmt.Errorf("usage: /edit <file>")
|
||||
}
|
||||
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
editor = "vi"
|
||||
}
|
||||
|
||||
ctx.Print(fmt.Sprintf("Opening %s in %s...", file, editor))
|
||||
|
||||
err := ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command(editor, file)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("editor session failed: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Finished editing %s", file), nil
|
||||
},
|
||||
Complete: func(prefix string, ctx ext.Context) []string {
|
||||
// Suggest files in the current directory.
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var results []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
results = append(results, name)
|
||||
}
|
||||
}
|
||||
return results
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "shell",
|
||||
Description: "Drop into an interactive shell (TUI suspends)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
|
||||
ctx.Print(fmt.Sprintf("Starting %s... (type 'exit' to return to Kit)", shell))
|
||||
|
||||
err := ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command(shell)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shell session failed: %w", err)
|
||||
}
|
||||
|
||||
return "Shell session ended, TUI restored.", nil
|
||||
},
|
||||
})
|
||||
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "run",
|
||||
Description: "Run a command with full terminal I/O (TUI suspends)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
cmdStr := strings.TrimSpace(args)
|
||||
if cmdStr == "" {
|
||||
return "", fmt.Errorf("usage: /run <command>")
|
||||
}
|
||||
|
||||
ctx.Print(fmt.Sprintf("Running: %s", cmdStr))
|
||||
|
||||
err := ctx.SuspendTUI(func() {
|
||||
cmd := exec.Command("sh", "-c", cmdStr)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Run()
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("command failed: %w", err)
|
||||
}
|
||||
|
||||
return "Command finished, TUI restored.", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -31,6 +32,16 @@ import (
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// kitJSONOutput matches the JSON envelope produced by `kit --json`.
|
||||
type kitJSONOutput struct {
|
||||
Response string `json:"response"`
|
||||
Model string `json:"model"`
|
||||
Usage *struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
} `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -474,27 +485,33 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Build subprocess arguments. Don't pass --model; the subprocess
|
||||
// inherits the same config/env and will use the same default.
|
||||
// Build subprocess arguments. Use --json for structured output parsing.
|
||||
// Don't pass --model; the subprocess inherits the same config/env default.
|
||||
args := []string{
|
||||
"--prompt", question,
|
||||
"--quiet",
|
||||
"--json",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
"--system-prompt", tmpFile.Name(),
|
||||
question,
|
||||
}
|
||||
|
||||
var stdoutBuf, stderrBuf bytes.Buffer
|
||||
cmd := exec.Command(kitBinary, args...)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Stdout = &stdoutBuf
|
||||
cmd.Stderr = &stderrBuf
|
||||
|
||||
outBytes, err := cmd.CombinedOutput()
|
||||
err = cmd.Run()
|
||||
close(done)
|
||||
elapsed = time.Since(start)
|
||||
result := strings.TrimSpace(string(outBytes))
|
||||
|
||||
if err != nil {
|
||||
// Extract a single-line summary for the card (no newlines).
|
||||
errLine := result
|
||||
// On error, prefer stderr for the error message; fall back to stdout.
|
||||
errText := strings.TrimSpace(stderrBuf.String())
|
||||
if errText == "" {
|
||||
errText = strings.TrimSpace(stdoutBuf.String())
|
||||
}
|
||||
errLine := errText
|
||||
if idx := strings.Index(errLine, "\n"); idx >= 0 {
|
||||
errLine = errLine[:idx]
|
||||
}
|
||||
@@ -505,10 +522,18 @@ func queryExpert(name, question string) (output string, exitCode int, elapsed ti
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
code = exitErr.ExitCode()
|
||||
}
|
||||
return result, code, elapsed
|
||||
return errText, code, elapsed
|
||||
}
|
||||
|
||||
// Success — extract last non-empty line for the card.
|
||||
// Parse JSON output from subprocess.
|
||||
var parsed kitJSONOutput
|
||||
result := strings.TrimSpace(stdoutBuf.String())
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err == nil {
|
||||
result = parsed.Response
|
||||
}
|
||||
// else: fall back to raw stdout (e.g. older kit binary without --json)
|
||||
|
||||
// Extract last non-empty line for the card.
|
||||
lines := strings.Split(result, "\n")
|
||||
var lastLine string
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# kit-telegram
|
||||
|
||||
A Kit extension that relays all Kit agent runs to Telegram and lets approved Telegram users reply back into Kit.
|
||||
|
||||
## What it does
|
||||
|
||||
- Relays **all Kit runs** to one Telegram chat while connected
|
||||
- Edits one Telegram progress message in place during a run
|
||||
- Lets approved Telegram users send normal text replies back into Kit
|
||||
- Shows `Telegram Connected` or `Telegram Disconnected` in the status bar
|
||||
- Shows a small spinner animation as `⠋ Telegram Connecting` only while the relay is still connecting
|
||||
- On startup with an already validated enabled config, sends a short Telegram connection message to confirm the relay is up
|
||||
|
||||
## Requirements
|
||||
|
||||
- `kit` installed and working
|
||||
- A Telegram bot token from `@BotFather`
|
||||
- Either:
|
||||
- A Telegram chat where you can message the bot, or
|
||||
- A numeric Telegram chat id you want to enter manually
|
||||
- For group chats, one or more allowed Telegram user ids
|
||||
|
||||
## Quickstart
|
||||
|
||||
### 1. Install the extension
|
||||
|
||||
```bash
|
||||
kit install github.com/mark3labs/kit/examples/extensions/kit-telegram
|
||||
```
|
||||
|
||||
Or run directly:
|
||||
```bash
|
||||
kit -e path/to/kit-telegram/main.go
|
||||
```
|
||||
|
||||
### 2. Start Kit and connect Telegram
|
||||
|
||||
```bash
|
||||
kit
|
||||
```
|
||||
|
||||
Inside Kit, run:
|
||||
|
||||
```
|
||||
/telegram connect
|
||||
```
|
||||
|
||||
You will be prompted for:
|
||||
|
||||
- Bot token from `@BotFather`
|
||||
- Whether to auto-detect the chat by messaging the bot or enter the chat id manually
|
||||
- Allowed user ids when needed
|
||||
|
||||
### 3. Verify the relay
|
||||
|
||||
```
|
||||
/telegram test
|
||||
```
|
||||
|
||||
Reply in Telegram with the code from the test message.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/telegram` | Human-friendly overview and subcommand list |
|
||||
| `/telegram status` | Raw deterministic relay state |
|
||||
| `/telegram test` | Verify outbound and inbound relay |
|
||||
| `/telegram toggle` | Enable or disable relay without deleting credentials |
|
||||
| `/telegram logout` | Remove saved credentials and disconnect relay |
|
||||
| `/telegram connect` | Run the setup flow again |
|
||||
| `/telegram clear` | Clear Telegram status and working messages from the TUI |
|
||||
|
||||
## Remote commands (from Telegram)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/telegram` | Sends the overview back to Telegram |
|
||||
| `/telegram status` | Sends the deterministic state report to Telegram |
|
||||
| `/telegram test` | Sends a reply-code test message from Telegram |
|
||||
| `/telegram toggle` | Flips the enabled flag |
|
||||
| `/telegram logout yes` | Logs out (requires `yes` confirmation) |
|
||||
| `/telegram clear` | Clears the TUI footer and working messages |
|
||||
|
||||
## Key APIs Used
|
||||
|
||||
- `RegisterCommand` — Slash command with subcommands and tab completion
|
||||
- `OnSessionStart` / `OnSessionShutdown` — Lifecycle management
|
||||
- `OnAgentStart` / `OnAgentEnd` — Run tracking and progress rendering
|
||||
- `OnToolCall` / `OnToolResult` — Action tracking
|
||||
- `OnMessageEnd` — Capture assistant responses
|
||||
- `OnInput` — Mirror local messages to Telegram
|
||||
- `SetStatus` / `RemoveStatus` — Status bar indicators
|
||||
- `SetWidget` / `RemoveWidget` — Working message display
|
||||
- `PromptInput` / `PromptSelect` / `PromptConfirm` — Interactive setup flow
|
||||
- `SendMessage` — Inject Telegram replies as Kit prompts
|
||||
|
||||
## Architecture
|
||||
|
||||
Single Go file interpreted by Yaegi at runtime. Core components:
|
||||
|
||||
- **Telegram Bot API client** — HTTP calls via `net/http` for getMe, getChat, getChatMember, getUpdates (long-polling), sendMessage, editMessageText
|
||||
- **Config persistence** — JSON file at `.kit/kit-telegram.json` with atomic writes
|
||||
- **Long-polling goroutine** — Background polling for Telegram updates with warmup poll, retry, and client-side timeouts
|
||||
- **Message queue** — In-memory FIFO queue for Telegram prompt input with edit-before-dispatch support
|
||||
- **Progress rendering** — `⏳ elapsed · step N` with action lines, edited in place
|
||||
- **Final rendering** — `✅/❌ elapsed` with response text, split into chunks for long output
|
||||
|
||||
## Debug mode
|
||||
|
||||
Set environment variable `KIT_TELEGRAM_DEBUG=1` to enable verbose debug logging.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init demonstrates a minimal-chrome extension.
|
||||
// Hides the startup banner, status bar, separator, and input hint, replacing
|
||||
// them with a compact footer showing model name and a context usage bar:
|
||||
//
|
||||
// claude-sonnet-4-5-20250929 [###-------] 30% (3.9K/200K tokens)
|
||||
//
|
||||
// Usage: kit -e examples/extensions/minimal.go
|
||||
func Init(api ext.API) {
|
||||
// updateFooter builds the footer text from current context stats.
|
||||
updateFooter := func(ctx ext.Context) {
|
||||
stats := ctx.GetContextStats()
|
||||
pct := stats.UsagePercent * 100
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
filled := int(math.Round(pct)) / 10
|
||||
bar := strings.Repeat("#", filled) + strings.Repeat("-", 10-filled)
|
||||
|
||||
// Format token counts like the built-in status bar (e.g. "3.9K/200K").
|
||||
fmtTokens := func(n int) string {
|
||||
if n >= 1000 {
|
||||
return fmt.Sprintf("%.1fK", float64(n)/1000)
|
||||
}
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("%s [%s] %d%%", ctx.Model, bar, int(math.Round(pct)))
|
||||
if stats.ContextLimit > 0 {
|
||||
text += fmt.Sprintf(" (%s/%s tokens)",
|
||||
fmtTokens(stats.EstimatedTokens), fmtTokens(stats.ContextLimit))
|
||||
}
|
||||
|
||||
ctx.SetFooter(ext.HeaderFooterConfig{
|
||||
Content: ext.WidgetContent{Text: text},
|
||||
Style: ext.WidgetStyle{BorderColor: "#585b70"},
|
||||
})
|
||||
}
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
// Strip built-in chrome for a minimal look.
|
||||
ctx.SetUIVisibility(ext.UIVisibility{
|
||||
HideStartupMessage: true,
|
||||
HideStatusBar: true,
|
||||
HideSeparator: true,
|
||||
HideInputHint: true,
|
||||
})
|
||||
|
||||
updateFooter(ctx)
|
||||
})
|
||||
|
||||
// Refresh after each agent turn — context usage changes here.
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
updateFooter(ctx)
|
||||
})
|
||||
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
ctx.RemoveFooter()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
// Init registers a "neon" theme and a /neon slash command to apply it.
|
||||
// Demonstrates how extensions can create and set themes programmatically.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/neon-theme.go
|
||||
func Init(api ext.API) {
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
// Register a cyberpunk neon theme at startup.
|
||||
ctx.RegisterTheme("neon", ext.ThemeColorConfig{
|
||||
Primary: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
|
||||
Secondary: ext.ThemeColor{Light: "#0088CC", Dark: "#00FFFF"},
|
||||
Success: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
|
||||
Warning: ext.ThemeColor{Light: "#CCAA00", Dark: "#FFFF00"},
|
||||
Error: ext.ThemeColor{Light: "#CC0033", Dark: "#FF0055"},
|
||||
Info: ext.ThemeColor{Light: "#0088CC", Dark: "#00CCFF"},
|
||||
Text: ext.ThemeColor{Light: "#111111", Dark: "#F0F0F0"},
|
||||
Background: ext.ThemeColor{Light: "#F0F0F0", Dark: "#0A0A14"},
|
||||
MdKeyword: ext.ThemeColor{Light: "#CC00FF", Dark: "#FF00FF"},
|
||||
MdString: ext.ThemeColor{Light: "#00CC44", Dark: "#00FF66"},
|
||||
MdComment: ext.ThemeColor{Light: "#888888", Dark: "#555555"},
|
||||
})
|
||||
|
||||
ctx.PrintInfo("Neon theme registered! Use /theme neon to activate.")
|
||||
})
|
||||
|
||||
// Also register a /neon slash command as a shortcut.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "neon",
|
||||
Description: "Switch to the neon cyberpunk theme",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
if err := ctx.SetTheme("neon"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Neon theme activated!", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init sends a desktop notification when the agent finishes responding.
|
||||
// Useful for long-running tasks — get notified without watching the terminal.
|
||||
|
||||
// Supports: Linux (notify-send), macOS (osascript).
|
||||
//
|
||||
// Usage: kit -e examples/extensions/notify.go
|
||||
func Init(api ext.API) {
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
sendNotification("Kit", "Agent finished responding")
|
||||
})
|
||||
}
|
||||
|
||||
func sendNotification(title, body string) {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
// Uses notify-send (libnotify) — available on most Linux desktops.
|
||||
_ = exec.Command("notify-send", "-a", "Kit", title, body).Start()
|
||||
case "darwin":
|
||||
// Uses macOS built-in osascript for native notifications.
|
||||
script := `display notification "` + body + `" with title "` + title + `"`
|
||||
_ = exec.Command("osascript", "-e", script).Start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init intercepts potentially dangerous bash commands and asks the user for
|
||||
// confirmation before allowing execution.
|
||||
//
|
||||
// Dangerous patterns: rm -rf, sudo, chmod 777, mkfs, dd, > /dev/
|
||||
//
|
||||
// Usage: kit -e examples/extensions/permission-gate.go
|
||||
func Init(api ext.API) {
|
||||
// Patterns that require user confirmation.
|
||||
dangerousPatterns := []string{
|
||||
"rm -rf",
|
||||
"rm -r /",
|
||||
"sudo ",
|
||||
"chmod 777",
|
||||
"chmod -R 777",
|
||||
"mkfs",
|
||||
"dd if=",
|
||||
"> /dev/",
|
||||
":(){ :|:& };:",
|
||||
}
|
||||
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if tc.ToolName != "Bash" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract the command from the tool input JSON.
|
||||
var input struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
|
||||
return nil
|
||||
}
|
||||
cmd := strings.ToLower(input.Command)
|
||||
|
||||
// Check for dangerous patterns.
|
||||
for _, pattern := range dangerousPatterns {
|
||||
if strings.Contains(cmd, strings.ToLower(pattern)) {
|
||||
result := ctx.PromptConfirm(ext.PromptConfirmConfig{
|
||||
Message: "Dangerous command detected: " + input.Command + "\n\nAllow execution?",
|
||||
})
|
||||
if result.Cancelled || !result.Value {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "User denied execution of dangerous command: " + input.Command,
|
||||
}
|
||||
}
|
||||
return nil // user approved
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import "kit/ext"
|
||||
|
||||
// Init injects a pirate persona into the system prompt, causing the LLM to
|
||||
// respond in pirate-speak. Demonstrates OnBeforeAgentStart system prompt
|
||||
// injection.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/pirate.go
|
||||
func Init(api ext.API) {
|
||||
piratePrompt := `
|
||||
You are a pirate! You must:
|
||||
- Start every response with "Ahoy!"
|
||||
- Use pirate slang (ye, matey, arr, landlubber, etc.)
|
||||
- Refer to files as "scrolls" and directories as "treasure chests"
|
||||
- Call errors "cursed mishaps" and bugs "sea monsters"
|
||||
- End responses with a pirate saying
|
||||
|
||||
Despite the pirate persona, your technical advice must remain accurate and helpful.`
|
||||
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
return &ext.BeforeAgentStartResult{
|
||||
SystemPrompt: &piratePrompt,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init implements a plan/explore mode that restricts the agent to read-only
|
||||
// tools. Toggle with /plan (or start in plan mode via KIT_OPT_PLAN=true).
|
||||
|
||||
// In plan mode the agent can only use read, grep, find, and ls — it cannot
|
||||
// write files, run bash, or make edits. This is useful for exploring a
|
||||
// codebase, reviewing architecture, or generating plans before executing.
|
||||
//
|
||||
// The status bar shows the current mode and the system prompt is augmented
|
||||
// with planning instructions when active.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/plan-mode.go
|
||||
//
|
||||
// Start in plan mode: KIT_OPT_PLAN=true kit -e examples/extensions/plan-mode.go
|
||||
func Init(api ext.API) {
|
||||
// Read-only tool set (matches core.ReadOnlyTools).
|
||||
readOnlyTools := []string{"read", "grep", "find", "ls"}
|
||||
|
||||
var planActive bool
|
||||
|
||||
// Register "plan" option so users can start in plan mode via env/config.
|
||||
api.RegisterOption(ext.OptionDef{
|
||||
Name: "plan",
|
||||
Description: "Start in plan mode (read-only tools)",
|
||||
Default: "false",
|
||||
})
|
||||
|
||||
// ctrl+alt+p — global shortcut to toggle plan mode.
|
||||
api.RegisterShortcut(ext.ShortcutDef{
|
||||
Key: "ctrl+alt+p",
|
||||
Description: "Toggle plan/explore mode",
|
||||
}, func(ctx ext.Context) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
})
|
||||
|
||||
// /plan — toggle plan mode on or off.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "plan",
|
||||
Description: "Toggle plan/explore mode (ctrl+alt+p)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
planActive = !planActive
|
||||
applyMode(ctx, planActive, readOnlyTools)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Check option at session start to enable plan mode from env/config.
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
opt := strings.ToLower(ctx.GetOption("plan"))
|
||||
if opt == "true" || opt == "1" || opt == "yes" {
|
||||
planActive = true
|
||||
applyMode(ctx, true, readOnlyTools)
|
||||
}
|
||||
})
|
||||
|
||||
// Inject planning instructions into the system prompt when active.
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
if !planActive {
|
||||
return nil
|
||||
}
|
||||
prompt := `You are in PLAN MODE (read-only exploration).
|
||||
You can ONLY read, search, and explore the codebase. You CANNOT write files,
|
||||
run commands, or make edits. Focus on:
|
||||
- Understanding the codebase structure and architecture
|
||||
- Identifying relevant files and patterns
|
||||
- Generating detailed plans and recommendations
|
||||
- Answering questions about how the code works
|
||||
|
||||
When the user is ready to execute, they will exit plan mode with /plan.`
|
||||
return &ext.BeforeAgentStartResult{
|
||||
SystemPrompt: &prompt,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func applyMode(ctx ext.Context, active bool, readOnlyTools []string) {
|
||||
if active {
|
||||
ctx.SetActiveTools(readOnlyTools)
|
||||
ctx.SetStatus("plan-mode", "PLAN MODE (read-only)", 10)
|
||||
ctx.PrintInfo("Plan mode ON — agent restricted to read-only tools (read, grep, find, ls).\nUse /plan to toggle off.")
|
||||
} else {
|
||||
ctx.SetActiveTools(nil) // re-enable all tools
|
||||
ctx.RemoveStatus("plan-mode")
|
||||
ctx.PrintInfo("Plan mode OFF — all tools re-enabled.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init loads project-specific rules from .kit/rules/ into the system prompt.
|
||||
// Each .md file in the rules directory is injected as additional context,
|
||||
// giving projects a way to customise LLM behaviour without editing the
|
||||
// main system prompt.
|
||||
//
|
||||
// Place rule files in:
|
||||
//
|
||||
// .kit/rules/code-style.md
|
||||
// .kit/rules/testing.md
|
||||
// .kit/rules/security.md
|
||||
//
|
||||
// Usage: kit -e examples/extensions/project-rules.go
|
||||
func Init(api ext.API) {
|
||||
var rules string
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
rulesDir := filepath.Join(ctx.CWD, ".kit", "rules")
|
||||
entries, err := os.ReadDir(rulesDir)
|
||||
if err != nil {
|
||||
return // no rules directory, nothing to do
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".md") && !strings.HasSuffix(name, ".txt") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(rulesDir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content != "" {
|
||||
parts = append(parts, "## "+strings.TrimSuffix(name, filepath.Ext(name))+"\n\n"+content)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
rules = "# Project Rules\n\n" + strings.Join(parts, "\n\n---\n\n")
|
||||
ctx.PrintInfo(fmt.Sprintf("[project-rules] Loaded %d rule file(s) from .kit/rules/", len(parts)))
|
||||
})
|
||||
|
||||
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
||||
if rules == "" {
|
||||
return nil
|
||||
}
|
||||
return &ext.BeforeAgentStartResult{
|
||||
SystemPrompt: &rules,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init blocks tool calls that attempt to write, edit, or delete files in
|
||||
// protected paths.
|
||||
//
|
||||
// Protected: .env*, .git/, secrets/, credentials*, *.pem, *.key
|
||||
//
|
||||
// Usage: kit -e examples/extensions/protected-paths.go
|
||||
func Init(api ext.API) {
|
||||
// Tools that modify files.
|
||||
writeTools := map[string]bool{
|
||||
"Write": true,
|
||||
"Edit": true,
|
||||
"Bash": true,
|
||||
}
|
||||
|
||||
// Path patterns to protect (checked against the file_path / filePath field).
|
||||
protectedPatterns := []string{
|
||||
".env",
|
||||
".git/",
|
||||
"secrets/",
|
||||
"credentials",
|
||||
".pem",
|
||||
".key",
|
||||
"id_rsa",
|
||||
"id_ed25519",
|
||||
}
|
||||
|
||||
// Bash commands that could modify protected files.
|
||||
bashWritePatterns := []string{
|
||||
"rm ", "mv ", "cp ", "> ",
|
||||
"cat >", "echo >", "tee ",
|
||||
"chmod ", "chown ",
|
||||
}
|
||||
|
||||
isProtected := func(path string) bool {
|
||||
lower := strings.ToLower(path)
|
||||
for _, p := range protectedPatterns {
|
||||
if strings.Contains(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
api.OnToolCall(func(tc ext.ToolCallEvent, ctx ext.Context) *ext.ToolCallResult {
|
||||
if !writeTools[tc.ToolName] {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For Write/Edit: check the file_path / filePath field.
|
||||
if tc.ToolName == "Write" || tc.ToolName == "Edit" {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
|
||||
return nil
|
||||
}
|
||||
// Try both naming conventions.
|
||||
filePath, _ := input["file_path"].(string)
|
||||
if filePath == "" {
|
||||
filePath, _ = input["filePath"].(string)
|
||||
}
|
||||
if isProtected(filePath) {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "Blocked: writing to protected path: " + filePath,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// For Bash: check if the command references protected paths.
|
||||
if tc.ToolName == "Bash" {
|
||||
var input struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tc.Input), &input); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only check bash commands that look like file mutations.
|
||||
isMutation := false
|
||||
for _, pat := range bashWritePatterns {
|
||||
if strings.Contains(input.Command, pat) {
|
||||
isMutation = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isMutation {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if any protected pattern appears in the command.
|
||||
for _, p := range protectedPatterns {
|
||||
if strings.Contains(input.Command, p) {
|
||||
return &ext.ToolCallResult{
|
||||
Block: true,
|
||||
Reason: "Blocked: bash command references protected path (" + p + "): " + input.Command,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//go:build ignore
|
||||
|
||||
// Subagent Test Extension — Tests the new first-class subagent API
|
||||
//
|
||||
// Commands:
|
||||
//
|
||||
// /subtest <task> — spawn a blocking subagent and print result
|
||||
// /subbg <task> — spawn a background subagent with live output
|
||||
//
|
||||
// Usage: kit -e examples/extensions/subagent-test.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
latestCtx ext.Context
|
||||
hasCtx bool
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Keep context fresh
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
ctx.PrintInfo(
|
||||
"Subagent Test Extension loaded\n\n" +
|
||||
"/subtest <task> Spawn blocking subagent\n" +
|
||||
"/subbg <task> Spawn background subagent\n\n" +
|
||||
"The LLM can also use the spawn_subagent tool.")
|
||||
})
|
||||
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
// Command: /subtest <task> — blocking subagent
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "subtest",
|
||||
Description: "Spawn a blocking subagent: /subtest <task>",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
task := strings.TrimSpace(args)
|
||||
if task == "" {
|
||||
return "Usage: /subtest <task>", nil
|
||||
}
|
||||
|
||||
ctx.PrintInfo(fmt.Sprintf("Spawning blocking subagent for: %s", task))
|
||||
|
||||
start := time.Now()
|
||||
_, result, err := ctx.SpawnSubagent(ext.SubagentConfig{
|
||||
Prompt: task,
|
||||
Timeout: 2 * time.Minute,
|
||||
Blocking: true,
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Spawn error: %v", err), nil
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return "No result returned", nil
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
return fmt.Sprintf("Subagent failed (exit %d) after %ds: %v\n\nPartial output:\n%s",
|
||||
result.ExitCode, int(elapsed.Seconds()), result.Error, truncate(result.Response, 2000)), nil
|
||||
}
|
||||
|
||||
response := fmt.Sprintf("Subagent completed in %ds", int(elapsed.Seconds()))
|
||||
if result.Usage != nil {
|
||||
response += fmt.Sprintf(" (tokens: %d in / %d out)", result.Usage.InputTokens, result.Usage.OutputTokens)
|
||||
}
|
||||
response += fmt.Sprintf("\n\nResult:\n%s", truncate(result.Response, 4000))
|
||||
|
||||
return response, nil
|
||||
},
|
||||
})
|
||||
|
||||
// Command: /subbg <task> — background subagent with callbacks
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "subbg",
|
||||
Description: "Spawn a background subagent: /subbg <task>",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
mu.Lock()
|
||||
latestCtx = ctx
|
||||
hasCtx = true
|
||||
mu.Unlock()
|
||||
|
||||
task := strings.TrimSpace(args)
|
||||
if task == "" {
|
||||
return "Usage: /subbg <task>", nil
|
||||
}
|
||||
|
||||
ctx.PrintInfo(fmt.Sprintf("Spawning background subagent for: %s", task))
|
||||
|
||||
start := time.Now()
|
||||
handle, _, err := ctx.SpawnSubagent(ext.SubagentConfig{
|
||||
Prompt: task,
|
||||
Timeout: 2 * time.Minute,
|
||||
OnOutput: func(chunk string) {
|
||||
// Live output - could update a widget here
|
||||
fmt.Print(chunk)
|
||||
},
|
||||
OnComplete: func(result ext.SubagentResult) {
|
||||
elapsed := time.Since(start)
|
||||
|
||||
mu.Lock()
|
||||
c := latestCtx
|
||||
ok := hasCtx
|
||||
mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
c.SendMessage(fmt.Sprintf("Background subagent failed after %ds: %v",
|
||||
int(elapsed.Seconds()), result.Error))
|
||||
return
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Background subagent completed in %ds", int(elapsed.Seconds()))
|
||||
if result.Usage != nil {
|
||||
msg += fmt.Sprintf(" (tokens: %d in / %d out)", result.Usage.InputTokens, result.Usage.OutputTokens)
|
||||
}
|
||||
msg += fmt.Sprintf("\n\nResult:\n%s", truncate(result.Response, 4000))
|
||||
|
||||
c.SendMessage(msg)
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Spawn error: %v", err), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Background subagent spawned (ID: %s). Results will be delivered when complete.", handle.ID), nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "\n\n... [truncated]"
|
||||
}
|
||||
@@ -35,6 +35,11 @@ import (
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// subJSONOutput matches the JSON envelope produced by `kit --json`.
|
||||
type subJSONOutput struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -204,10 +209,10 @@ func spawnAgent(state *subState) {
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--prompt", prompt,
|
||||
"--quiet",
|
||||
"--json",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
prompt,
|
||||
}
|
||||
|
||||
cmd := exec.Command(kitBinary, args...)
|
||||
@@ -261,7 +266,7 @@ func spawnAgent(state *subState) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Read stderr in background goroutine.
|
||||
// Read stderr in background goroutine (live widget updates).
|
||||
var readWg sync.WaitGroup
|
||||
readWg.Add(1)
|
||||
go func() {
|
||||
@@ -277,12 +282,12 @@ func spawnAgent(state *subState) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Read stdout in foreground.
|
||||
// Read stdout into a separate buffer (JSON output from --json mode).
|
||||
var stdoutBuf strings.Builder
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
state.appendChunk(scanner.Text() + "\n")
|
||||
updateWidgets()
|
||||
stdoutBuf.WriteString(scanner.Text() + "\n")
|
||||
}
|
||||
|
||||
// Wait for all pipe readers, then the process.
|
||||
@@ -290,6 +295,17 @@ func spawnAgent(state *subState) {
|
||||
waitErr := cmd.Wait()
|
||||
close(doneCh) // stop timer
|
||||
|
||||
// Parse JSON output from --json mode to extract the response.
|
||||
var result string
|
||||
rawStdout := strings.TrimSpace(stdoutBuf.String())
|
||||
var parsed subJSONOutput
|
||||
if rawStdout != "" && json.Unmarshal([]byte(rawStdout), &parsed) == nil && parsed.Response != "" {
|
||||
result = parsed.Response
|
||||
} else {
|
||||
// Fallback: use raw stdout (e.g. older kit binary without --json).
|
||||
result = rawStdout
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
state.Elapsed = time.Since(start)
|
||||
state.Proc = nil
|
||||
@@ -298,7 +314,6 @@ func spawnAgent(state *subState) {
|
||||
} else {
|
||||
state.Status = "done"
|
||||
}
|
||||
result := strings.Join(state.Chunks, "")
|
||||
|
||||
// Save history for /subcont continuations (cap at 16 KB).
|
||||
state.History += fmt.Sprintf("\n--- Turn %d ---\nTask: %s\nResult:\n%s\n",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// Init adds a /summarize command that generates a concise summary of the
|
||||
// current conversation using a direct LLM completion. Demonstrates the
|
||||
// ctx.Complete API.
|
||||
//
|
||||
// The summary is displayed in a styled block and can optionally be saved
|
||||
// to the session via AppendEntry for later retrieval.
|
||||
//
|
||||
// Usage: kit -e examples/extensions/summarize.go
|
||||
func Init(api ext.API) {
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "summarize",
|
||||
Description: "Summarize the current conversation",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
msgs := ctx.GetMessages()
|
||||
if len(msgs) == 0 {
|
||||
ctx.PrintInfo("Nothing to summarize — no messages yet.")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Build a text representation of the conversation.
|
||||
var parts []string
|
||||
for _, m := range msgs {
|
||||
content := m.Content
|
||||
if len(content) > 2000 {
|
||||
content = content[:1997] + "..."
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("[%s]: %s", m.Role, content))
|
||||
}
|
||||
conversation := strings.Join(parts, "\n\n")
|
||||
|
||||
ctx.PrintInfo("Generating summary...")
|
||||
|
||||
resp, err := ctx.Complete(ext.CompleteRequest{
|
||||
System: `You are a concise summarization assistant. Summarize the conversation below in 3-5 bullet points. Focus on:
|
||||
- What was discussed or requested
|
||||
- Key decisions or outcomes
|
||||
- Any pending action items
|
||||
|
||||
Be concise. Use plain text, no markdown headers.`,
|
||||
Prompt: conversation,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.PrintError("Summary failed: " + err.Error())
|
||||
return "", nil
|
||||
}
|
||||
|
||||
summary := strings.TrimSpace(resp.Text)
|
||||
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: summary,
|
||||
BorderColor: "#89b4fa",
|
||||
Subtitle: fmt.Sprintf("Summary (%d messages, %d tokens used)", len(msgs), resp.InputTokens+resp.OutputTokens),
|
||||
})
|
||||
|
||||
// Persist the summary in the session for later retrieval.
|
||||
ctx.AppendEntry("summary", summary)
|
||||
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /summaries — list all saved summaries.
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "summaries",
|
||||
Description: "List saved conversation summaries",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
entries := ctx.GetEntries("summary")
|
||||
if len(entries) == 0 {
|
||||
ctx.PrintInfo("No summaries saved yet. Use /summarize to create one.")
|
||||
return "", nil
|
||||
}
|
||||
for i, e := range entries {
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: e.Data,
|
||||
BorderColor: "#89b4fa",
|
||||
Subtitle: fmt.Sprintf("Summary #%d (%s)", i+1, e.Timestamp[:19]),
|
||||
})
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/pkg/extensions/test"
|
||||
)
|
||||
|
||||
// Test that the tool-logger extension loads and registers handlers
|
||||
func TestToolLogger_Loads(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
ext := harness.LoadFile("tool-logger.go")
|
||||
|
||||
if ext == nil {
|
||||
t.Fatal("extension should not be nil")
|
||||
}
|
||||
|
||||
// Verify all expected handlers are registered
|
||||
test.AssertHasHandlers(t, harness, extensions.ToolCall)
|
||||
test.AssertHasHandlers(t, harness, extensions.ToolResult)
|
||||
test.AssertHasHandlers(t, harness, extensions.SessionStart)
|
||||
test.AssertHasHandlers(t, harness, extensions.SessionShutdown)
|
||||
test.AssertHasHandlers(t, harness, extensions.Input)
|
||||
}
|
||||
|
||||
// Test that tool calls are logged (handlers run without errors)
|
||||
func TestToolLogger_ToolCall(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
// Emit a tool call event
|
||||
result, err := harness.Emit(extensions.ToolCallEvent{
|
||||
ToolName: "Read",
|
||||
ToolCallID: "call-123",
|
||||
Input: `{"file": "test.txt"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Tool logger should not block any tools
|
||||
test.AssertNotBlocked(t, result)
|
||||
}
|
||||
|
||||
// Test that tool results are processed
|
||||
func TestToolLogger_ToolResult(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
content := "Hello, World!"
|
||||
result, err := harness.Emit(extensions.ToolResultEvent{
|
||||
ToolName: "Read",
|
||||
Content: content,
|
||||
IsError: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Tool logger should not modify results
|
||||
if result != nil {
|
||||
t.Error("expected nil result (no modification)")
|
||||
}
|
||||
}
|
||||
|
||||
// Test that error tool results are handled
|
||||
func TestToolLogger_ToolResultError(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
result, err := harness.Emit(extensions.ToolResultEvent{
|
||||
ToolName: "Bash",
|
||||
Content: "command not found",
|
||||
IsError: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
t.Error("expected nil result (no modification)")
|
||||
}
|
||||
}
|
||||
|
||||
// Test session start handler
|
||||
func TestToolLogger_SessionStart(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{
|
||||
SessionID: "test-session-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Handler should run without errors (logs to file)
|
||||
// Since file logging happens outside our mock, we just verify no errors
|
||||
}
|
||||
|
||||
// Test session shutdown handler
|
||||
func TestToolLogger_SessionShutdown(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
_, err := harness.Emit(extensions.SessionShutdownEvent{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test the !time command
|
||||
func TestToolLogger_TimeCommand(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
result, err := harness.Emit(extensions.InputEvent{
|
||||
Text: "!time",
|
||||
Source: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
test.AssertInputHandled(t, result, "handled")
|
||||
|
||||
// Verify PrintInfo was called with a time message
|
||||
infos := harness.Context().GetPrintInfos()
|
||||
found := false
|
||||
for _, info := range infos {
|
||||
if strings.Contains(info, "Current time:") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected PrintInfo with 'Current time:', got: %v", infos)
|
||||
}
|
||||
}
|
||||
|
||||
// Test the !status command
|
||||
func TestToolLogger_StatusCommand(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
result, err := harness.Emit(extensions.InputEvent{
|
||||
Text: "!status",
|
||||
Source: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
test.AssertInputHandled(t, result, "handled")
|
||||
|
||||
// Verify PrintBlock was called
|
||||
blocks := harness.Context().PrintBlocks
|
||||
if len(blocks) != 1 {
|
||||
t.Fatalf("expected 1 PrintBlock call, got %d", len(blocks))
|
||||
}
|
||||
|
||||
block := blocks[0]
|
||||
if block.Subtitle != "tool-logger extension" {
|
||||
t.Errorf("expected subtitle 'tool-logger extension', got %q", block.Subtitle)
|
||||
}
|
||||
if block.BorderColor != "#a6e3a1" {
|
||||
t.Errorf("expected border color '#a6e3a1', got %q", block.BorderColor)
|
||||
}
|
||||
if !strings.Contains(block.Text, "Session active") {
|
||||
t.Errorf("expected text to contain 'Session active', got %q", block.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// Test that unknown commands are not handled
|
||||
func TestToolLogger_UnknownCommand(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
result, err := harness.Emit(extensions.InputEvent{
|
||||
Text: "!unknown",
|
||||
Source: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result for unknown command, got %v", result)
|
||||
}
|
||||
|
||||
// Verify no info/block prints for unknown commands
|
||||
if len(harness.Context().GetPrintInfos()) != 0 {
|
||||
t.Error("expected no PrintInfo calls for unknown command")
|
||||
}
|
||||
if len(harness.Context().PrintBlocks) != 0 {
|
||||
t.Error("expected no PrintBlock calls for unknown command")
|
||||
}
|
||||
}
|
||||
|
||||
// Test regular text input (not a command)
|
||||
func TestToolLogger_RegularInput(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
result, err := harness.Emit(extensions.InputEvent{
|
||||
Text: "This is a normal message",
|
||||
Source: "cli",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result for regular input, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Test complete session flow
|
||||
func TestToolLogger_FullSession(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
// Simulate a full session
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Several tool calls
|
||||
tools := []string{"Read", "Glob", "Grep", "Bash"}
|
||||
for _, tool := range tools {
|
||||
_, err := harness.Emit(extensions.ToolCallEvent{
|
||||
ToolName: tool,
|
||||
Input: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("error for tool %s: %v", tool, err)
|
||||
}
|
||||
|
||||
_, err = harness.Emit(extensions.ToolResultEvent{
|
||||
ToolName: tool,
|
||||
Content: "result",
|
||||
IsError: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("error for tool result %s: %v", tool, err)
|
||||
}
|
||||
}
|
||||
|
||||
// User issues a command
|
||||
_, err = harness.Emit(extensions.InputEvent{Text: "!time", Source: "cli"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
_, err = harness.Emit(extensions.SessionShutdownEvent{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify the !time command was handled
|
||||
if len(harness.Context().GetPrintInfos()) != 1 {
|
||||
t.Errorf("expected 1 PrintInfo call, got %d", len(harness.Context().GetPrintInfos()))
|
||||
}
|
||||
}
|
||||
|
||||
// Test that the extension handles file write errors gracefully
|
||||
func TestToolLogger_FileError(t *testing.T) {
|
||||
// This test verifies the extension doesn't panic when file operations fail
|
||||
// Since we can't easily mock os.OpenFile, we rely on the extension code
|
||||
// properly checking for errors (which it does)
|
||||
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
// Just verify the handlers run without panicking
|
||||
_, err := harness.Emit(extensions.ToolCallEvent{ToolName: "Read", Input: "{}"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
_, err = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test concurrent tool calls (race condition check)
|
||||
func TestToolLogger_ConcurrentToolCalls(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
// Run multiple tool calls concurrently
|
||||
done := make(chan bool, 10)
|
||||
for i := range 10 {
|
||||
go func(index int) {
|
||||
defer func() { done <- true }()
|
||||
|
||||
toolName := "Tool" + string(rune('0'+index))
|
||||
_, err := harness.Emit(extensions.ToolCallEvent{
|
||||
ToolName: toolName,
|
||||
Input: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("error in goroutine %d: %v", index, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for range 10 {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// Test the actual log file is created and written to
|
||||
func TestToolLogger_LogFile(t *testing.T) {
|
||||
logFile := "/tmp/kit-tool-log.txt"
|
||||
|
||||
// Clean up before test
|
||||
_ = os.Remove(logFile)
|
||||
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("tool-logger.go")
|
||||
|
||||
// Emit events
|
||||
_, _ = harness.Emit(extensions.SessionStartEvent{SessionID: "test"})
|
||||
_, _ = harness.Emit(extensions.ToolCallEvent{ToolName: "Read", Input: "{}"})
|
||||
_, _ = harness.Emit(extensions.ToolResultEvent{ToolName: "Read", Content: "data", IsError: false})
|
||||
|
||||
// Note: Since the extension writes to a real file and the test harness
|
||||
// mocks the context, the file writes actually happen. Let's verify.
|
||||
|
||||
// Give it a moment for file operations
|
||||
if _, err := os.Stat(logFile); err == nil {
|
||||
// File exists - read and verify content
|
||||
content, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
t.Logf("Could not read log file: %v", err)
|
||||
} else {
|
||||
contentStr := string(content)
|
||||
if !strings.Contains(contentStr, "SESSION_START") {
|
||||
t.Error("log file should contain SESSION_START")
|
||||
}
|
||||
if !strings.Contains(contentStr, "CALL tool=Read") {
|
||||
t.Error("log file should contain CALL tool=Read")
|
||||
}
|
||||
if !strings.Contains(contentStr, "RESULT tool=Read") {
|
||||
t.Error("log file should contain RESULT tool=Read")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
t.Log("Note: Log file not created - this is expected since the extension writes directly to disk")
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func Init(api ext.API) {
|
||||
DisplayName: "File",
|
||||
BorderColor: "#89b4fa", // Catppuccin blue
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args map[string]interface{}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func Init(api ext.API) {
|
||||
Background: "#1e1e2e", // Dark background
|
||||
BorderColor: "#a6e3a1", // Catppuccin green
|
||||
RenderHeader: func(toolArgs string, width int) string {
|
||||
var args map[string]interface{}
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
module github.com/mark3labs/kit
|
||||
|
||||
go 1.26.0
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.0.0
|
||||
charm.land/bubbletea/v2 v2.0.0
|
||||
charm.land/fantasy v0.10.0
|
||||
charm.land/lipgloss/v2 v2.0.0
|
||||
github.com/charmbracelet/fang v0.4.4
|
||||
github.com/mark3labs/mcp-go v0.44.0
|
||||
charm.land/bubbletea/v2 v2.0.2
|
||||
charm.land/fantasy v0.16.0
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.2
|
||||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/fang v1.0.0
|
||||
github.com/charmbracelet/log v1.0.0
|
||||
github.com/coder/acp-go-sdk v0.6.3
|
||||
github.com/mark3labs/mcp-go v0.45.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/term v0.40.0
|
||||
github.com/traefik/yaegi v0.16.1
|
||||
golang.org/x/term v0.41.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -22,47 +28,49 @@ require (
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
|
||||
github.com/aws/smithy-go v1.24.1 // indirect
|
||||
github.com/aymanbagabas/go-udiff v0.4.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/buger/jsonparser v1.1.2 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/log v0.4.2 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 // indirect
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260223200540-d6a276319c45 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260223200540-d6a276319c45 // indirect
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260322003602-9b007323c5cd // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260322003602-9b007323c5cd // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/json v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
@@ -71,23 +79,23 @@ require (
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/kaptinlin/go-i18n v0.2.11 // indirect
|
||||
github.com/kaptinlin/jsonpointer v0.4.16 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.7.3 // indirect
|
||||
github.com/kaptinlin/go-i18n v0.2.12 // indirect
|
||||
github.com/kaptinlin/jsonpointer v0.4.17 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.7.6 // indirect
|
||||
github.com/kaptinlin/messageformat-go v0.4.18 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/mango v0.2.0 // indirect
|
||||
github.com/muesli/mango-cobra v1.3.0 // indirect
|
||||
github.com/muesli/mango-pflag v0.2.0 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/roff v0.1.0 // indirect
|
||||
github.com/openai/openai-go/v2 v2.7.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
@@ -97,46 +105,45 @@ require (
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/traefik/yaegi v0.16.1 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
github.com/yuin/goldmark v1.7.17 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/api v0.269.0 // indirect
|
||||
google.golang.org/genai v1.47.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||
google.golang.org/grpc v1.79.1 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.272.0 // indirect
|
||||
google.golang.org/genai v1.51.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/glamour v1.0.0
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0
|
||||
)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
|
||||
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
|
||||
charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
|
||||
charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
||||
charm.land/fantasy v0.10.0 h1:6PD+1rrsCgLIG1n+PAZp/gHiC0dltU0cvb7c8zUKyu8=
|
||||
charm.land/fantasy v0.10.0/go.mod h1:KIeNQUpJTswwpY0P6HJsr3LBFgfTDb8FDpOdVQMsKqY=
|
||||
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
|
||||
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
|
||||
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
|
||||
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
||||
charm.land/fantasy v0.16.0 h1:vE/6sR9nPcSD8qXJXX6wR8NXjtWlBVAzwQmTh5pHVrs=
|
||||
charm.land/fantasy v0.16.0/go.mod h1:VZjpXVh7IgeiIzGQybEnKzd68ofDsRj94+kzH1ZCAfQ=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
|
||||
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
@@ -32,74 +34,86 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
|
||||
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
|
||||
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
|
||||
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
|
||||
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
|
||||
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
|
||||
github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
|
||||
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ=
|
||||
github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
|
||||
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
||||
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
|
||||
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 h1:Af/L28Xh+pddhouT/6lJ7IAIYfu5tWJOB0iqt+mXsYM=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
|
||||
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
|
||||
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 h1:BW/sZtyd1JyYy0h5adMm3tzpNyL857LWjuTRET6OhpY=
|
||||
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502 h1:hzWNs3UQRSUTS6YCbLaQnwqKBFXT5Yh1OOw6+26apqg=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502/go.mod h1:mkUCcxn9w9j89JJp3pOza5tmDQZPgIB75UfmQlFYvas=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260223200540-d6a276319c45 h1:t/EWU3ZOrVxmr2d19f+1wnWr92p1O82oOTm7ASxodsA=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260223200540-d6a276319c45/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260322003602-9b007323c5cd h1:eStB6uX52pgrm6TxQcEKctPrEC+a/9ubJC+P671idOc=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260322003602-9b007323c5cd/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260223200540-d6a276319c45 h1:jgQlAnMmwbjtvd91AzjWWFtwpIZ2P/Nspx5zyrhmPec=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260223200540-d6a276319c45/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260322003602-9b007323c5cd h1:U8xj0UXwqHzO+UYHZJopKF+gWaQEW8oj60fmiq9TFY4=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260322003602-9b007323c5cd/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||
github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
|
||||
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
|
||||
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
|
||||
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
@@ -108,19 +122,27 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/coder/acp-go-sdk v0.6.3 h1:LsXQytehdjKIYJnoVWON/nf7mqbiarnyuyE3rrjBsXQ=
|
||||
github.com/coder/acp-go-sdk v0.6.3/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
|
||||
@@ -134,8 +156,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
|
||||
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -155,10 +177,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=
|
||||
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -169,12 +191,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/kaptinlin/go-i18n v0.2.11 h1:OayNt8mWt8nDaqAOp09/C1VG9Y5u8LpQnnxbyGARDV4=
|
||||
github.com/kaptinlin/go-i18n v0.2.11/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
|
||||
github.com/kaptinlin/jsonpointer v0.4.16 h1:Ux4w4FY+uLv+K+TxaCJtM/TpPv+1+eS6gH4Z9/uhOuA=
|
||||
github.com/kaptinlin/jsonpointer v0.4.16/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=
|
||||
github.com/kaptinlin/jsonschema v0.7.3 h1:kyIydij76ORiSxmfy0xFYy0cOx8MwG6pyyaSoQshsK4=
|
||||
github.com/kaptinlin/jsonschema v0.7.3/go.mod h1:Ys6zr+W6/1330FzZEouFrAYImK+AmYt5HQVTHQQXQo8=
|
||||
github.com/kaptinlin/go-i18n v0.2.12 h1:ywDsvb4KDFddMC2dpI/rrIzGU2mWUSvHmWUm9BMsdl4=
|
||||
github.com/kaptinlin/go-i18n v0.2.12/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
|
||||
github.com/kaptinlin/jsonpointer v0.4.17 h1:mY9k8ciWncxbsECyaxKnR0MdmxamNdp2tLQkAKVrtSk=
|
||||
github.com/kaptinlin/jsonpointer v0.4.17/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=
|
||||
github.com/kaptinlin/jsonschema v0.7.6 h1:UUMqZGFAk7nOzQsYAxvgygm4wpDp/nwXxA4VP9mCPCs=
|
||||
github.com/kaptinlin/jsonschema v0.7.6/go.mod h1:GGk/oE+F1lWUfYrzKaCf4QWZmMdytt0LL4XdFEFB0LE=
|
||||
github.com/kaptinlin/messageformat-go v0.4.18 h1:RBlHVWgZyoxTcUgGWBsl2AcyScq/urqbLZvzgryTmSI=
|
||||
github.com/kaptinlin/messageformat-go v0.4.18/go.mod h1:ntI3154RnqJgr7GaC+vZBnIExl2V3sv9selvRNNEM24=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -185,17 +207,19 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mark3labs/mcp-go v0.44.0 h1:OlYfcVviAnwNN40QZUrrzU0QZjq3En7rCU5X09a/B7I=
|
||||
github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mark3labs/mcp-go v0.45.0 h1:s0S8qR/9fWaQ3pHxz7pm1uQ0DrswoSnRIxKIjbiQtkc=
|
||||
github.com/mark3labs/mcp-go v0.45.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
|
||||
@@ -210,8 +234,6 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
||||
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
|
||||
github.com/openai/openai-go/v2 v2.7.1/go.mod h1:jrJs23apqJKKbT+pqtFgNKpRju/KP9zpUTZhz3GElQE=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
@@ -263,57 +285,57 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
|
||||
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
|
||||
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
|
||||
google.golang.org/genai v1.47.0 h1:iWCS7gEdO6rctOqfCYLOrZGKu2D+N42aTnCEcBvB1jo=
|
||||
google.golang.org/genai v1.47.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
|
||||
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=
|
||||
google.golang.org/genai v1.51.0 h1:IZGuUqgfx40INv3hLFGCbOSGp0qFqm7LVmDghzNIYqg=
|
||||
google.golang.org/genai v1.51.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// Package acpserver implements a Kit-backed ACP (Agent Client Protocol) agent.
|
||||
//
|
||||
// It bridges Kit's LLM execution, tool system, and session management to the
|
||||
// ACP protocol over stdio, allowing ACP clients (such as OpenCode) to drive
|
||||
// Kit as a remote coding agent.
|
||||
package acpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
acp "github.com/coder/acp-go-sdk"
|
||||
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// Version is injected at build time; fallback to "dev".
|
||||
var Version = "dev"
|
||||
|
||||
// Agent implements the acp.Agent interface, delegating to Kit for LLM
|
||||
// execution, tool calls, and session management.
|
||||
type Agent struct {
|
||||
conn *acp.AgentSideConnection
|
||||
registry *sessionRegistry
|
||||
|
||||
// toolCallCounter provides unique IDs for tool calls within a turn.
|
||||
toolCallCounter atomic.Int64
|
||||
}
|
||||
|
||||
// NewAgent creates a new ACP agent backed by Kit.
|
||||
func NewAgent() *Agent {
|
||||
return &Agent{
|
||||
registry: newSessionRegistry(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetAgentConnection stores the connection so the agent can send session
|
||||
// updates (streaming, tool calls, etc.) back to the ACP client. This follows
|
||||
// the AgentConnAware duck-typing pattern from the SDK.
|
||||
func (a *Agent) SetAgentConnection(conn *acp.AgentSideConnection) {
|
||||
a.conn = conn
|
||||
}
|
||||
|
||||
// Close shuts down all active sessions.
|
||||
func (a *Agent) Close() {
|
||||
a.registry.closeAll()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// acp.Agent interface implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Authenticate handles authentication requests. Kit doesn't require auth for
|
||||
// local stdio usage, so this is a no-op.
|
||||
func (a *Agent) Authenticate(_ context.Context, _ acp.AuthenticateRequest) (acp.AuthenticateResponse, error) {
|
||||
return acp.AuthenticateResponse{}, nil
|
||||
}
|
||||
|
||||
// Initialize negotiates capabilities with the ACP client.
|
||||
func (a *Agent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) {
|
||||
log.Debug("acp: initialize", "protocol_version", params.ProtocolVersion)
|
||||
|
||||
return acp.InitializeResponse{
|
||||
ProtocolVersion: acp.ProtocolVersion(1),
|
||||
AgentCapabilities: acp.AgentCapabilities{
|
||||
LoadSession: true,
|
||||
PromptCapabilities: acp.PromptCapabilities{
|
||||
EmbeddedContext: true,
|
||||
Image: true,
|
||||
},
|
||||
},
|
||||
AgentInfo: &acp.Implementation{
|
||||
Name: "Kit",
|
||||
Version: Version,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewSession creates a new Kit session for the given working directory.
|
||||
func (a *Agent) NewSession(ctx context.Context, params acp.NewSessionRequest) (acp.NewSessionResponse, error) {
|
||||
cwd := params.Cwd
|
||||
if cwd == "" {
|
||||
return acp.NewSessionResponse{}, acp.NewInvalidParams("cwd is required")
|
||||
}
|
||||
|
||||
log.Debug("acp: new_session", "cwd", cwd)
|
||||
|
||||
sess, err := a.registry.create(ctx, cwd)
|
||||
if err != nil {
|
||||
log.Error("acp: session creation failed", "cwd", cwd, "error", err)
|
||||
return acp.NewSessionResponse{}, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
|
||||
return acp.NewSessionResponse{
|
||||
SessionId: acp.SessionId(sess.sessionID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Prompt handles the main agent execution. It subscribes to Kit's event bus,
|
||||
// converts events to ACP session updates, and runs the prompt through Kit's
|
||||
// full turn lifecycle (hooks, LLM, tool calls, persistence).
|
||||
func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.PromptResponse, error) {
|
||||
sessionID := string(params.SessionId)
|
||||
sess, ok := a.registry.get(sessionID)
|
||||
if !ok {
|
||||
return acp.PromptResponse{}, acp.NewInvalidParams(
|
||||
fmt.Sprintf("session not found: %s", sessionID),
|
||||
)
|
||||
}
|
||||
|
||||
// Extract text from prompt content blocks.
|
||||
promptText := extractPromptText(params.Prompt)
|
||||
if promptText == "" {
|
||||
return acp.PromptResponse{}, acp.NewInvalidParams("empty prompt")
|
||||
}
|
||||
|
||||
log.Debug("acp: prompt", "session", sessionID, "prompt_len", len(promptText))
|
||||
|
||||
// Create a cancellable context for this prompt turn.
|
||||
promptCtx, cancel := context.WithCancel(ctx)
|
||||
sess.setCancel(cancel)
|
||||
defer sess.clearCancel()
|
||||
|
||||
// Subscribe to Kit events and stream them as ACP session updates.
|
||||
unsub := a.subscribeEvents(promptCtx, sess.kit, params.SessionId)
|
||||
defer unsub()
|
||||
|
||||
// Run the prompt through Kit's full turn lifecycle.
|
||||
_, err := sess.kit.PromptResult(promptCtx, promptText)
|
||||
if err != nil {
|
||||
if promptCtx.Err() != nil {
|
||||
return acp.PromptResponse{
|
||||
StopReason: acp.StopReasonCancelled,
|
||||
}, nil
|
||||
}
|
||||
return acp.PromptResponse{}, fmt.Errorf("prompt failed: %w", err)
|
||||
}
|
||||
|
||||
return acp.PromptResponse{
|
||||
StopReason: acp.StopReasonEndTurn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Cancel cancels the ongoing prompt for a session.
|
||||
func (a *Agent) Cancel(_ context.Context, params acp.CancelNotification) error {
|
||||
sessionID := string(params.SessionId)
|
||||
sess, ok := a.registry.get(sessionID)
|
||||
if !ok {
|
||||
return nil // No-op if session doesn't exist.
|
||||
}
|
||||
|
||||
log.Debug("acp: cancel", "session", sessionID)
|
||||
sess.cancelPrompt()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSessionMode is a no-op for now — Kit doesn't have built-in session modes.
|
||||
func (a *Agent) SetSessionMode(_ context.Context, _ acp.SetSessionModeRequest) (acp.SetSessionModeResponse, error) {
|
||||
return acp.SetSessionModeResponse{}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event streaming: Kit events → ACP SessionUpdate notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// subscribeEvents subscribes to Kit's event bus and forwards events as ACP
|
||||
// session update notifications to the client.
|
||||
func (a *Agent) subscribeEvents(ctx context.Context, k *kit.Kit, sessionID acp.SessionId) func() {
|
||||
return k.Subscribe(func(e kit.Event) {
|
||||
// Don't send updates after the context is cancelled.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var update *acp.SessionUpdate
|
||||
switch ev := e.(type) {
|
||||
case kit.MessageUpdateEvent:
|
||||
u := acp.UpdateAgentMessageText(ev.Chunk)
|
||||
update = &u
|
||||
|
||||
case kit.ReasoningDeltaEvent:
|
||||
u := acp.UpdateAgentThoughtText(ev.Delta)
|
||||
update = &u
|
||||
|
||||
case kit.ToolCallEvent:
|
||||
tcID := acp.ToolCallId(ev.ToolCallID)
|
||||
if tcID == "" {
|
||||
tcID = acp.ToolCallId(fmt.Sprintf("tc_%d", a.toolCallCounter.Add(1)))
|
||||
}
|
||||
u := acp.StartToolCall(tcID, ev.ToolName,
|
||||
acp.WithStartStatus(acp.ToolCallStatusInProgress),
|
||||
acp.WithStartRawInput(parseToolArgs(ev.ToolArgs)),
|
||||
)
|
||||
update = &u
|
||||
|
||||
case kit.ToolResultEvent:
|
||||
tcID := acp.ToolCallId(ev.ToolCallID)
|
||||
if tcID == "" {
|
||||
tcID = acp.ToolCallId(fmt.Sprintf("tc_%d", a.toolCallCounter.Load()))
|
||||
}
|
||||
status := acp.ToolCallStatusCompleted
|
||||
if ev.IsError {
|
||||
status = acp.ToolCallStatusFailed
|
||||
}
|
||||
u := acp.UpdateToolCall(tcID,
|
||||
acp.WithUpdateStatus(status),
|
||||
acp.WithUpdateContent([]acp.ToolCallContent{
|
||||
acp.ToolContent(acp.TextBlock(ev.Result)),
|
||||
}),
|
||||
)
|
||||
update = &u
|
||||
|
||||
case kit.ToolCallContentEvent:
|
||||
u := acp.UpdateAgentMessageText(ev.Content)
|
||||
update = &u
|
||||
}
|
||||
|
||||
if update != nil {
|
||||
_ = a.conn.SessionUpdate(ctx, acp.SessionNotification{
|
||||
SessionId: sessionID,
|
||||
Update: *update,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// extractPromptText extracts the concatenated text content from ACP content
|
||||
// blocks. Non-text blocks are ignored for now.
|
||||
func extractPromptText(blocks []acp.ContentBlock) string {
|
||||
var text string
|
||||
for _, block := range blocks {
|
||||
if block.Text != nil {
|
||||
if text != "" {
|
||||
text += "\n"
|
||||
}
|
||||
text += block.Text.Text
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// parseToolArgs attempts to parse a JSON tool args string into a map for
|
||||
// structured display. Falls back to a simple string wrapper.
|
||||
func parseToolArgs(args string) any {
|
||||
if args == "" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(args), &m); err == nil {
|
||||
return m
|
||||
}
|
||||
return map[string]any{"input": args}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package acpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// acpSession maps an ACP session to a Kit instance with its own tree session.
|
||||
type acpSession struct {
|
||||
kit *kit.Kit
|
||||
cancelFn context.CancelFunc // cancels the current prompt
|
||||
cancelMu sync.Mutex
|
||||
cwd string
|
||||
sessionID string // Kit-generated session ID (from JSONL header)
|
||||
}
|
||||
|
||||
// sessionRegistry is a thread-safe registry of ACP session ID → Kit sessions.
|
||||
type sessionRegistry struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*acpSession // ACP session ID → session
|
||||
}
|
||||
|
||||
func newSessionRegistry() *sessionRegistry {
|
||||
return &sessionRegistry{
|
||||
sessions: make(map[string]*acpSession),
|
||||
}
|
||||
}
|
||||
|
||||
// create creates a new Kit instance with a persisted tree session for the
|
||||
// given working directory. The Kit-generated session ID is used as the ACP
|
||||
// session ID so the mapping is 1:1.
|
||||
func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession, error) {
|
||||
kitInstance, err := kit.New(ctx, &kit.Options{
|
||||
SessionDir: cwd,
|
||||
Quiet: true,
|
||||
Streaming: true,
|
||||
})
|
||||
if err != nil {
|
||||
// Provide actionable guidance for provider auth errors, which are
|
||||
// the most common failure mode when running via ACP.
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "API key") || strings.Contains(msg, "credentials") || strings.Contains(msg, "OAuth") {
|
||||
return nil, fmt.Errorf("provider authentication failed: %w — run 'kit auth login <provider>' or set the appropriate environment variable before starting 'kit acp'", err)
|
||||
}
|
||||
return nil, fmt.Errorf("create kit instance: %w", err)
|
||||
}
|
||||
|
||||
sessionID := kitInstance.GetSessionID()
|
||||
if sessionID == "" {
|
||||
_ = kitInstance.Close()
|
||||
return nil, fmt.Errorf("kit instance has no session ID")
|
||||
}
|
||||
|
||||
// Wire extension context with headless implementations so extensions
|
||||
// work in ACP mode. TUI-dependent features (widgets, prompts, editor)
|
||||
// become no-ops or return cancelled; all data/model/tool APIs work
|
||||
// identically to interactive mode.
|
||||
if kitInstance.HasExtensions() {
|
||||
kitInstance.SetExtensionContext(extensions.Context{
|
||||
SessionID: sessionID,
|
||||
CWD: cwd,
|
||||
Model: kitInstance.GetModelString(),
|
||||
Interactive: false,
|
||||
|
||||
// Output — route through structured logger.
|
||||
Print: func(text string) { log.Debug("extension: print", "text", text) },
|
||||
PrintInfo: func(text string) { log.Info("extension: info", "text", text) },
|
||||
PrintError: func(text string) { log.Error("extension: error", "text", text) },
|
||||
PrintBlock: func(opts extensions.PrintBlockOpts) {
|
||||
log.Info("extension: block", "subtitle", opts.Subtitle, "text", opts.Text)
|
||||
},
|
||||
|
||||
// Message injection — no-ops for now; ACP clients drive prompts.
|
||||
SendMessage: func(string) {},
|
||||
CancelAndSend: func(string) {},
|
||||
Exit: func() {},
|
||||
|
||||
// TUI widgets/chrome — silent no-ops (no TUI in ACP).
|
||||
SetWidget: func(extensions.WidgetConfig) {},
|
||||
RemoveWidget: func(string) {},
|
||||
SetHeader: func(extensions.HeaderFooterConfig) {},
|
||||
RemoveHeader: func() {},
|
||||
SetFooter: func(extensions.HeaderFooterConfig) {},
|
||||
RemoveFooter: func() {},
|
||||
SetEditor: func(extensions.EditorConfig) {},
|
||||
ResetEditor: func() {},
|
||||
SetEditorText: func(string) {},
|
||||
SetUIVisibility: func(extensions.UIVisibility) {},
|
||||
SetStatus: func(string, string, int) {},
|
||||
RemoveStatus: func(string) {},
|
||||
|
||||
// Interactive prompts — return cancelled (no user to prompt).
|
||||
PromptSelect: func(extensions.PromptSelectConfig) extensions.PromptSelectResult {
|
||||
return extensions.PromptSelectResult{Cancelled: true}
|
||||
},
|
||||
PromptConfirm: func(extensions.PromptConfirmConfig) extensions.PromptConfirmResult {
|
||||
return extensions.PromptConfirmResult{Cancelled: true}
|
||||
},
|
||||
PromptInput: func(extensions.PromptInputConfig) extensions.PromptInputResult {
|
||||
return extensions.PromptInputResult{Cancelled: true}
|
||||
},
|
||||
ShowOverlay: func(extensions.OverlayConfig) extensions.OverlayResult {
|
||||
return extensions.OverlayResult{Cancelled: true, Index: -1}
|
||||
},
|
||||
SuspendTUI: func(callback func()) error { callback(); return nil },
|
||||
|
||||
// Data access — delegate to Kit instance.
|
||||
GetContextStats: func() extensions.ContextStats {
|
||||
s := kitInstance.GetContextStats()
|
||||
return extensions.ContextStats{
|
||||
EstimatedTokens: s.EstimatedTokens,
|
||||
ContextLimit: s.ContextLimit,
|
||||
UsagePercent: s.UsagePercent,
|
||||
MessageCount: s.MessageCount,
|
||||
}
|
||||
},
|
||||
GetMessages: func() []extensions.SessionMessage { return kitInstance.GetSessionMessages() },
|
||||
GetSessionPath: func() string { return kitInstance.GetSessionFilePath() },
|
||||
AppendEntry: func(entryType, data string) (string, error) {
|
||||
return kitInstance.AppendExtensionEntry(entryType, data)
|
||||
},
|
||||
GetEntries: func(entryType string) []extensions.ExtensionEntry {
|
||||
return kitInstance.GetExtensionEntries(entryType)
|
||||
},
|
||||
|
||||
// Options, model, and tool management.
|
||||
GetOption: func(name string) string { return kitInstance.GetExtensionOption(name) },
|
||||
SetOption: func(name, value string) { kitInstance.SetExtensionOption(name, value) },
|
||||
SetModel: func(modelString string) error {
|
||||
previousModel := kitInstance.GetExtensionContext().Model
|
||||
if err := kitInstance.SetModel(context.Background(), modelString); err != nil {
|
||||
return err
|
||||
}
|
||||
kitInstance.UpdateExtensionContextModel(modelString)
|
||||
kitInstance.EmitModelChange(modelString, previousModel, "extension")
|
||||
return nil
|
||||
},
|
||||
GetAvailableModels: func() []extensions.ModelInfoEntry { return kitInstance.GetAvailableModels() },
|
||||
EmitCustomEvent: func(name, data string) { kitInstance.EmitExtensionCustomEvent(name, data) },
|
||||
GetAllTools: func() []extensions.ToolInfo { return kitInstance.GetExtensionToolInfos() },
|
||||
SetActiveTools: func(names []string) { kitInstance.SetExtensionActiveTools(names) },
|
||||
|
||||
// LLM completions and subagents.
|
||||
Complete: func(req extensions.CompleteRequest) (extensions.CompleteResponse, error) {
|
||||
return kitInstance.ExecuteCompletion(context.Background(), req)
|
||||
},
|
||||
SpawnSubagent: func(config extensions.SubagentConfig) (*extensions.SubagentHandle, *extensions.SubagentResult, error) {
|
||||
sdkCfg := kit.SubagentConfig{
|
||||
Prompt: config.Prompt,
|
||||
Model: config.Model,
|
||||
SystemPrompt: config.SystemPrompt,
|
||||
Timeout: config.Timeout,
|
||||
NoSession: config.NoSession,
|
||||
}
|
||||
if config.OnEvent != nil {
|
||||
sdkCfg.OnEvent = func(e kit.Event) {
|
||||
se := sdkEventToSubagentEvent(e)
|
||||
if se.Type != "" {
|
||||
config.OnEvent(se)
|
||||
}
|
||||
}
|
||||
}
|
||||
result, err := kitInstance.Subagent(context.Background(), sdkCfg)
|
||||
if result == nil {
|
||||
return nil, &extensions.SubagentResult{Error: err}, err
|
||||
}
|
||||
extResult := &extensions.SubagentResult{
|
||||
Response: result.Response,
|
||||
Error: result.Error,
|
||||
SessionID: result.SessionID,
|
||||
Elapsed: result.Elapsed,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
extResult.Usage = &extensions.SubagentUsage{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
return nil, extResult, err
|
||||
},
|
||||
|
||||
// Render — fall back to logging.
|
||||
RenderMessage: func(name, content string) {
|
||||
renderer := kitInstance.GetExtensionMessageRenderer(name)
|
||||
if renderer != nil && renderer.Render != nil {
|
||||
content = renderer.Render(content, 80)
|
||||
}
|
||||
log.Info("extension: message", "renderer", name, "content", content)
|
||||
},
|
||||
ReloadExtensions: func() error { return kitInstance.ReloadExtensions() },
|
||||
})
|
||||
kitInstance.EmitSessionStart()
|
||||
}
|
||||
|
||||
sess := &acpSession{
|
||||
kit: kitInstance,
|
||||
cwd: cwd,
|
||||
sessionID: sessionID,
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.sessions[sessionID] = sess
|
||||
r.mu.Unlock()
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// get retrieves a session by ACP session ID.
|
||||
func (r *sessionRegistry) get(sessionID string) (*acpSession, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
s, ok := r.sessions[sessionID]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// closeAll closes all sessions.
|
||||
func (r *sessionRegistry) closeAll() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for id, sess := range r.sessions {
|
||||
if sess.kit != nil {
|
||||
_ = sess.kit.Close()
|
||||
}
|
||||
delete(r.sessions, id)
|
||||
}
|
||||
}
|
||||
|
||||
// cancelPrompt cancels the current prompt for a session, if any.
|
||||
func (s *acpSession) cancelPrompt() {
|
||||
s.cancelMu.Lock()
|
||||
defer s.cancelMu.Unlock()
|
||||
if s.cancelFn != nil {
|
||||
s.cancelFn()
|
||||
s.cancelFn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// setCancel stores a cancel function for the current prompt.
|
||||
func (s *acpSession) setCancel(cancel context.CancelFunc) {
|
||||
s.cancelMu.Lock()
|
||||
defer s.cancelMu.Unlock()
|
||||
s.cancelFn = cancel
|
||||
}
|
||||
|
||||
// clearCancel clears the stored cancel function (called when prompt completes).
|
||||
func (s *acpSession) clearCancel() {
|
||||
s.cancelMu.Lock()
|
||||
defer s.cancelMu.Unlock()
|
||||
s.cancelFn = nil
|
||||
}
|
||||
|
||||
// sdkEventToSubagentEvent converts an SDK event to an extension SubagentEvent.
|
||||
func sdkEventToSubagentEvent(e kit.Event) extensions.SubagentEvent {
|
||||
switch ev := e.(type) {
|
||||
case kit.MessageUpdateEvent:
|
||||
return extensions.SubagentEvent{Type: "text", Content: ev.Chunk}
|
||||
case kit.ReasoningDeltaEvent:
|
||||
return extensions.SubagentEvent{Type: "reasoning", Content: ev.Delta}
|
||||
case kit.ToolCallEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_call", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind, ToolArgs: ev.ToolArgs,
|
||||
}
|
||||
case kit.ToolExecutionStartEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_start", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolExecutionEndEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_execution_end", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
}
|
||||
case kit.ToolResultEvent:
|
||||
return extensions.SubagentEvent{
|
||||
Type: "tool_result", ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName, ToolKind: ev.ToolKind,
|
||||
ToolResult: ev.Result, IsError: ev.IsError,
|
||||
}
|
||||
case kit.TurnStartEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_start"}
|
||||
case kit.TurnEndEvent:
|
||||
return extensions.SubagentEvent{Type: "turn_end"}
|
||||
default:
|
||||
return extensions.SubagentEvent{}
|
||||
}
|
||||
}
|
||||
+183
-20
@@ -41,13 +41,15 @@ type AgentConfig struct {
|
||||
}
|
||||
|
||||
// ToolCallHandler is a function type for handling tool calls as they happen.
|
||||
type ToolCallHandler func(toolName, toolArgs string)
|
||||
type ToolCallHandler func(toolCallID, toolName, toolArgs string)
|
||||
|
||||
// ToolExecutionHandler is a function type for handling tool execution start/end events.
|
||||
type ToolExecutionHandler func(toolName string, isStarting bool)
|
||||
type ToolExecutionHandler func(toolCallID, toolName, toolArgs string, isStarting bool)
|
||||
|
||||
// ToolResultHandler is a function type for handling tool results.
|
||||
type ToolResultHandler func(toolName, toolArgs, result string, isError bool)
|
||||
// The metadata parameter carries optional structured data (e.g. file diff
|
||||
// info) from the tool execution, JSON-encoded. It may be empty.
|
||||
type ToolResultHandler func(toolCallID, toolName, toolArgs, result, metadata string, isError bool)
|
||||
|
||||
// ResponseHandler is a function type for handling LLM responses.
|
||||
type ResponseHandler func(content string)
|
||||
@@ -58,6 +60,9 @@ type StreamingResponseHandler func(content string)
|
||||
// ToolCallContentHandler is a function type for handling content that accompanies tool calls.
|
||||
type ToolCallContentHandler func(content string)
|
||||
|
||||
// ReasoningDeltaHandler is a function type for handling streaming reasoning/thinking deltas.
|
||||
type ReasoningDeltaHandler func(delta string)
|
||||
|
||||
// Agent represents an AI agent with core tool integration using the fantasy library.
|
||||
// Core tools (bash, read, write, edit, grep, find, ls) are registered as direct
|
||||
// fantasy.AgentTool implementations — no MCP layer, no serialization overhead.
|
||||
@@ -74,6 +79,7 @@ type Agent struct {
|
||||
streamingEnabled bool
|
||||
coreTools []fantasy.AgentTool
|
||||
extraTools []fantasy.AgentTool
|
||||
toolWrapper func([]fantasy.AgentTool) []fantasy.AgentTool // stored for SetModel rebuild
|
||||
}
|
||||
|
||||
// GenerateWithLoopResult contains the result and conversation history from an agent interaction.
|
||||
@@ -86,6 +92,8 @@ type GenerateWithLoopResult struct {
|
||||
Messages []message.Message
|
||||
// TotalUsage contains aggregate token usage across all steps
|
||||
TotalUsage fantasy.Usage
|
||||
// StopReason is the LLM provider's finish reason for the final response.
|
||||
StopReason string
|
||||
}
|
||||
|
||||
// NewAgent creates a new Agent with core tools and optional MCP tool integration.
|
||||
@@ -156,6 +164,27 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
))
|
||||
}
|
||||
|
||||
// Pass provider-specific options (e.g. OpenAI Responses API reasoning settings).
|
||||
if providerResult.ProviderOptions != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithProviderOptions(providerResult.ProviderOptions))
|
||||
}
|
||||
|
||||
// Pass generation parameters when available.
|
||||
if agentConfig.ModelConfig != nil {
|
||||
if agentConfig.ModelConfig.MaxTokens > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(agentConfig.ModelConfig.MaxTokens)))
|
||||
}
|
||||
if agentConfig.ModelConfig.Temperature != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTemperature(float64(*agentConfig.ModelConfig.Temperature)))
|
||||
}
|
||||
if agentConfig.ModelConfig.TopP != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopP(float64(*agentConfig.ModelConfig.TopP)))
|
||||
}
|
||||
if agentConfig.ModelConfig.TopK != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopK(int64(*agentConfig.ModelConfig.TopK)))
|
||||
}
|
||||
}
|
||||
|
||||
// Create the fantasy agent
|
||||
fantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
|
||||
|
||||
@@ -179,6 +208,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
streamingEnabled: agentConfig.StreamingEnabled,
|
||||
coreTools: coreTools,
|
||||
extraTools: agentConfig.ExtraTools,
|
||||
toolWrapper: agentConfig.ToolWrapper,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -188,7 +218,7 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult,
|
||||
onResponse, onToolCallContent, nil)
|
||||
onResponse, onToolCallContent, nil, nil)
|
||||
}
|
||||
|
||||
// GenerateWithLoopAndStreaming processes messages using the fantasy agent with streaming and callbacks.
|
||||
@@ -198,11 +228,14 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
onStreamingResponse StreamingResponseHandler,
|
||||
onReasoningDelta ReasoningDeltaHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
|
||||
// Fantasy requires the current user input as Prompt, with prior messages as history.
|
||||
// Extract the last user message text as the prompt, and pass everything before it as Messages.
|
||||
prompt, history := splitPromptAndHistory(messages)
|
||||
// Extract the last user message text and files as the prompt, and pass everything
|
||||
// before it as Messages. Files (e.g. clipboard images) are passed via the Files
|
||||
// field so Fantasy includes them in the API request.
|
||||
prompt, files, history := splitPromptAndHistory(messages)
|
||||
|
||||
// Track current tool call info for callbacks
|
||||
var currentToolName string
|
||||
@@ -213,14 +246,32 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// Stream is required to observe tool execution in real time. The non-streaming
|
||||
// Generate path is reserved for the simple case with no callbacks at all.
|
||||
hasCallbacks := onToolCall != nil || onToolExecution != nil || onToolResult != nil ||
|
||||
onToolCallContent != nil || onStreamingResponse != nil
|
||||
onToolCallContent != nil || onStreamingResponse != nil || onReasoningDelta != nil
|
||||
|
||||
if a.streamingEnabled || hasCallbacks {
|
||||
// Track completed step messages so we can return partial results
|
||||
// on cancellation. Fantasy's Stream() discards accumulated steps
|
||||
// when it returns an error, but the OnStepFinish callback fires
|
||||
// for every step that completed before the error occurred.
|
||||
var completedStepMessages []fantasy.Message
|
||||
|
||||
// Use fantasy's streaming agent
|
||||
result, err := a.fantasyAgent.Stream(ctx, fantasy.AgentStreamCall{
|
||||
Prompt: prompt,
|
||||
Files: files,
|
||||
Messages: history,
|
||||
|
||||
// Reasoning/thinking streaming callback
|
||||
OnReasoningDelta: func(id, delta string) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if onReasoningDelta != nil {
|
||||
onReasoningDelta(delta)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
// Text streaming callback
|
||||
OnTextDelta: func(id, text string) error {
|
||||
if ctx.Err() != nil {
|
||||
@@ -242,12 +293,12 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
|
||||
// Notify about the tool call
|
||||
if onToolCall != nil {
|
||||
onToolCall(tc.ToolName, tc.Input)
|
||||
onToolCall(tc.ToolCallID, tc.ToolName, tc.Input)
|
||||
}
|
||||
|
||||
// Notify tool execution starting
|
||||
if onToolExecution != nil {
|
||||
onToolExecution(tc.ToolName, true)
|
||||
onToolExecution(tc.ToolCallID, tc.ToolName, tc.Input, true)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -260,13 +311,13 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
}
|
||||
// Notify tool execution finished
|
||||
if onToolExecution != nil {
|
||||
onToolExecution(tr.ToolName, false)
|
||||
onToolExecution(tr.ToolCallID, tr.ToolName, currentToolArgs, false)
|
||||
}
|
||||
|
||||
if onToolResult != nil {
|
||||
// Extract result text and error status
|
||||
resultText, isError := extractToolResultText(tr)
|
||||
onToolResult(tr.ToolName, currentToolArgs, resultText, isError)
|
||||
onToolResult(tr.ToolCallID, tr.ToolName, currentToolArgs, resultText, tr.ClientMetadata, isError)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -274,6 +325,10 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
|
||||
// Step callbacks for content that accompanies tool calls
|
||||
OnStepFinish: func(step fantasy.StepResult) error {
|
||||
// Accumulate messages from completed steps so they can be
|
||||
// persisted even if a later step is cancelled.
|
||||
completedStepMessages = append(completedStepMessages, step.Messages...)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -287,6 +342,20 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
// On cancellation (or any error), return a partial result
|
||||
// containing messages from completed steps so the caller can
|
||||
// persist tool calls and results that finished before the
|
||||
// cancellation. The original input messages are included so
|
||||
// the caller sees the full conversation up to the point of
|
||||
// cancellation.
|
||||
if len(completedStepMessages) > 0 {
|
||||
partialMessages := make([]fantasy.Message, 0, len(messages)+len(completedStepMessages))
|
||||
partialMessages = append(partialMessages, messages...)
|
||||
partialMessages = append(partialMessages, completedStepMessages...)
|
||||
return &GenerateWithLoopResult{
|
||||
ConversationMessages: partialMessages,
|
||||
}, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -302,6 +371,7 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// Non-streaming path with no callbacks — use the simpler Generate call.
|
||||
result, err := a.fantasyAgent.Generate(ctx, fantasy.AgentCall{
|
||||
Prompt: prompt,
|
||||
Files: files,
|
||||
Messages: history,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -322,27 +392,32 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
// and returns everything before it as conversation history. Fantasy's agent
|
||||
// requires the current turn's input as Prompt (string), with prior messages
|
||||
// passed separately as Messages (history).
|
||||
func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.Message) {
|
||||
func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.FilePart, []fantasy.Message) {
|
||||
if len(messages) == 0 {
|
||||
return "", nil
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
// Walk backwards to find the last user message
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == fantasy.MessageRoleUser {
|
||||
// Extract text from the user message parts
|
||||
// Extract text and file parts from the user message
|
||||
var prompt string
|
||||
var files []fantasy.FilePart
|
||||
for _, part := range messages[i].Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
prompt = tp.Text
|
||||
break
|
||||
switch p := part.(type) {
|
||||
case fantasy.TextPart:
|
||||
if prompt == "" {
|
||||
prompt = p.Text
|
||||
}
|
||||
case fantasy.FilePart:
|
||||
files = append(files, p)
|
||||
}
|
||||
}
|
||||
// History is everything except this last user message
|
||||
history := make([]fantasy.Message, 0, len(messages)-1)
|
||||
history = append(history, messages[:i]...)
|
||||
history = append(history, messages[i+1:]...)
|
||||
return prompt, history
|
||||
return prompt, files, history
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,11 +425,11 @@ func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.Messag
|
||||
last := messages[len(messages)-1]
|
||||
for _, part := range last.Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
return tp.Text, messages[:len(messages)-1]
|
||||
return tp.Text, nil, messages[:len(messages)-1]
|
||||
}
|
||||
}
|
||||
|
||||
return "", messages
|
||||
return "", nil, messages
|
||||
}
|
||||
|
||||
// convertAgentResult converts a fantasy AgentResult to our GenerateWithLoopResult.
|
||||
@@ -379,6 +454,7 @@ func convertAgentResult(result *fantasy.AgentResult, originalMessages []fantasy.
|
||||
ConversationMessages: allFantasyMessages,
|
||||
Messages: allMessages,
|
||||
TotalUsage: result.TotalUsage,
|
||||
StopReason: string(result.Response.FinishReason),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,6 +531,11 @@ func (a *Agent) GetTools() []fantasy.AgentTool {
|
||||
return allTools
|
||||
}
|
||||
|
||||
// GetCoreToolCount returns the number of core tools.
|
||||
func (a *Agent) GetCoreToolCount() int {
|
||||
return len(a.coreTools)
|
||||
}
|
||||
|
||||
// GetMCPToolCount returns the number of tools loaded from external MCP servers.
|
||||
func (a *Agent) GetMCPToolCount() int {
|
||||
if a.toolManager == nil {
|
||||
@@ -481,6 +562,88 @@ func (a *Agent) GetLoadedServerNames() []string {
|
||||
return a.toolManager.GetLoadedServerNames()
|
||||
}
|
||||
|
||||
// SetModel swaps the agent's LLM provider to a new model. The existing tools,
|
||||
// system prompt, and configuration are preserved. The old provider is closed
|
||||
// if it has a closer. Returns the previous model string for notification.
|
||||
func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) error {
|
||||
providerResult, err := models.CreateProvider(ctx, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create model provider: %v", err)
|
||||
}
|
||||
|
||||
// Rebuild tool list (same as NewAgent).
|
||||
allTools := make([]fantasy.AgentTool, len(a.coreTools))
|
||||
copy(allTools, a.coreTools)
|
||||
if a.toolManager != nil {
|
||||
allTools = append(allTools, a.toolManager.GetTools()...)
|
||||
}
|
||||
if len(a.extraTools) > 0 {
|
||||
allTools = append(allTools, a.extraTools...)
|
||||
}
|
||||
if a.toolWrapper != nil {
|
||||
allTools = a.toolWrapper(allTools)
|
||||
}
|
||||
|
||||
// Rebuild fantasy agent options.
|
||||
var agentOpts []fantasy.AgentOption
|
||||
if a.systemPrompt != "" {
|
||||
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(a.systemPrompt))
|
||||
}
|
||||
if len(allTools) > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithTools(allTools...))
|
||||
}
|
||||
if a.maxSteps > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithStopConditions(
|
||||
fantasy.StepCountIs(a.maxSteps),
|
||||
))
|
||||
}
|
||||
|
||||
// Pass provider-specific options (e.g. OpenAI Responses API reasoning settings).
|
||||
if providerResult.ProviderOptions != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithProviderOptions(providerResult.ProviderOptions))
|
||||
}
|
||||
|
||||
// Pass generation parameters when available.
|
||||
if config.MaxTokens > 0 {
|
||||
agentOpts = append(agentOpts, fantasy.WithMaxOutputTokens(int64(config.MaxTokens)))
|
||||
}
|
||||
if config.Temperature != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTemperature(float64(*config.Temperature)))
|
||||
}
|
||||
if config.TopP != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopP(float64(*config.TopP)))
|
||||
}
|
||||
if config.TopK != nil {
|
||||
agentOpts = append(agentOpts, fantasy.WithTopK(int64(*config.TopK)))
|
||||
}
|
||||
|
||||
newFantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
|
||||
|
||||
// Close old provider.
|
||||
if a.providerCloser != nil {
|
||||
_ = a.providerCloser.Close()
|
||||
}
|
||||
|
||||
// Update model info on MCP tool manager.
|
||||
if a.toolManager != nil {
|
||||
a.toolManager.SetModel(providerResult.Model)
|
||||
}
|
||||
|
||||
// Swap fields.
|
||||
a.fantasyAgent = newFantasyAgent
|
||||
a.model = providerResult.Model
|
||||
a.providerCloser = providerResult.Closer
|
||||
|
||||
// Update provider type.
|
||||
if config.ModelString != "" {
|
||||
if p, _, err := models.ParseModelString(config.ModelString); err == nil {
|
||||
a.providerType = p
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetModel returns the underlying fantasy LanguageModel.
|
||||
func (a *Agent) GetModel() fantasy.LanguageModel {
|
||||
return a.model
|
||||
|
||||
+318
-46
@@ -13,6 +13,12 @@ import (
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
// queueItem holds a prompt and optional image attachments for the execution queue.
|
||||
type queueItem struct {
|
||||
Prompt string
|
||||
Files []fantasy.FilePart
|
||||
}
|
||||
|
||||
// App is the application-layer orchestrator. It owns the agentic loop,
|
||||
// conversation history (via MessageStore), and queue management. It is
|
||||
// designed to be created once per session and reused across multiple prompts.
|
||||
@@ -47,7 +53,7 @@ type App struct {
|
||||
// mu protects busy, queue, and cancelStep.
|
||||
mu sync.Mutex
|
||||
busy bool
|
||||
queue []string
|
||||
queue []queueItem
|
||||
|
||||
// wg tracks in-flight goroutines; Close() waits on it.
|
||||
wg sync.WaitGroup
|
||||
@@ -100,6 +106,16 @@ func (a *App) SetProgram(p *tea.Program) {
|
||||
//
|
||||
// Satisfies ui.AppController.
|
||||
func (a *App) Run(prompt string) int {
|
||||
return a.RunWithFiles(prompt, nil)
|
||||
}
|
||||
|
||||
// RunWithFiles queues a multimodal prompt (text + image files) for execution.
|
||||
// If the app is idle the prompt executes immediately; otherwise it is queued.
|
||||
// Returns the current queue depth (0 = started immediately, >0 = queued).
|
||||
//
|
||||
// Satisfies ui.AppController (via RunWithImages which converts ImageAttachment
|
||||
// to fantasy.FilePart).
|
||||
func (a *App) RunWithFiles(prompt string, files []fantasy.FilePart) int {
|
||||
a.mu.Lock()
|
||||
|
||||
if a.closed {
|
||||
@@ -107,8 +123,10 @@ func (a *App) Run(prompt string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
item := queueItem{Prompt: prompt, Files: files}
|
||||
|
||||
if a.busy {
|
||||
a.queue = append(a.queue, prompt)
|
||||
a.queue = append(a.queue, item)
|
||||
qLen := len(a.queue)
|
||||
a.mu.Unlock()
|
||||
return qLen
|
||||
@@ -117,7 +135,7 @@ func (a *App) Run(prompt string) int {
|
||||
a.busy = true
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(prompt)
|
||||
go a.drainQueue(item)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -141,6 +159,36 @@ func (a *App) QueueLength() int {
|
||||
return len(a.queue)
|
||||
}
|
||||
|
||||
// Steer cancels the current agent step (if running), clears the queue, and
|
||||
// sends a new message that will execute as soon as the current step finishes
|
||||
// cancelling. If the agent is idle, the message executes immediately.
|
||||
// This is the "steer" delivery mode for SendMessage.
|
||||
func (a *App) Steer(prompt string) {
|
||||
a.mu.Lock()
|
||||
|
||||
if a.closed {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
item := queueItem{Prompt: prompt}
|
||||
|
||||
if !a.busy {
|
||||
// Not busy — start immediately, same as Run().
|
||||
a.busy = true
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(item)
|
||||
return
|
||||
}
|
||||
|
||||
// Agent is busy: clear queue, insert steer message, then cancel.
|
||||
a.queue = []queueItem{item}
|
||||
cancel := a.cancelStep
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
}
|
||||
|
||||
// ClearQueue discards all queued prompts. The caller is responsible for
|
||||
// updating any UI state (e.g. queue badge) — ClearQueue does NOT send
|
||||
// events to the program, because it may be called synchronously from
|
||||
@@ -169,6 +217,38 @@ func (a *App) GetTreeSession() *session.TreeManager {
|
||||
return a.opts.TreeSession
|
||||
}
|
||||
|
||||
// SwitchTreeSession replaces the active tree session with a new one and
|
||||
// reloads the in-memory message store from the new session's messages.
|
||||
// The old tree session is closed. Used by /resume to switch sessions.
|
||||
func (a *App) SwitchTreeSession(ts *session.TreeManager) {
|
||||
// Close old session.
|
||||
if old := a.opts.TreeSession; old != nil {
|
||||
_ = old.Close()
|
||||
}
|
||||
a.opts.TreeSession = ts
|
||||
// Reload messages from new session.
|
||||
a.store.Clear()
|
||||
if ts != nil {
|
||||
a.store.Replace(ts.GetFantasyMessages())
|
||||
}
|
||||
}
|
||||
|
||||
// AddContextMessage adds a user-role message to the conversation history
|
||||
// without triggering an LLM response. Used by the ! shell command prefix
|
||||
// to inject command output into context so the LLM can reference it in
|
||||
// subsequent turns.
|
||||
//
|
||||
// Satisfies ui.AppController.
|
||||
func (a *App) AddContextMessage(text string) {
|
||||
msg := fantasy.NewUserMessage(text)
|
||||
a.store.Add(msg)
|
||||
|
||||
// Persist to tree session if active.
|
||||
if ts := a.opts.TreeSession; ts != nil {
|
||||
_, _ = ts.AppendFantasyMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// CompactConversation summarises older messages to free context space. It
|
||||
// returns an error synchronously if compaction cannot start (agent busy or
|
||||
// app closed). The actual compaction runs in a background goroutine and
|
||||
@@ -243,7 +323,7 @@ func (a *App) RunOnce(ctx context.Context, prompt string) error {
|
||||
a.cancelStep = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
result, err := a.executeStep(stepCtx, prompt, nil)
|
||||
result, err := a.executeStep(stepCtx, prompt, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -254,6 +334,20 @@ func (a *App) RunOnce(ctx context.Context, prompt string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunOnceResult executes a single agent step synchronously and returns the
|
||||
// full TurnResult without printing anything. This is used by --json mode to
|
||||
// capture structured output for serialization.
|
||||
func (a *App) RunOnceResult(ctx context.Context, prompt string) (*kit.TurnResult, error) {
|
||||
stepCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
a.mu.Lock()
|
||||
a.cancelStep = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
return a.executeStep(stepCtx, prompt, nil, nil)
|
||||
}
|
||||
|
||||
// RunOnceWithDisplay executes a single agent step synchronously, sending
|
||||
// intermediate display events (spinner, tool calls, streaming chunks, etc.)
|
||||
// to eventFn. This is the non-TUI equivalent of the interactive Run() path —
|
||||
@@ -272,7 +366,7 @@ func (a *App) RunOnceWithDisplay(ctx context.Context, prompt string, eventFn fun
|
||||
a.cancelStep = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
result, err := a.executeStep(stepCtx, prompt, eventFn)
|
||||
result, err := a.executeStep(stepCtx, prompt, eventFn, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -313,41 +407,63 @@ func (a *App) Close() {
|
||||
// Internal: queue drain loop
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// drainQueue runs in a goroutine. It executes the given prompt and then
|
||||
// continues draining the queue until it is empty.
|
||||
// drainQueue runs in a goroutine. It collects all queued items (including the
|
||||
// first one) and submits them together as a single batch. This ensures that
|
||||
// when multiple messages are queued while the agent is working, they are all
|
||||
// submitted together in one turn rather than sequentially.
|
||||
// Must be called with a.busy == true and a.wg incremented.
|
||||
func (a *App) drainQueue(firstPrompt string) {
|
||||
func (a *App) drainQueue(first queueItem) {
|
||||
defer a.wg.Done()
|
||||
|
||||
prompt := firstPrompt
|
||||
for {
|
||||
a.runPrompt(prompt)
|
||||
// Collect all items to process in this batch
|
||||
var items []queueItem
|
||||
items = append(items, first)
|
||||
|
||||
// Process batches until no more items are queued
|
||||
for {
|
||||
// Drain the queue to collect any pending items
|
||||
a.mu.Lock()
|
||||
// Stop draining if the app is shutting down.
|
||||
if a.closed || a.rootCtx.Err() != nil {
|
||||
a.busy = false
|
||||
a.queue = a.queue[:0]
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if len(a.queue) == 0 {
|
||||
a.busy = false
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
prompt = a.queue[0]
|
||||
a.queue = a.queue[1:]
|
||||
qLen := len(a.queue)
|
||||
items = append(items, a.queue...)
|
||||
a.queue = a.queue[:0] // Clear the queue
|
||||
queueLen := len(a.queue)
|
||||
a.mu.Unlock()
|
||||
// sendEvent must be called without a.mu held (see sendEvent comment).
|
||||
a.sendEvent(QueueUpdatedEvent{Length: qLen})
|
||||
|
||||
// Send queue updated event (queue is now empty)
|
||||
a.sendEvent(QueueUpdatedEvent{Length: queueLen})
|
||||
|
||||
// Process all collected items as a single batch
|
||||
a.runQueueBatch(items)
|
||||
|
||||
// Check if more items were queued while we were processing
|
||||
a.mu.Lock()
|
||||
hasMore := len(a.queue) > 0
|
||||
if hasMore {
|
||||
// Start a new batch with the newly queued items
|
||||
items = a.queue
|
||||
a.queue = a.queue[:0]
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
if !hasMore {
|
||||
// No more items, we're done
|
||||
break
|
||||
}
|
||||
// Process the new batch
|
||||
}
|
||||
|
||||
// Mark as no longer busy
|
||||
a.mu.Lock()
|
||||
a.busy = false
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// runPrompt executes a single prompt: adds the user message to the store,
|
||||
// runs the agent step, and sends the appropriate event to the program.
|
||||
func (a *App) runPrompt(prompt string) {
|
||||
// runQueueBatch executes multiple queue items as a single agent turn.
|
||||
// All items are submitted together, and the agent responds once to the combined context.
|
||||
func (a *App) runQueueBatch(items []queueItem) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a per-step cancellable context.
|
||||
stepCtx, cancel := context.WithCancel(a.rootCtx)
|
||||
a.mu.Lock()
|
||||
@@ -366,12 +482,18 @@ func (a *App) runPrompt(prompt string) {
|
||||
}
|
||||
}
|
||||
|
||||
result, err := a.executeStep(stepCtx, prompt, eventFn)
|
||||
// Execute the batch
|
||||
result, err := a.executeBatch(stepCtx, items, eventFn)
|
||||
if err != nil {
|
||||
if stepCtx.Err() != nil {
|
||||
// Step was cancelled by the user (e.g. double-ESC). Send a
|
||||
// cancellation event so the TUI can cut off the response
|
||||
// cleanly without printing an error.
|
||||
// Step was cancelled by the user (double-ESC). The SDK's
|
||||
// runTurn has rolled the tree session back to the pre-turn
|
||||
// state, discarding the user message and any tool call/result
|
||||
// pairs from the cancelled turn. Sync the in-memory store
|
||||
// to match the rolled-back tree session.
|
||||
if ts := a.opts.TreeSession; ts != nil {
|
||||
a.store.Replace(ts.GetFantasyMessages())
|
||||
}
|
||||
a.sendEvent(StepCancelledEvent{})
|
||||
return
|
||||
}
|
||||
@@ -387,9 +509,9 @@ func (a *App) runPrompt(prompt string) {
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// executeStep runs a single agentic step by delegating to the SDK's
|
||||
// PromptResult(), which handles session persistence, hooks, extension
|
||||
// events, and the generation loop.
|
||||
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg)) (*kit.TurnResult, error) {
|
||||
// PromptResult() (or PromptResultWithFiles for multimodal), which handles
|
||||
// session persistence, hooks, extension events, and the generation loop.
|
||||
func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.Msg), files []fantasy.FilePart) (*kit.TurnResult, error) {
|
||||
// Test hook: bypass SDK entirely.
|
||||
if a.opts.PromptFunc != nil {
|
||||
return a.opts.PromptFunc(ctx, prompt)
|
||||
@@ -409,7 +531,13 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
|
||||
// Show spinner while the agent works.
|
||||
sendFn(SpinnerEvent{Show: true})
|
||||
|
||||
result, err := a.opts.Kit.PromptResult(ctx, prompt)
|
||||
var result *kit.TurnResult
|
||||
var err error
|
||||
if len(files) > 0 {
|
||||
result, err = a.opts.Kit.PromptResultWithFiles(ctx, prompt, files)
|
||||
} else {
|
||||
result, err = a.opts.Kit.PromptResult(ctx, prompt)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -423,9 +551,87 @@ func (a *App) executeStep(ctx context.Context, prompt string, eventFn func(tea.M
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Internal: event helpers
|
||||
// --------------------------------------------------------------------------
|
||||
// executeBatch runs a batch of queue items as a single agent step by delegating
|
||||
// to the SDK's PromptResultWithMessages(), which handles session persistence,
|
||||
// hooks, extension events, and the generation loop.
|
||||
func (a *App) executeBatch(ctx context.Context, items []queueItem, eventFn func(tea.Msg)) (*kit.TurnResult, error) {
|
||||
// Test hook: bypass SDK entirely (single item only for test compatibility).
|
||||
if a.opts.PromptFunc != nil {
|
||||
if len(items) == 1 {
|
||||
return a.opts.PromptFunc(ctx, items[0].Prompt)
|
||||
}
|
||||
// For batch mode with PromptFunc, just use the first item
|
||||
return a.opts.PromptFunc(ctx, items[0].Prompt)
|
||||
}
|
||||
|
||||
sendFn := func(msg tea.Msg) {
|
||||
if eventFn != nil {
|
||||
eventFn(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to SDK events for TUI rendering. The subscription is
|
||||
// temporary — it lives only for the duration of this step.
|
||||
unsub := a.subscribeSDKEvents(sendFn)
|
||||
defer unsub()
|
||||
|
||||
// Show spinner while the agent works.
|
||||
sendFn(SpinnerEvent{Show: true})
|
||||
|
||||
// Check if any items have file attachments
|
||||
hasFiles := false
|
||||
for _, item := range items {
|
||||
if len(item.Files) > 0 {
|
||||
hasFiles = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var result *kit.TurnResult
|
||||
var err error
|
||||
|
||||
if len(items) == 1 {
|
||||
// Single item: use the original path for compatibility
|
||||
item := items[0]
|
||||
if len(item.Files) > 0 || hasFiles {
|
||||
result, err = a.opts.Kit.PromptResultWithFiles(ctx, item.Prompt, item.Files)
|
||||
} else {
|
||||
result, err = a.opts.Kit.PromptResult(ctx, item.Prompt)
|
||||
}
|
||||
} else {
|
||||
// Multiple items: batch them together
|
||||
var messages []string
|
||||
for _, item := range items {
|
||||
messages = append(messages, item.Prompt)
|
||||
}
|
||||
|
||||
// TODO: Handle file attachments in batch mode
|
||||
// For now, files are ignored in batch mode (rare edge case)
|
||||
if hasFiles {
|
||||
// If files exist, fall back to processing just the first item with files
|
||||
for _, item := range items {
|
||||
if len(item.Files) > 0 {
|
||||
result, err = a.opts.Kit.PromptResultWithFiles(ctx, item.Prompt, item.Files)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result, err = a.opts.Kit.PromptResultWithMessages(ctx, messages)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sync in-memory store with the SDK's authoritative conversation.
|
||||
a.store.Replace(result.Messages)
|
||||
|
||||
// Update usage tracker (using last item's prompt for tracking).
|
||||
a.updateUsageFromTurnResult(result, items[len(items)-1].Prompt)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// sendEvent sends a tea.Msg to the registered program if one is set.
|
||||
// Must NOT be called with a.mu held (to avoid deadlock with the program).
|
||||
@@ -448,14 +654,14 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
|
||||
unsubs = append(unsubs, k.Subscribe(func(e kit.Event) {
|
||||
switch ev := e.(type) {
|
||||
case kit.ToolCallEvent:
|
||||
sendFn(ToolCallStartedEvent{ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
|
||||
sendFn(ToolCallStartedEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs})
|
||||
case kit.ToolExecutionStartEvent:
|
||||
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: true})
|
||||
sendFn(ToolExecutionEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs, IsStarting: true})
|
||||
case kit.ToolExecutionEndEvent:
|
||||
sendFn(ToolExecutionEvent{ToolName: ev.ToolName, IsStarting: false})
|
||||
sendFn(ToolExecutionEvent{ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, IsStarting: false})
|
||||
case kit.ToolResultEvent:
|
||||
sendFn(ToolResultEvent{
|
||||
ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
|
||||
ToolCallID: ev.ToolCallID, ToolName: ev.ToolName, ToolArgs: ev.ToolArgs,
|
||||
Result: ev.Result, IsError: ev.IsError,
|
||||
})
|
||||
case kit.ToolCallContentEvent:
|
||||
@@ -464,6 +670,8 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
|
||||
sendFn(ResponseCompleteEvent{Content: ev.Content})
|
||||
case kit.MessageUpdateEvent:
|
||||
sendFn(StreamChunkEvent{Content: ev.Chunk})
|
||||
case kit.ReasoningDeltaEvent:
|
||||
sendFn(ReasoningChunkEvent{Delta: ev.Delta})
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -474,6 +682,22 @@ func (a *App) subscribeSDKEvents(sendFn func(tea.Msg)) func() {
|
||||
}
|
||||
}
|
||||
|
||||
// QuitFromExtension triggers a graceful shutdown. In interactive mode it
|
||||
// sends a tea.QuitMsg to the program so the TUI exits cleanly. In
|
||||
// non-interactive mode it cancels the root context, stopping any in-flight
|
||||
// step. Safe to call from any goroutine; idempotent.
|
||||
func (a *App) QuitFromExtension() {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(tea.QuitMsg{})
|
||||
return
|
||||
}
|
||||
// Non-interactive: cancel the root context.
|
||||
a.rootCancel()
|
||||
}
|
||||
|
||||
// PrintFromExtension outputs text from an extension to the user. The level
|
||||
// controls styling: "" for plain text, "info" for a system message block,
|
||||
// "error" for an error block. In interactive mode it sends an
|
||||
@@ -491,6 +715,28 @@ func (a *App) PrintFromExtension(level, text string) {
|
||||
fmt.Println(text)
|
||||
}
|
||||
|
||||
// SetEditorTextFromExtension sends an EditorTextSetEvent to the TUI to
|
||||
// pre-fill the input editor. In non-interactive mode this is a no-op.
|
||||
func (a *App) SetEditorTextFromExtension(text string) {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(EditorTextSetEvent{Text: text})
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyModelChanged sends a ModelChangedEvent to the TUI so it updates
|
||||
// the model name in the status bar and message attribution.
|
||||
func (a *App) NotifyModelChanged(provider, model string) {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog != nil {
|
||||
prog.Send(ModelChangedEvent{ProviderName: provider, ModelName: model})
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyWidgetUpdate sends a WidgetUpdateEvent to the TUI so it re-renders
|
||||
// extension widgets. Called from the extension context's SetWidget/RemoveWidget
|
||||
// closures. In non-interactive mode this is a no-op (widgets are TUI-only).
|
||||
@@ -547,6 +793,32 @@ func (a *App) SendOverlayRequest(evt OverlayRequestEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// SuspendTUI temporarily releases the terminal from the TUI, runs the
|
||||
// callback (which may spawn interactive subprocesses), and then restores
|
||||
// the TUI. In non-interactive mode (no program registered) the callback
|
||||
// runs directly with no terminal state changes.
|
||||
//
|
||||
// Safe to call from any goroutine (extension command handlers run in
|
||||
// goroutines). Blocks until the callback returns.
|
||||
func (a *App) SuspendTUI(callback func()) error {
|
||||
a.mu.Lock()
|
||||
prog := a.program
|
||||
a.mu.Unlock()
|
||||
if prog == nil {
|
||||
// Non-interactive: just run the callback directly.
|
||||
callback()
|
||||
return nil
|
||||
}
|
||||
if err := prog.ReleaseTerminal(); err != nil {
|
||||
return fmt.Errorf("release terminal: %w", err)
|
||||
}
|
||||
callback()
|
||||
if err := prog.RestoreTerminal(); err != nil {
|
||||
return fmt.Errorf("restore terminal: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintBlockFromExtension outputs a custom styled block from an extension.
|
||||
func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
|
||||
a.mu.Lock()
|
||||
|
||||
+25
-37
@@ -120,9 +120,8 @@ func TestRun_single(t *testing.T) {
|
||||
// Run (queued prompts)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// TestRun_queued verifies that a second Run() call while the first is in-flight
|
||||
// enqueues the prompt rather than spawning a second goroutine, and that the
|
||||
// queue is drained after the first step completes.
|
||||
// TestRun_queued verifies that queued prompts are batched together and submitted
|
||||
// as a single agent turn rather than individually.
|
||||
func TestRun_queued(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
callCount := 0
|
||||
@@ -134,13 +133,7 @@ func TestRun_queued(t *testing.T) {
|
||||
callCount++
|
||||
mu.Unlock()
|
||||
<-gate
|
||||
return turnResult("first"), nil
|
||||
},
|
||||
func(_ context.Context) (*kit.TurnResult, error) {
|
||||
mu.Lock()
|
||||
callCount++
|
||||
mu.Unlock()
|
||||
return turnResult("second"), nil
|
||||
return turnResult("batch result"), nil
|
||||
},
|
||||
)
|
||||
app := newTestApp(stub)
|
||||
@@ -165,11 +158,15 @@ func TestRun_queued(t *testing.T) {
|
||||
t.Fatal("app did not become idle within 3s after queued runs")
|
||||
}
|
||||
|
||||
// Wait for the goroutine to fully finish (avoid race with queue check)
|
||||
app.wg.Wait()
|
||||
|
||||
mu.Lock()
|
||||
total := callCount
|
||||
mu.Unlock()
|
||||
if total != 2 {
|
||||
t.Fatalf("expected 2 calls, got %d", total)
|
||||
// With batching, both prompts should be processed in a single call
|
||||
if total != 1 {
|
||||
t.Fatalf("expected 1 batched call, got %d", total)
|
||||
}
|
||||
if got := app.QueueLength(); got != 0 {
|
||||
t.Fatalf("expected empty queue after drain, got %d", got)
|
||||
@@ -180,31 +177,22 @@ func TestRun_queued(t *testing.T) {
|
||||
// Queue drain ordering
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// TestQueueDrainOrdering verifies that queued prompts are consumed in FIFO order.
|
||||
// TestQueueDrainOrdering verifies that queued prompts are batched together and
|
||||
// processed in a single agent turn.
|
||||
func TestQueueDrainOrdering(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
var order []string
|
||||
var receivedPrompt string
|
||||
var mu sync.Mutex
|
||||
|
||||
stub := newStubWithFuncs(
|
||||
func(ctx context.Context) (*kit.TurnResult, error) {
|
||||
mu.Lock()
|
||||
order = append(order, "first")
|
||||
// In test mode with PromptFunc, we receive the first prompt
|
||||
// but all messages are batched together
|
||||
receivedPrompt = "batched"
|
||||
mu.Unlock()
|
||||
<-gate
|
||||
return turnResult("first"), nil
|
||||
},
|
||||
func(_ context.Context) (*kit.TurnResult, error) {
|
||||
mu.Lock()
|
||||
order = append(order, "second")
|
||||
mu.Unlock()
|
||||
return turnResult("second"), nil
|
||||
},
|
||||
func(_ context.Context) (*kit.TurnResult, error) {
|
||||
mu.Lock()
|
||||
order = append(order, "third")
|
||||
mu.Unlock()
|
||||
return turnResult("third"), nil
|
||||
return turnResult("batch result"), nil
|
||||
},
|
||||
)
|
||||
|
||||
@@ -228,16 +216,12 @@ func TestQueueDrainOrdering(t *testing.T) {
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
got := order
|
||||
got := receivedPrompt
|
||||
mu.Unlock()
|
||||
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 calls, got %d: %v", len(got), got)
|
||||
}
|
||||
for i, want := range []string{"first", "second", "third"} {
|
||||
if got[i] != want {
|
||||
t.Fatalf("call[%d]: expected %q, got %q", i, want, got[i])
|
||||
}
|
||||
// With batching, all 3 prompts should be processed in a single call
|
||||
if got != "batched" {
|
||||
t.Fatalf("expected batched processing, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +478,11 @@ func TestQueueLength_reflects(t *testing.T) {
|
||||
}
|
||||
|
||||
app.mu.Lock()
|
||||
app.queue = append(app.queue, "a", "b", "c")
|
||||
app.queue = append(app.queue,
|
||||
queueItem{Prompt: "a"},
|
||||
queueItem{Prompt: "b"},
|
||||
queueItem{Prompt: "c"},
|
||||
)
|
||||
app.mu.Unlock()
|
||||
|
||||
if got := app.QueueLength(); got != 3 {
|
||||
|
||||
@@ -9,9 +9,18 @@ type StreamChunkEvent struct {
|
||||
Content string
|
||||
}
|
||||
|
||||
// ReasoningChunkEvent is sent when a streaming reasoning/thinking delta arrives
|
||||
// from the LLM. Thinking content is rendered separately from regular text.
|
||||
type ReasoningChunkEvent struct {
|
||||
// Delta is the incremental reasoning text from the streaming response.
|
||||
Delta string
|
||||
}
|
||||
|
||||
// ToolCallStartedEvent is sent when a tool call has been parsed and is about to execute.
|
||||
// It carries the tool name and its arguments for display purposes.
|
||||
type ToolCallStartedEvent struct {
|
||||
// ToolCallID is the stable identifier for correlating tool lifecycle events.
|
||||
ToolCallID string
|
||||
// ToolName is the name of the tool being called.
|
||||
ToolName string
|
||||
// ToolArgs is the JSON-encoded arguments for the tool call.
|
||||
@@ -21,14 +30,20 @@ type ToolCallStartedEvent struct {
|
||||
// ToolExecutionEvent is sent when a tool starts or finishes executing.
|
||||
// The IsStarting flag distinguishes between the start and end of execution.
|
||||
type ToolExecutionEvent struct {
|
||||
// ToolCallID is the stable identifier for correlating tool lifecycle events.
|
||||
ToolCallID string
|
||||
// ToolName is the name of the tool being executed.
|
||||
ToolName string
|
||||
// ToolArgs is the JSON-encoded arguments for the tool call (only set when IsStarting is true).
|
||||
ToolArgs string
|
||||
// IsStarting is true when execution is beginning, false when it is complete.
|
||||
IsStarting bool
|
||||
}
|
||||
|
||||
// ToolResultEvent is sent after a tool execution completes with its result.
|
||||
type ToolResultEvent struct {
|
||||
// ToolCallID is the stable identifier for correlating tool lifecycle events.
|
||||
ToolCallID string
|
||||
// ToolName is the name of the tool that was executed.
|
||||
ToolName string
|
||||
// ToolArgs is the JSON-encoded arguments that were passed to the tool.
|
||||
@@ -113,11 +128,28 @@ type CompactErrorEvent struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// ModelChangedEvent is sent when an extension changes the active model via
|
||||
// ctx.SetModel. The TUI updates the model name shown in the status bar and
|
||||
// message attribution.
|
||||
type ModelChangedEvent struct {
|
||||
// ProviderName is the new provider (e.g. "anthropic").
|
||||
ProviderName string
|
||||
// ModelName is the new model ID (e.g. "claude-3-5-haiku-20241022").
|
||||
ModelName string
|
||||
}
|
||||
|
||||
// WidgetUpdateEvent is sent when an extension adds, updates, or removes a
|
||||
// widget via ctx.SetWidget or ctx.RemoveWidget. The TUI re-reads widget state
|
||||
// from its WidgetProvider on the next render cycle.
|
||||
type WidgetUpdateEvent struct{}
|
||||
|
||||
// EditorTextSetEvent is sent when an extension calls ctx.SetEditorText to
|
||||
// pre-fill the input editor with text. The TUI handles this by setting the
|
||||
// textarea content and moving the cursor to the end.
|
||||
type EditorTextSetEvent struct {
|
||||
Text string
|
||||
}
|
||||
|
||||
// ExtensionPrintEvent is sent when an extension calls ctx.Print, ctx.PrintInfo,
|
||||
// ctx.PrintError, or ctx.PrintBlock. The TUI renders it via the appropriate
|
||||
// renderer and tea.Println (scrollback); the CLI handler uses
|
||||
|
||||
@@ -51,6 +51,7 @@ func TestCredentialManager(t *testing.T) {
|
||||
}
|
||||
if creds == nil {
|
||||
t.Fatal("Expected credentials to be returned")
|
||||
return
|
||||
}
|
||||
if creds.APIKey != testAPIKey {
|
||||
t.Errorf("Expected API key %s, got %s", testAPIKey, creds.APIKey)
|
||||
@@ -236,6 +237,7 @@ func TestCredentialStorePersistence(t *testing.T) {
|
||||
}
|
||||
if creds == nil {
|
||||
t.Fatal("Expected credentials to persist")
|
||||
return
|
||||
}
|
||||
if creds.APIKey != testAPIKey {
|
||||
t.Errorf("Expected API key %s, got %s", testAPIKey, creds.APIKey)
|
||||
|
||||
@@ -49,12 +49,12 @@ func NewOAuthClient() *OAuthClient {
|
||||
}
|
||||
}
|
||||
|
||||
// GeneratePKCE generates a cryptographically secure PKCE verifier and challenge pair
|
||||
// generatePKCE generates a cryptographically secure PKCE verifier and challenge pair
|
||||
// for the OAuth 2.0 PKCE flow. The verifier is a random 32-byte string encoded as
|
||||
// base64url, and the challenge is the SHA256 hash of the verifier, also base64url encoded.
|
||||
// Returns the verifier (to be stored securely), challenge (to be sent with auth request),
|
||||
// and any error encountered during generation.
|
||||
func GeneratePKCE() (verifier, challenge string, err error) {
|
||||
func generatePKCE() (verifier, challenge string, err error) {
|
||||
// Generate 32 bytes of random data
|
||||
verifierBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(verifierBytes); err != nil {
|
||||
@@ -76,7 +76,7 @@ func GeneratePKCE() (verifier, challenge string, err error) {
|
||||
// and PKCE challenge. Returns an AuthData structure containing the URL for user
|
||||
// authentication and the PKCE verifier for the subsequent code exchange.
|
||||
func (c *OAuthClient) GetAuthorizationURL() (*AuthData, error) {
|
||||
verifier, challenge, err := GeneratePKCE()
|
||||
verifier, challenge, err := generatePKCE()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate PKCE: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package clipboard provides cross-platform clipboard image reading for Kit.
|
||||
//
|
||||
// Terminals cannot paste binary image data via bracketed paste — only text is
|
||||
// supported. To read images we shell out to platform-specific clipboard tools:
|
||||
//
|
||||
// - Linux X11: xclip -selection clipboard -t image/png -o
|
||||
// - Linux Wayland: wl-paste --type image/png
|
||||
// - macOS: osascript + pbpaste (via a helper that reads NSPasteboard)
|
||||
// - Windows/WSL: powershell Get-Clipboard -Format Image (not yet supported)
|
||||
//
|
||||
// The ReadImage function returns the raw image bytes and detected MIME type,
|
||||
// or an error if no image is available on the clipboard.
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ImageData holds the result of a clipboard image read.
|
||||
type ImageData struct {
|
||||
// Data is the raw image bytes (PNG, JPEG, etc.).
|
||||
Data []byte
|
||||
// MediaType is the MIME type (e.g. "image/png", "image/jpeg").
|
||||
MediaType string
|
||||
}
|
||||
|
||||
// DetectMediaType inspects the magic bytes of data to determine the image
|
||||
// MIME type. Returns empty string if the format is not recognized.
|
||||
func DetectMediaType(data []byte) string {
|
||||
if len(data) < 8 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
|
||||
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A {
|
||||
return "image/png"
|
||||
}
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||
return "image/jpeg"
|
||||
}
|
||||
|
||||
// GIF: 47 49 46 38
|
||||
if data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x38 {
|
||||
return "image/gif"
|
||||
}
|
||||
|
||||
// WebP: RIFF....WEBP
|
||||
if len(data) >= 12 &&
|
||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
||||
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 {
|
||||
return "image/webp"
|
||||
}
|
||||
|
||||
// BMP: 42 4D
|
||||
if data[0] == 0x42 && data[1] == 0x4D {
|
||||
return "image/bmp"
|
||||
}
|
||||
|
||||
// TIFF: 49 49 2A 00 (little-endian) or 4D 4D 00 2A (big-endian)
|
||||
if (data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00) ||
|
||||
(data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A) {
|
||||
return "image/tiff"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ErrNoImage is returned when the clipboard does not contain image data.
|
||||
var ErrNoImage = fmt.Errorf("no image data on clipboard")
|
||||
|
||||
// errNoClipboardTool is returned when no suitable clipboard tool is found.
|
||||
var errNoClipboardTool = fmt.Errorf("no clipboard tool available (install xclip, wl-paste, or use macOS)")
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build darwin
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// ReadImage reads image data from the system clipboard on macOS.
|
||||
// It uses osascript to check if the clipboard contains an image via
|
||||
// NSPasteboard and writes it to stdout as PNG data.
|
||||
func ReadImage() (*ImageData, error) {
|
||||
// Use osascript to write clipboard image to stdout via a pipe.
|
||||
// The script checks if the clipboard has a «class PNGf» item.
|
||||
script := `use framework "AppKit"
|
||||
set pb to current application's NSPasteboard's generalPasteboard()
|
||||
set imgData to pb's dataForType:(current application's NSPasteboardTypePNG)
|
||||
if imgData is missing value then
|
||||
set tiffData to pb's dataForType:(current application's NSPasteboardTypeTIFF)
|
||||
if tiffData is missing value then
|
||||
error "No image on clipboard"
|
||||
end if
|
||||
set bitmapRep to current application's NSBitmapImageRep's imageRepWithData:tiffData
|
||||
set imgData to bitmapRep's representationUsingType:(current application's NSPNGFileType) |properties|:(missing value)
|
||||
end if
|
||||
imgData's writeToFile:"/dev/stdout" atomically:false`
|
||||
|
||||
cmd := exec.Command("osascript", "-l", "AppleScript", "-e", script)
|
||||
data, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, ErrNoImage
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, ErrNoImage
|
||||
}
|
||||
|
||||
mediaType := DetectMediaType(data)
|
||||
if mediaType == "" {
|
||||
mediaType = "image/png" // osascript converts to PNG
|
||||
}
|
||||
|
||||
return &ImageData{Data: data, MediaType: mediaType}, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build integration
|
||||
|
||||
package clipboard_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/kit/internal/clipboard"
|
||||
)
|
||||
|
||||
// TestReadImageIntegration tests reading an image from the system clipboard.
|
||||
// Run with: WAYLAND_DISPLAY=wayland-1 go test -tags integration -v -run TestReadImageIntegration ./internal/clipboard/
|
||||
//
|
||||
// Prerequisites: copy an image to the clipboard first, e.g.:
|
||||
//
|
||||
// WAYLAND_DISPLAY=wayland-1 wl-copy --type image/png < ~/Pictures/Screenshots/some_screenshot.png
|
||||
func TestReadImageIntegration(t *testing.T) {
|
||||
if os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("DISPLAY") == "" {
|
||||
t.Skip("no display server available (set WAYLAND_DISPLAY or DISPLAY)")
|
||||
}
|
||||
|
||||
img, err := clipboard.ReadImage()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadImage() error: %v", err)
|
||||
}
|
||||
|
||||
if img == nil {
|
||||
t.Fatal("ReadImage() returned nil without error")
|
||||
}
|
||||
|
||||
t.Logf("Image data: %d bytes", len(img.Data))
|
||||
t.Logf("Media type: %s", img.MediaType)
|
||||
|
||||
if len(img.Data) == 0 {
|
||||
t.Fatal("image data is empty")
|
||||
}
|
||||
|
||||
if img.MediaType == "" {
|
||||
t.Fatal("media type is empty")
|
||||
}
|
||||
|
||||
// Verify magic bytes match the declared media type.
|
||||
detected := clipboard.DetectMediaType(img.Data)
|
||||
if detected == "" {
|
||||
t.Fatal("could not detect image format from magic bytes")
|
||||
}
|
||||
t.Logf("Detected format: %s", detected)
|
||||
|
||||
if detected != img.MediaType {
|
||||
t.Errorf("media type mismatch: declared=%s detected=%s", img.MediaType, detected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMediaType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
expected string
|
||||
}{
|
||||
{"PNG", []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00}, "image/png"},
|
||||
{"JPEG", []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49}, "image/jpeg"},
|
||||
{"GIF", []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x00, 0x00, 0x00}, "image/gif"},
|
||||
{"BMP", []byte{0x42, 0x4D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, "image/bmp"},
|
||||
{"WebP", []byte{0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50}, "image/webp"},
|
||||
{"TIFF-LE", []byte{0x49, 0x49, 0x2A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, "image/tiff"},
|
||||
{"TIFF-BE", []byte{0x4D, 0x4D, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x00, 0x00}, "image/tiff"},
|
||||
{"unknown", []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, ""},
|
||||
{"too short", []byte{0x89, 0x50}, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := clipboard.DetectMediaType(tt.data)
|
||||
if got != tt.expected {
|
||||
t.Errorf("DetectMediaType() = %q, want %q", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//go:build linux
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// ReadImage reads image data from the system clipboard on Linux.
|
||||
// It tries xclip first (X11), then falls back to wl-paste (Wayland).
|
||||
func ReadImage() (*ImageData, error) {
|
||||
// Try xclip first (X11).
|
||||
if path, err := exec.LookPath("xclip"); err == nil {
|
||||
data, err := readWithXclip(path)
|
||||
if err == nil && len(data) > 0 {
|
||||
mediaType := DetectMediaType(data)
|
||||
if mediaType == "" {
|
||||
mediaType = "image/png" // xclip was asked for image/png
|
||||
}
|
||||
return &ImageData{Data: data, MediaType: mediaType}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to wl-paste (Wayland).
|
||||
if path, err := exec.LookPath("wl-paste"); err == nil {
|
||||
data, err := readWithWlPaste(path)
|
||||
if err == nil && len(data) > 0 {
|
||||
mediaType := DetectMediaType(data)
|
||||
if mediaType == "" {
|
||||
mediaType = "image/png"
|
||||
}
|
||||
return &ImageData{Data: data, MediaType: mediaType}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check if either tool exists but just had no image.
|
||||
if _, err := exec.LookPath("xclip"); err == nil {
|
||||
return nil, ErrNoImage
|
||||
}
|
||||
if _, err := exec.LookPath("wl-paste"); err == nil {
|
||||
return nil, ErrNoImage
|
||||
}
|
||||
|
||||
return nil, errNoClipboardTool
|
||||
}
|
||||
|
||||
// readWithXclip reads image data using xclip.
|
||||
func readWithXclip(xclipPath string) ([]byte, error) {
|
||||
cmd := exec.Command(xclipPath, "-selection", "clipboard", "-t", "image/png", "-o")
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
// readWithWlPaste reads image data using wl-paste.
|
||||
func readWithWlPaste(wlPastePath string) ([]byte, error) {
|
||||
cmd := exec.Command(wlPastePath, "--type", "image/png")
|
||||
return cmd.Output()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build windows
|
||||
|
||||
package clipboard
|
||||
|
||||
// ReadImage reads image data from the system clipboard on Windows.
|
||||
// Windows clipboard image support is not yet implemented.
|
||||
func ReadImage() (*ImageData, error) {
|
||||
return nil, errNoClipboardTool
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
// Package compaction provides context window management with token estimation,
|
||||
// compaction triggers, and LLM-based conversation summarization.
|
||||
//
|
||||
// The algorithm mirrors Pi's approach: preserve a token budget of recent
|
||||
// The algorithm preserves a token budget of recent
|
||||
// messages (KeepRecentTokens, default 20 000) rather than a fixed message
|
||||
// count. Auto-compaction fires when estimated context usage exceeds
|
||||
// contextWindow − ReserveTokens.
|
||||
//
|
||||
// Features modelled after pi's compaction system:
|
||||
// - Tool result truncation (2000 char max) during serialisation
|
||||
// - Split turn handling: when a single turn exceeds the keep budget,
|
||||
// the turn prefix is summarised separately and merged
|
||||
// - Cumulative file tracking: read and modified files extracted from
|
||||
// tool calls and carried forward across compactions
|
||||
package compaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -19,8 +27,8 @@ import (
|
||||
// Token estimation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// EstimateTokens provides a rough token count (~4 chars per token).
|
||||
func EstimateTokens(text string) int {
|
||||
// estimateTokens provides a rough token count (~4 chars per token).
|
||||
func estimateTokens(text string) int {
|
||||
return len(text) / 4
|
||||
}
|
||||
|
||||
@@ -40,7 +48,7 @@ func estimateSingleMessageTokens(msg fantasy.Message) int {
|
||||
total := 0
|
||||
for _, part := range msg.Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
total += EstimateTokens(tp.Text)
|
||||
total += estimateTokens(tp.Text)
|
||||
}
|
||||
}
|
||||
return total
|
||||
@@ -50,8 +58,8 @@ func estimateSingleMessageTokens(msg fantasy.Message) int {
|
||||
// Auto-compact trigger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ShouldCompact reports whether auto-compaction should fire. It uses Pi's
|
||||
// formula: contextTokens > contextWindow − reserveTokens.
|
||||
// ShouldCompact reports whether auto-compaction should fire.
|
||||
// Formula: contextTokens > contextWindow − reserveTokens.
|
||||
func ShouldCompact(messages []fantasy.Message, contextWindow int, reserveTokens int) bool {
|
||||
if contextWindow <= 0 || reserveTokens <= 0 {
|
||||
return false
|
||||
@@ -66,14 +74,17 @@ func ShouldCompact(messages []fantasy.Message, contextWindow int, reserveTokens
|
||||
|
||||
// CompactionResult contains statistics from a compaction operation.
|
||||
type CompactionResult struct {
|
||||
Summary string // LLM-generated summary of compacted messages
|
||||
OriginalTokens int // Estimated token count before compaction
|
||||
CompactedTokens int // Estimated token count after compaction
|
||||
MessagesRemoved int // Number of messages replaced by the summary
|
||||
Summary string // LLM-generated summary of compacted messages
|
||||
OriginalTokens int // Estimated token count before compaction
|
||||
CompactedTokens int // Estimated token count after compaction
|
||||
MessagesRemoved int // Number of messages replaced by the summary
|
||||
CutPoint int // Index in the original messages where the cut was made
|
||||
ReadFiles []string // Files read during the compacted conversation
|
||||
ModifiedFiles []string // Files modified during the compacted conversation
|
||||
}
|
||||
|
||||
// CompactionOptions configures compaction behaviour. Pi-style token-based
|
||||
// defaults are applied for zero-value fields.
|
||||
// CompactionOptions configures compaction behaviour. Token-based defaults
|
||||
// are applied for zero-value fields.
|
||||
type CompactionOptions struct {
|
||||
ContextWindow int // Model's context window size (tokens)
|
||||
ReserveTokens int // Tokens to reserve for LLM response, default 16384
|
||||
@@ -81,7 +92,7 @@ type CompactionOptions struct {
|
||||
SummaryPrompt string // Custom summary prompt (empty = use default)
|
||||
}
|
||||
|
||||
// defaults fills zero-value fields with sensible Pi-style defaults.
|
||||
// defaults fills zero-value fields with sensible defaults.
|
||||
func (o *CompactionOptions) defaults() {
|
||||
if o.ReserveTokens <= 0 {
|
||||
o.ReserveTokens = 16384
|
||||
@@ -92,13 +103,13 @@ func (o *CompactionOptions) defaults() {
|
||||
}
|
||||
|
||||
// defaultSystemPrompt is the system prompt sent to the summarisation LLM.
|
||||
// Matches Pi's compaction system prompt.
|
||||
|
||||
const defaultSystemPrompt = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
|
||||
|
||||
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`
|
||||
|
||||
// defaultSummaryPrompt is the user prompt appended after the serialised
|
||||
// conversation. Matches Pi's initial-compaction format.
|
||||
// conversation.
|
||||
const defaultSummaryPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
|
||||
|
||||
Use this EXACT format:
|
||||
@@ -130,10 +141,36 @@ Use this EXACT format:
|
||||
- [Any data, examples, or references needed to continue]
|
||||
- [Or "(none)" if not applicable]
|
||||
|
||||
<read-files>
|
||||
[One file path per line for files that were read during the conversation]
|
||||
</read-files>
|
||||
|
||||
<modified-files>
|
||||
[One file path per line for files that were created, edited, or written during the conversation]
|
||||
</modified-files>
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cut point (token-based, Pi-style)
|
||||
// Tool result truncation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// maxToolResultChars is the maximum length of tool result text preserved
|
||||
// during serialisation. Longer results are truncated with a marker.
|
||||
const maxToolResultChars = 2000
|
||||
|
||||
// truncateToolResult truncates text to maxToolResultChars, appending a
|
||||
// marker indicating how many characters were removed.
|
||||
func truncateToolResult(text string) string {
|
||||
if len(text) <= maxToolResultChars {
|
||||
return text
|
||||
}
|
||||
truncated := len(text) - maxToolResultChars
|
||||
return text[:maxToolResultChars] + fmt.Sprintf("\n[...%d chars truncated]", truncated)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cut point (token-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// isValidCutPoint returns true if the message at index i is a valid place to
|
||||
@@ -143,11 +180,26 @@ func isValidCutPoint(msg fantasy.Message) bool {
|
||||
return msg.Role != fantasy.MessageRoleTool
|
||||
}
|
||||
|
||||
// findTurnStart returns the index of the user message that starts the turn
|
||||
// containing messages[idx]. A "turn" starts with a user message and includes
|
||||
// all subsequent assistant/tool messages until the next user message.
|
||||
func findTurnStart(messages []fantasy.Message, idx int) int {
|
||||
for i := idx; i >= 0; i-- {
|
||||
if messages[i].Role == fantasy.MessageRoleUser {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// FindCutPoint walks backward from the end of messages, accumulating tokens
|
||||
// until the keepRecentTokens budget is filled. Returns the index that
|
||||
// separates "old" messages (0..cutPoint-1, to be summarised) from "recent"
|
||||
// messages (cutPoint..end, to be preserved).
|
||||
//
|
||||
// The cut point prefers turn boundaries (user messages). When a single turn
|
||||
// exceeds the budget, the cut lands mid-turn (IsSplitTurn returns true).
|
||||
//
|
||||
// Returns 0 if there are fewer than 2 messages or all messages fit within
|
||||
// the keep budget.
|
||||
func FindCutPoint(messages []fantasy.Message, keepRecentTokens int) int {
|
||||
@@ -193,6 +245,23 @@ func FindCutPoint(messages []fantasy.Message, keepRecentTokens int) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// IsSplitTurn returns true if the cut point lands in the middle of a turn
|
||||
// (i.e. the message at cutPoint is not a user message, meaning we're
|
||||
// splitting a single turn's assistant/tool messages).
|
||||
func IsSplitTurn(messages []fantasy.Message, cutPoint int) bool {
|
||||
if cutPoint <= 0 || cutPoint >= len(messages) {
|
||||
return false
|
||||
}
|
||||
// If the cut point is at a user message, it's a clean turn boundary.
|
||||
if messages[cutPoint].Role == fantasy.MessageRoleUser {
|
||||
return false
|
||||
}
|
||||
// Otherwise we're cutting mid-turn — check if the turn started before
|
||||
// the cut point.
|
||||
turnStart := findTurnStart(messages, cutPoint)
|
||||
return turnStart < cutPoint
|
||||
}
|
||||
|
||||
// forceCutPoint returns a cut point that keeps only the last non-tool
|
||||
// message, summarising everything before it. Used when the budget-based
|
||||
// FindCutPoint returns 0 but the caller wants to compact anyway (manual
|
||||
@@ -208,11 +277,103 @@ func forceCutPoint(messages []fantasy.Message) int {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message serialisation (Pi-style)
|
||||
// File tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// roleLabel returns a human-readable label for a fantasy message role,
|
||||
// matching Pi's serialisation format.
|
||||
// fileOps contains cumulative file operation tracking.
|
||||
type fileOps struct {
|
||||
ReadFiles map[string]bool
|
||||
ModifiedFiles map[string]bool
|
||||
}
|
||||
|
||||
func newFileOps() *fileOps {
|
||||
return &fileOps{
|
||||
ReadFiles: make(map[string]bool),
|
||||
ModifiedFiles: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// extractFileOps scans messages for tool calls and extracts file paths.
|
||||
// It recognises the built-in Kit tools: read, write, edit, bash, grep, find, ls.
|
||||
func extractFileOps(messages []fantasy.Message) *fileOps {
|
||||
ops := newFileOps()
|
||||
for _, msg := range messages {
|
||||
for _, part := range msg.Content {
|
||||
tc, ok := part.(fantasy.ToolCallPart)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the JSON input to extract path arguments.
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Input), &args); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
path, _ := args["path"].(string)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch tc.ToolName {
|
||||
case "read", "grep", "find", "ls":
|
||||
ops.ReadFiles[path] = true
|
||||
case "write", "edit":
|
||||
ops.ModifiedFiles[path] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// merge combines another fileOps into this one (for cumulative tracking).
|
||||
func (f *fileOps) merge(other *fileOps) {
|
||||
if other == nil {
|
||||
return
|
||||
}
|
||||
for k := range other.ReadFiles {
|
||||
f.ReadFiles[k] = true
|
||||
}
|
||||
for k := range other.ModifiedFiles {
|
||||
f.ModifiedFiles[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
// mergeSlices adds previously tracked file lists (from a prior compaction).
|
||||
func (f *fileOps) mergeSlices(readFiles, modifiedFiles []string) {
|
||||
for _, p := range readFiles {
|
||||
f.ReadFiles[p] = true
|
||||
}
|
||||
for _, p := range modifiedFiles {
|
||||
f.ModifiedFiles[p] = true
|
||||
}
|
||||
}
|
||||
|
||||
// sortedKeys returns the keys of a bool map sorted alphabetically.
|
||||
func sortedKeys(m map[string]bool) []string {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// Simple sort — no need for sort package for small lists.
|
||||
for i := 0; i < len(keys); i++ {
|
||||
for j := i + 1; j < len(keys); j++ {
|
||||
if keys[j] < keys[i] {
|
||||
keys[i], keys[j] = keys[j], keys[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message serialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// roleLabel returns a human-readable label for a fantasy message role.
|
||||
func roleLabel(role fantasy.MessageRole) string {
|
||||
switch role {
|
||||
case fantasy.MessageRoleUser:
|
||||
@@ -229,16 +390,26 @@ func roleLabel(role fantasy.MessageRole) string {
|
||||
}
|
||||
|
||||
// serializeMessages converts a slice of fantasy messages into a plain-text
|
||||
// representation suitable for sending to the summarisation LLM. The format
|
||||
// mirrors Pi's compaction serialisation.
|
||||
// representation suitable for sending to the summarisation LLM. Tool result
|
||||
// text is truncated to maxToolResultChars to keep the summarisation request
|
||||
// within reasonable token budgets.
|
||||
func serializeMessages(messages []fantasy.Message) string {
|
||||
var sb strings.Builder
|
||||
for _, msg := range messages {
|
||||
sb.WriteString(roleLabel(msg.Role))
|
||||
sb.WriteString(":\n")
|
||||
for _, part := range msg.Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
sb.WriteString(tp.Text)
|
||||
switch p := part.(type) {
|
||||
case fantasy.TextPart:
|
||||
if msg.Role == fantasy.MessageRoleTool {
|
||||
sb.WriteString(truncateToolResult(p.Text))
|
||||
} else {
|
||||
sb.WriteString(p.Text)
|
||||
}
|
||||
case fantasy.ToolCallPart:
|
||||
fmt.Fprintf(&sb, "[Tool call: %s(%s)]", p.ToolName, truncateToolResult(p.Input))
|
||||
case fantasy.ReasoningPart:
|
||||
fmt.Fprintf(&sb, "[Thinking]: %s", truncateToolResult(p.Text))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
@@ -250,6 +421,13 @@ func serializeMessages(messages []fantasy.Message) string {
|
||||
// Compact
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PreviousCompaction carries file tracking state from a prior compaction so
|
||||
// that file operations accumulate across multiple compactions.
|
||||
type PreviousCompaction struct {
|
||||
ReadFiles []string
|
||||
ModifiedFiles []string
|
||||
}
|
||||
|
||||
// Compact summarises older messages using the LLM, returning the compaction
|
||||
// result and a new message slice (summary message + preserved recent
|
||||
// messages).
|
||||
@@ -261,12 +439,16 @@ func serializeMessages(messages []fantasy.Message) string {
|
||||
// customInstructions is optional text appended to the summary prompt (e.g.
|
||||
// "Focus on the API design decisions"). Pass "" to use the default prompt
|
||||
// only.
|
||||
//
|
||||
// prev carries file tracking from a previous compaction for cumulative
|
||||
// tracking. Pass nil if there is no prior compaction.
|
||||
func Compact(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
messages []fantasy.Message,
|
||||
opts CompactionOptions,
|
||||
customInstructions string,
|
||||
prev *PreviousCompaction,
|
||||
) (*CompactionResult, []fantasy.Message, error) {
|
||||
opts.defaults()
|
||||
|
||||
@@ -277,8 +459,8 @@ func Compact(
|
||||
cutPoint := FindCutPoint(messages, opts.KeepRecentTokens)
|
||||
if cutPoint == 0 {
|
||||
// All messages fit within the keep budget. Force a cut that
|
||||
// keeps only the last non-tool message — matching Pi, which
|
||||
// always compacts when the user explicitly requests it.
|
||||
// keeps only the last non-tool message — always compact when
|
||||
// the user explicitly requests it.
|
||||
cutPoint = forceCutPoint(messages)
|
||||
if cutPoint == 0 {
|
||||
return nil, messages, nil
|
||||
@@ -289,30 +471,30 @@ func Compact(
|
||||
recentMessages := messages[cutPoint:]
|
||||
originalTokens := EstimateMessageTokens(messages)
|
||||
|
||||
// Serialise old messages to text, matching Pi's format.
|
||||
conversationText := serializeMessages(oldMessages)
|
||||
|
||||
// Build the user-facing prompt: conversation text + summary instructions.
|
||||
userPrompt := opts.SummaryPrompt
|
||||
if userPrompt == "" {
|
||||
userPrompt = defaultSummaryPrompt
|
||||
}
|
||||
if customInstructions != "" {
|
||||
userPrompt += "\n\nAdditional instructions: " + customInstructions
|
||||
// Extract file operations from old messages.
|
||||
ops := extractFileOps(oldMessages)
|
||||
// Accumulate from previous compaction if present.
|
||||
if prev != nil {
|
||||
ops.mergeSlices(prev.ReadFiles, prev.ModifiedFiles)
|
||||
}
|
||||
// Also scan recent messages for file ops (they'll be carried forward).
|
||||
recentOps := extractFileOps(recentMessages)
|
||||
ops.merge(recentOps)
|
||||
|
||||
// Create a lightweight agent (no tools) just for summarisation.
|
||||
summaryAgent := fantasy.NewAgent(model,
|
||||
fantasy.WithSystemPrompt(defaultSystemPrompt),
|
||||
)
|
||||
result, err := summaryAgent.Generate(ctx, fantasy.AgentCall{
|
||||
Prompt: conversationText + "\n\n" + userPrompt,
|
||||
})
|
||||
// Handle split turns: when the cut lands mid-turn, summarise the turn
|
||||
// prefix separately and merge with the history summary.
|
||||
var summaryText string
|
||||
var err error
|
||||
|
||||
if IsSplitTurn(messages, cutPoint) {
|
||||
summaryText, err = compactSplitTurn(ctx, model, oldMessages, messages, cutPoint, opts, customInstructions)
|
||||
} else {
|
||||
summaryText, err = compactNormal(ctx, model, oldMessages, opts, customInstructions)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("compaction summarisation failed: %w", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
summaryText := result.Response.Content.Text()
|
||||
if summaryText == "" {
|
||||
return nil, nil, fmt.Errorf("compaction produced an empty summary")
|
||||
}
|
||||
@@ -338,5 +520,120 @@ func Compact(
|
||||
OriginalTokens: originalTokens,
|
||||
CompactedTokens: compactedTokens,
|
||||
MessagesRemoved: len(oldMessages),
|
||||
CutPoint: cutPoint,
|
||||
ReadFiles: sortedKeys(ops.ReadFiles),
|
||||
ModifiedFiles: sortedKeys(ops.ModifiedFiles),
|
||||
}, newMessages, nil
|
||||
}
|
||||
|
||||
// compactNormal generates a summary for a clean turn-boundary cut.
|
||||
func compactNormal(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
oldMessages []fantasy.Message,
|
||||
opts CompactionOptions,
|
||||
customInstructions string,
|
||||
) (string, error) {
|
||||
conversationText := serializeMessages(oldMessages)
|
||||
return generateSummary(ctx, model, conversationText, opts, customInstructions)
|
||||
}
|
||||
|
||||
// compactSplitTurn handles the case where the cut point lands mid-turn.
|
||||
// It generates two summaries and merges them:
|
||||
// 1. History summary: all complete turns before the split turn
|
||||
// 2. Turn prefix summary: the early part of the split turn (from the turn's
|
||||
// user message up to the cut point)
|
||||
//
|
||||
// The merged result preserves context from both the older history and the
|
||||
// beginning of the current long turn.
|
||||
func compactSplitTurn(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
oldMessages []fantasy.Message,
|
||||
allMessages []fantasy.Message,
|
||||
cutPoint int,
|
||||
opts CompactionOptions,
|
||||
customInstructions string,
|
||||
) (string, error) {
|
||||
// Find where the split turn starts.
|
||||
turnStart := findTurnStart(allMessages, cutPoint)
|
||||
|
||||
// Messages before the turn are the "history" portion.
|
||||
historyMessages := oldMessages
|
||||
if turnStart > 0 && turnStart < len(oldMessages) {
|
||||
historyMessages = oldMessages[:turnStart]
|
||||
}
|
||||
|
||||
// The turn prefix: from turnStart to cutPoint.
|
||||
turnPrefixMessages := allMessages[turnStart:cutPoint]
|
||||
|
||||
var historySummary string
|
||||
var err error
|
||||
|
||||
// Generate history summary if there are complete turns before the split.
|
||||
if len(historyMessages) >= 2 {
|
||||
historySummary, err = generateSummary(ctx, model,
|
||||
serializeMessages(historyMessages), opts, "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("split turn history summary failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate turn prefix summary.
|
||||
turnPrefixText := serializeMessages(turnPrefixMessages)
|
||||
turnPrefixPrompt := "The messages above are the BEGINNING of a long turn that was split. " +
|
||||
"Summarize the work done so far in this turn, preserving tool call results, " +
|
||||
"file changes, and progress. Another LLM will continue this turn."
|
||||
if customInstructions != "" {
|
||||
turnPrefixPrompt += "\n\nAdditional instructions: " + customInstructions
|
||||
}
|
||||
|
||||
summaryAgent := fantasy.NewAgent(model,
|
||||
fantasy.WithSystemPrompt(defaultSystemPrompt),
|
||||
)
|
||||
result, err := summaryAgent.Generate(ctx, fantasy.AgentCall{
|
||||
Prompt: turnPrefixText + "\n\n" + turnPrefixPrompt,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("split turn prefix summary failed: %w", err)
|
||||
}
|
||||
turnPrefixSummary := result.Response.Content.Text()
|
||||
|
||||
// Merge the two summaries.
|
||||
if historySummary != "" && turnPrefixSummary != "" {
|
||||
return historySummary + "\n\n---\n\n## Current Turn (in progress)\n\n" + turnPrefixSummary, nil
|
||||
}
|
||||
if turnPrefixSummary != "" {
|
||||
return turnPrefixSummary, nil
|
||||
}
|
||||
return historySummary, nil
|
||||
}
|
||||
|
||||
// generateSummary calls the LLM to produce a structured summary.
|
||||
func generateSummary(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
conversationText string,
|
||||
opts CompactionOptions,
|
||||
customInstructions string,
|
||||
) (string, error) {
|
||||
userPrompt := opts.SummaryPrompt
|
||||
if userPrompt == "" {
|
||||
userPrompt = defaultSummaryPrompt
|
||||
}
|
||||
if customInstructions != "" {
|
||||
userPrompt += "\n\nAdditional instructions: " + customInstructions
|
||||
}
|
||||
|
||||
summaryAgent := fantasy.NewAgent(model,
|
||||
fantasy.WithSystemPrompt(defaultSystemPrompt),
|
||||
)
|
||||
result, err := summaryAgent.Generate(ctx, fantasy.AgentCall{
|
||||
Prompt: conversationText + "\n\n" + userPrompt,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("compaction summarisation failed: %w", err)
|
||||
}
|
||||
|
||||
return result.Response.Content.Text(), nil
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@ func TestEstimateTokens(t *testing.T) {
|
||||
{"hello world", 2}, // 11 / 4 = 2
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := EstimateTokens(tt.text)
|
||||
got := estimateTokens(tt.text)
|
||||
if got != tt.want {
|
||||
t.Errorf("EstimateTokens(%q) = %d, want %d", tt.text, got, tt.want)
|
||||
t.Errorf("estimateTokens(%q) = %d, want %d", tt.text, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestEstimateMessageTokens_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ShouldCompact (Pi-style: contextTokens > contextWindow - reserveTokens)
|
||||
// ShouldCompact (contextTokens > contextWindow - reserveTokens)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestShouldCompact(t *testing.T) {
|
||||
@@ -94,7 +94,7 @@ func TestShouldCompact(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FindCutPoint (token-based, Pi-style)
|
||||
// FindCutPoint (token-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFindCutPoint_TokenBased(t *testing.T) {
|
||||
@@ -243,7 +243,7 @@ func TestCompact_TooFewMessages(t *testing.T) {
|
||||
makeTextMessageN(fantasy.MessageRoleUser, 400),
|
||||
}
|
||||
|
||||
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "")
|
||||
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -262,7 +262,7 @@ func TestCompact_WithinBudget(t *testing.T) {
|
||||
makeTextMessageN(fantasy.MessageRoleAssistant, 400),
|
||||
}
|
||||
|
||||
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "")
|
||||
result, newMsgs, err := Compact(context.TODO(), nil, msgs, CompactionOptions{}, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -273,3 +273,169 @@ func TestCompact_WithinBudget(t *testing.T) {
|
||||
t.Errorf("messages changed: got %d, want %d", len(newMsgs), len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool result truncation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestTruncateToolResult(t *testing.T) {
|
||||
// Short text — no truncation.
|
||||
short := strings.Repeat("x", 100)
|
||||
if got := truncateToolResult(short); got != short {
|
||||
t.Errorf("truncated short text unexpectedly")
|
||||
}
|
||||
|
||||
// Exactly at limit.
|
||||
exact := strings.Repeat("x", maxToolResultChars)
|
||||
if got := truncateToolResult(exact); got != exact {
|
||||
t.Errorf("truncated text at exact limit")
|
||||
}
|
||||
|
||||
// Over limit.
|
||||
over := strings.Repeat("x", maxToolResultChars+500)
|
||||
got := truncateToolResult(over)
|
||||
if len(got) > maxToolResultChars+50 { // allow room for marker
|
||||
t.Errorf("truncated text too long: %d chars", len(got))
|
||||
}
|
||||
if !strings.Contains(got, "500 chars truncated") {
|
||||
t.Errorf("truncation marker missing, got: %s", got[maxToolResultChars:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeMessages_TruncatesToolResults(t *testing.T) {
|
||||
longResult := strings.Repeat("R", maxToolResultChars+1000)
|
||||
msgs := []fantasy.Message{
|
||||
makeTextMessage(fantasy.MessageRoleUser, "question"),
|
||||
{
|
||||
Role: fantasy.MessageRoleTool,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: longResult}},
|
||||
},
|
||||
}
|
||||
|
||||
serialized := serializeMessages(msgs)
|
||||
if strings.Contains(serialized, longResult) {
|
||||
t.Error("tool result was not truncated during serialisation")
|
||||
}
|
||||
if !strings.Contains(serialized, "chars truncated") {
|
||||
t.Error("truncation marker missing in serialised output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSerializeMessages_PreservesNonToolText(t *testing.T) {
|
||||
longText := strings.Repeat("T", maxToolResultChars+1000)
|
||||
msgs := []fantasy.Message{
|
||||
makeTextMessage(fantasy.MessageRoleUser, longText),
|
||||
}
|
||||
|
||||
serialized := serializeMessages(msgs)
|
||||
if !strings.Contains(serialized, longText) {
|
||||
t.Error("non-tool text was unexpectedly truncated")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Split turn detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIsSplitTurn(t *testing.T) {
|
||||
msgs := []fantasy.Message{
|
||||
makeTextMessageN(fantasy.MessageRoleUser, 400), // 0: turn 1 user
|
||||
makeTextMessageN(fantasy.MessageRoleAssistant, 400), // 1: turn 1 assistant
|
||||
makeTextMessageN(fantasy.MessageRoleUser, 400), // 2: turn 2 user
|
||||
makeTextMessageN(fantasy.MessageRoleAssistant, 400), // 3: turn 2 assistant
|
||||
makeTextMessageN(fantasy.MessageRoleTool, 400), // 4: turn 2 tool result
|
||||
makeTextMessageN(fantasy.MessageRoleAssistant, 400), // 5: turn 2 assistant
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cutPoint int
|
||||
want bool
|
||||
}{
|
||||
{"at user message (turn boundary)", 2, false},
|
||||
{"at assistant mid-turn", 3, true},
|
||||
{"at assistant after tool (mid-turn)", 5, true},
|
||||
{"at 0 (no cut)", 0, false},
|
||||
{"beyond range", 10, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsSplitTurn(msgs, tt.cutPoint)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsSplitTurn(msgs, %d) = %v, want %v", tt.cutPoint, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File operations extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExtractFileOps(t *testing.T) {
|
||||
// Create messages with tool calls.
|
||||
msgs := []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.ToolCallPart{ToolCallID: "1", ToolName: "read", Input: `{"path":"src/main.go"}`},
|
||||
fantasy.ToolCallPart{ToolCallID: "2", ToolName: "write", Input: `{"path":"src/out.go"}`},
|
||||
fantasy.ToolCallPart{ToolCallID: "3", ToolName: "edit", Input: `{"path":"src/edit.go"}`},
|
||||
fantasy.ToolCallPart{ToolCallID: "4", ToolName: "grep", Input: `{"path":"src/search"}`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ops := extractFileOps(msgs)
|
||||
if !ops.ReadFiles["src/main.go"] {
|
||||
t.Error("read file not tracked: src/main.go")
|
||||
}
|
||||
if !ops.ReadFiles["src/search"] {
|
||||
t.Error("grep path not tracked as read: src/search")
|
||||
}
|
||||
if !ops.ModifiedFiles["src/out.go"] {
|
||||
t.Error("write file not tracked: src/out.go")
|
||||
}
|
||||
if !ops.ModifiedFiles["src/edit.go"] {
|
||||
t.Error("edit file not tracked: src/edit.go")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileOps_MergeSlices(t *testing.T) {
|
||||
ops := newFileOps()
|
||||
ops.ReadFiles["a.go"] = true
|
||||
ops.ModifiedFiles["b.go"] = true
|
||||
|
||||
ops.mergeSlices(
|
||||
[]string{"c.go", "a.go"},
|
||||
[]string{"d.go"},
|
||||
)
|
||||
|
||||
if len(ops.ReadFiles) != 2 { // a.go, c.go
|
||||
t.Errorf("ReadFiles len = %d, want 2", len(ops.ReadFiles))
|
||||
}
|
||||
if len(ops.ModifiedFiles) != 2 { // b.go, d.go
|
||||
t.Errorf("ModifiedFiles len = %d, want 2", len(ops.ModifiedFiles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortedKeys(t *testing.T) {
|
||||
m := map[string]bool{"c": true, "a": true, "b": true}
|
||||
got := sortedKeys(m)
|
||||
want := []string{"a", "b", "c"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("sortedKeys len = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i, v := range got {
|
||||
if v != want[i] {
|
||||
t.Errorf("sortedKeys[%d] = %q, want %q", i, v, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortedKeys_Empty(t *testing.T) {
|
||||
got := sortedKeys(nil)
|
||||
if got != nil {
|
||||
t.Errorf("sortedKeys(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
+56
-37
@@ -105,42 +105,56 @@ type AdaptiveColor struct {
|
||||
Dark string `json:"dark,omitempty" yaml:"dark,omitempty"`
|
||||
}
|
||||
|
||||
// MarkdownThemeConfig defines color overrides for markdown rendering and
|
||||
// syntax highlighting.
|
||||
type MarkdownThemeConfig struct {
|
||||
Text AdaptiveColor `json:"text,omitzero" yaml:"text,omitempty"`
|
||||
Muted AdaptiveColor `json:"muted,omitzero" yaml:"muted,omitempty"`
|
||||
Heading AdaptiveColor `json:"heading,omitzero" yaml:"heading,omitempty"`
|
||||
Emph AdaptiveColor `json:"emph,omitzero" yaml:"emph,omitempty"`
|
||||
Strong AdaptiveColor `json:"strong,omitzero" yaml:"strong,omitempty"`
|
||||
Link AdaptiveColor `json:"link,omitzero" yaml:"link,omitempty"`
|
||||
Code AdaptiveColor `json:"code,omitzero" yaml:"code,omitempty"`
|
||||
Error AdaptiveColor `json:"error,omitzero" yaml:"error,omitempty"`
|
||||
Keyword AdaptiveColor `json:"keyword,omitzero" yaml:"keyword,omitempty"`
|
||||
String AdaptiveColor `json:"string,omitzero" yaml:"string,omitempty"`
|
||||
Number AdaptiveColor `json:"number,omitzero" yaml:"number,omitempty"`
|
||||
Comment AdaptiveColor `json:"comment,omitzero" yaml:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// Theme defines the color scheme for the application UI with adaptive colors
|
||||
// that support both light and dark modes.
|
||||
type Theme struct {
|
||||
Primary AdaptiveColor `json:"primary" yaml:"primary"`
|
||||
Secondary AdaptiveColor `json:"secondary" yaml:"secondary"`
|
||||
Success AdaptiveColor `json:"success" yaml:"success"`
|
||||
Warning AdaptiveColor `json:"warning" yaml:"warning"`
|
||||
Error AdaptiveColor `json:"error" yaml:"error"`
|
||||
Info AdaptiveColor `json:"info" yaml:"info"`
|
||||
Text AdaptiveColor `json:"text" yaml:"text"`
|
||||
Muted AdaptiveColor `json:"muted" yaml:"muted"`
|
||||
VeryMuted AdaptiveColor `json:"very-muted" yaml:"very-muted"`
|
||||
Background AdaptiveColor `json:"background" yaml:"background"`
|
||||
Border AdaptiveColor `json:"border" yaml:"border"`
|
||||
MutedBorder AdaptiveColor `json:"muted-border" yaml:"muted-border"`
|
||||
System AdaptiveColor `json:"system" yaml:"system"`
|
||||
Tool AdaptiveColor `json:"tool" yaml:"tool"`
|
||||
Accent AdaptiveColor `json:"accent" yaml:"accent"`
|
||||
Highlight AdaptiveColor `json:"highlight" yaml:"highlight"`
|
||||
}
|
||||
Primary AdaptiveColor `json:"primary,omitzero" yaml:"primary,omitempty"`
|
||||
Secondary AdaptiveColor `json:"secondary,omitzero" yaml:"secondary,omitempty"`
|
||||
Success AdaptiveColor `json:"success,omitzero" yaml:"success,omitempty"`
|
||||
Warning AdaptiveColor `json:"warning,omitzero" yaml:"warning,omitempty"`
|
||||
Error AdaptiveColor `json:"error,omitzero" yaml:"error,omitempty"`
|
||||
Info AdaptiveColor `json:"info,omitzero" yaml:"info,omitempty"`
|
||||
Text AdaptiveColor `json:"text,omitzero" yaml:"text,omitempty"`
|
||||
Muted AdaptiveColor `json:"muted,omitzero" yaml:"muted,omitempty"`
|
||||
VeryMuted AdaptiveColor `json:"very-muted,omitzero" yaml:"very-muted,omitempty"`
|
||||
Background AdaptiveColor `json:"background,omitzero" yaml:"background,omitempty"`
|
||||
Border AdaptiveColor `json:"border,omitzero" yaml:"border,omitempty"`
|
||||
MutedBorder AdaptiveColor `json:"muted-border,omitzero" yaml:"muted-border,omitempty"`
|
||||
System AdaptiveColor `json:"system,omitzero" yaml:"system,omitempty"`
|
||||
Tool AdaptiveColor `json:"tool,omitzero" yaml:"tool,omitempty"`
|
||||
Accent AdaptiveColor `json:"accent,omitzero" yaml:"accent,omitempty"`
|
||||
Highlight AdaptiveColor `json:"highlight,omitzero" yaml:"highlight,omitempty"`
|
||||
|
||||
// MarkdownTheme defines the color scheme for markdown rendering with syntax
|
||||
// highlighting support and adaptive colors for light and dark modes.
|
||||
type MarkdownTheme struct {
|
||||
Text AdaptiveColor `json:"text" yaml:"text"`
|
||||
Muted AdaptiveColor `json:"muted" yaml:"muted"`
|
||||
Heading AdaptiveColor `json:"heading" yaml:"heading"`
|
||||
Emph AdaptiveColor `json:"emph" yaml:"emph"`
|
||||
Strong AdaptiveColor `json:"strong" yaml:"strong"`
|
||||
Link AdaptiveColor `json:"link" yaml:"link"`
|
||||
Code AdaptiveColor `json:"code" yaml:"code"`
|
||||
Error AdaptiveColor `json:"error" yaml:"error"`
|
||||
Keyword AdaptiveColor `json:"keyword" yaml:"keyword"`
|
||||
String AdaptiveColor `json:"string" yaml:"string"`
|
||||
Number AdaptiveColor `json:"number" yaml:"number"`
|
||||
Comment AdaptiveColor `json:"comment" yaml:"comment"`
|
||||
// Diff block backgrounds
|
||||
DiffInsertBg AdaptiveColor `json:"diff-insert-bg,omitzero" yaml:"diff-insert-bg,omitempty"`
|
||||
DiffDeleteBg AdaptiveColor `json:"diff-delete-bg,omitzero" yaml:"diff-delete-bg,omitempty"`
|
||||
DiffEqualBg AdaptiveColor `json:"diff-equal-bg,omitzero" yaml:"diff-equal-bg,omitempty"`
|
||||
DiffMissingBg AdaptiveColor `json:"diff-missing-bg,omitzero" yaml:"diff-missing-bg,omitempty"`
|
||||
|
||||
// Code/output block backgrounds
|
||||
CodeBg AdaptiveColor `json:"code-bg,omitzero" yaml:"code-bg,omitempty"`
|
||||
GutterBg AdaptiveColor `json:"gutter-bg,omitzero" yaml:"gutter-bg,omitempty"`
|
||||
WriteBg AdaptiveColor `json:"write-bg,omitzero" yaml:"write-bg,omitempty"`
|
||||
|
||||
// Markdown rendering and syntax highlighting
|
||||
Markdown MarkdownThemeConfig `json:"markdown,omitzero" yaml:"markdown,omitempty"`
|
||||
}
|
||||
|
||||
// Config represents the complete application configuration including MCP servers,
|
||||
@@ -157,7 +171,6 @@ type Config struct {
|
||||
ProviderURL string `json:"provider-url,omitempty" yaml:"provider-url,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty" yaml:"stream,omitempty"`
|
||||
Theme any `json:"theme" yaml:"theme"`
|
||||
MarkdownTheme any `json:"markdown-theme" yaml:"markdown-theme"`
|
||||
// Model generation parameters
|
||||
MaxTokens int `json:"max-tokens,omitempty" yaml:"max-tokens,omitempty"`
|
||||
Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
@@ -165,8 +178,15 @@ type Config struct {
|
||||
TopK *int32 `json:"top-k,omitempty" yaml:"top-k,omitempty"`
|
||||
StopSequences []string `json:"stop-sequences,omitempty" yaml:"stop-sequences,omitempty"`
|
||||
|
||||
// Thinking / extended reasoning
|
||||
ThinkingLevel string `json:"thinking-level,omitempty" yaml:"thinking-level,omitempty"`
|
||||
|
||||
// TLS configuration
|
||||
TLSSkipVerify bool `json:"tls-skip-verify,omitempty" yaml:"tls-skip-verify,omitempty"`
|
||||
|
||||
// Prompt templates configuration
|
||||
Prompts []string `json:"prompts,omitempty" yaml:"prompts,omitempty"`
|
||||
NoPromptTemplates bool `json:"no-prompt-templates,omitempty" yaml:"no-prompt-templates,omitempty"`
|
||||
}
|
||||
|
||||
// GetTransportType returns the transport type for the server config, mapping
|
||||
@@ -370,11 +390,10 @@ func FilepathOr[T any](key string, value *T) error {
|
||||
fmt.Fprintf(os.Stderr, "%q", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if filepath.Ext(absPath) == ".json" {
|
||||
switch filepath.Ext(absPath) {
|
||||
case ".json":
|
||||
return json.Unmarshal(b, value)
|
||||
}
|
||||
|
||||
if filepath.Ext(absPath) == ".yaml" {
|
||||
case ".yaml", ".yml":
|
||||
return yaml.Unmarshal(b, value)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -90,11 +91,19 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
cmd.Dir = workDir
|
||||
}
|
||||
|
||||
// Ensure SHELL is set to bash so child processes (e.g. tmux) use bash
|
||||
// rather than the user's login shell (which may be nushell, fish, etc.).
|
||||
bashPath, err := exec.LookPath("bash")
|
||||
if err != nil {
|
||||
bashPath = "/bin/bash"
|
||||
}
|
||||
cmd.Env = append(os.Environ(), "SHELL="+bashPath)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
err = cmd.Run()
|
||||
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
@@ -130,7 +139,7 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
}
|
||||
|
||||
// Truncate from tail (keep last N lines, most relevant for bash)
|
||||
tr := truncateTail(output, defaultMaxLines, defaultMaxBytes)
|
||||
tr := TruncateTail(output, defaultMaxLines, defaultMaxBytes)
|
||||
|
||||
if exitCode != 0 {
|
||||
return fantasy.NewTextErrorResponse(tr.Content), nil
|
||||
|
||||
+115
-89
@@ -6,8 +6,11 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
udiff "github.com/aymanbagabas/go-udiff"
|
||||
)
|
||||
|
||||
type editArgs struct {
|
||||
@@ -76,13 +79,15 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
// If no exact match, try fuzzy matching
|
||||
if count == 0 {
|
||||
if idx, matchLen := fuzzyMatch(normalized, normalizedOld); idx >= 0 {
|
||||
// Apply fuzzy match
|
||||
// Apply fuzzy match — the matched text is the original content slice
|
||||
matchedText := normalized[idx : idx+matchLen]
|
||||
newContent := normalized[:idx] + args.NewText + normalized[idx+matchLen:]
|
||||
if err := os.WriteFile(absPath, []byte(newContent), 0644); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
|
||||
}
|
||||
diff := generateDiff(absPath, normalized, newContent, idx)
|
||||
return fantasy.NewTextResponse(fmt.Sprintf("Applied edit (fuzzy match) to %s\n%s", args.Path, diff)), nil
|
||||
diff := generateDiff(absPath, normalized, newContent)
|
||||
resp := fantasy.NewTextResponse(fmt.Sprintf("Applied edit (fuzzy match) to %s\n%s", args.Path, diff))
|
||||
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, matchedText, args.NewText)), nil
|
||||
}
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("old_text not found in %s", args.Path)), nil
|
||||
}
|
||||
@@ -98,108 +103,129 @@ func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
|
||||
}
|
||||
|
||||
idx := strings.Index(normalized, normalizedOld)
|
||||
diff := generateDiff(absPath, normalized, newContent, idx)
|
||||
return fantasy.NewTextResponse(fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff)), nil
|
||||
diff := generateDiff(absPath, normalized, newContent)
|
||||
resp := fantasy.NewTextResponse(fmt.Sprintf("Applied edit to %s\n%s", args.Path, diff))
|
||||
return fantasy.WithResponseMetadata(resp, editDiffMeta(absPath, normalizedOld, args.NewText)), nil
|
||||
}
|
||||
|
||||
// editDiffMeta builds the structured metadata attached to edit tool responses.
|
||||
func editDiffMeta(path, oldText, newText string) map[string]any {
|
||||
return map[string]any{
|
||||
"file_diffs": []map[string]any{{
|
||||
"path": path,
|
||||
"additions": strings.Count(newText, "\n") + 1,
|
||||
"deletions": strings.Count(oldText, "\n") + 1,
|
||||
"diff_blocks": []map[string]any{{
|
||||
"old_text": oldText,
|
||||
"new_text": newText,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// fuzzyMatch tries to find old_text with relaxed matching:
|
||||
// - Strips trailing whitespace per line
|
||||
// - Normalizes unicode quotes to ASCII
|
||||
// - Normalizes unicode dashes/spaces
|
||||
// Returns (index, matchLength) or (-1, 0) if not found.
|
||||
// - Strips trailing whitespace per line
|
||||
// - Normalizes unicode quotes to ASCII
|
||||
// - Normalizes unicode dashes/spaces
|
||||
//
|
||||
// Returns (index, matchLength) in the original content, or (-1, 0) if not
|
||||
// found or ambiguous (multiple matches).
|
||||
func fuzzyMatch(content, search string) (int, int) {
|
||||
normalizedContent := normalizeForFuzzy(content)
|
||||
normalizedSearch := normalizeForFuzzy(search)
|
||||
normContent, contentMap := normalizeWithMap(content)
|
||||
normSearch := normalizeForFuzzy(search)
|
||||
|
||||
idx := strings.Index(normalizedContent, normalizedSearch)
|
||||
if normSearch == "" {
|
||||
return -1, 0
|
||||
}
|
||||
|
||||
idx := strings.Index(normContent, normSearch)
|
||||
if idx < 0 {
|
||||
return -1, 0
|
||||
}
|
||||
|
||||
// Map back to original content position
|
||||
// Since normalization can change lengths, we need to find the
|
||||
// corresponding region in the original content
|
||||
origIdx := mapFuzzyIndex(content, normalizedContent, idx)
|
||||
origEnd := mapFuzzyIndex(content, normalizedContent, idx+len(normalizedSearch))
|
||||
// Reject ambiguous matches — if there are multiple fuzzy matches
|
||||
// we can't safely pick one.
|
||||
if strings.Count(normContent, normSearch) > 1 {
|
||||
return -1, 0
|
||||
}
|
||||
|
||||
return origIdx, origEnd - origIdx
|
||||
// Map normalized byte positions back to original byte positions.
|
||||
origStart := contentMap[idx]
|
||||
endNorm := idx + len(normSearch)
|
||||
var origEnd int
|
||||
if endNorm >= len(normContent) {
|
||||
origEnd = len(content)
|
||||
} else {
|
||||
origEnd = contentMap[endNorm]
|
||||
}
|
||||
|
||||
return origStart, origEnd - origStart
|
||||
}
|
||||
|
||||
func normalizeForFuzzy(s string) string {
|
||||
// Strip trailing whitespace per line
|
||||
// normalizeWithMap normalizes s for fuzzy matching and returns both the
|
||||
// normalized string and a byte-position mapping where mapping[i] is the
|
||||
// original byte position corresponding to normalized byte position i.
|
||||
//
|
||||
// Normalization: trim trailing whitespace per line, replace unicode
|
||||
// quotes/dashes/spaces with their ASCII equivalents.
|
||||
func normalizeWithMap(s string) (string, []int) {
|
||||
var result []byte
|
||||
var mapping []int // mapping[i] = original byte position for result byte i
|
||||
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = strings.TrimRightFunc(line, unicode.IsSpace)
|
||||
}
|
||||
result := strings.Join(lines, "\n")
|
||||
|
||||
// Normalize smart quotes
|
||||
replacer := strings.NewReplacer(
|
||||
"\u201c", "\"", // left double quote
|
||||
"\u201d", "\"", // right double quote
|
||||
"\u2018", "'", // left single quote
|
||||
"\u2019", "'", // right single quote
|
||||
"\u2013", "-", // en dash
|
||||
"\u2014", "-", // em dash
|
||||
"\u00a0", " ", // non-breaking space
|
||||
)
|
||||
return replacer.Replace(result)
|
||||
}
|
||||
|
||||
func mapFuzzyIndex(original, normalized string, normIdx int) int {
|
||||
// Simple approach: count runes up to normIdx in normalized,
|
||||
// then advance that many runes in original.
|
||||
// This works because our normalization only replaces runes 1:1.
|
||||
origRunes := []rune(original)
|
||||
normRunes := []rune(normalized)
|
||||
|
||||
if normIdx >= len(normRunes) {
|
||||
return len(original)
|
||||
}
|
||||
|
||||
// Count bytes for the first normIdx runes in original
|
||||
byteCount := 0
|
||||
for i := 0; i < normIdx && i < len(origRunes); i++ {
|
||||
byteCount += len(string(origRunes[i]))
|
||||
}
|
||||
return byteCount
|
||||
}
|
||||
|
||||
// generateDiff creates a simple unified diff showing the change.
|
||||
func generateDiff(path, old, new string, changeIdx int) string {
|
||||
oldLines := strings.Split(old, "\n")
|
||||
newLines := strings.Split(new, "\n")
|
||||
|
||||
// Find the line number where the change starts
|
||||
lineNum := strings.Count(old[:changeIdx], "\n") + 1
|
||||
|
||||
// Show context around the change
|
||||
contextLines := 3
|
||||
start := max(lineNum-contextLines-1, 0)
|
||||
|
||||
var diff strings.Builder
|
||||
fmt.Fprintf(&diff, "--- %s\n+++ %s\n", path, path)
|
||||
|
||||
// Find changed region
|
||||
endOld := min(lineNum+contextLines+countNewlines(old[changeIdx:])+1, len(oldLines))
|
||||
endNew := min(lineNum+contextLines+countNewlines(new[changeIdx:])+1, len(newLines))
|
||||
|
||||
fmt.Fprintf(&diff, "@@ -%d,%d +%d,%d @@\n", start+1, endOld-start, start+1, endNew-start)
|
||||
|
||||
// Very simplified diff: show old lines as removed, new lines as added
|
||||
// around the change region
|
||||
for i := start; i < endOld && i < len(oldLines); i++ {
|
||||
prefix := " "
|
||||
if i >= lineNum-1 && i < lineNum-1+countNewlines(old[changeIdx:])+1 {
|
||||
prefix = "-"
|
||||
origPos := 0
|
||||
for li, line := range lines {
|
||||
if li > 0 {
|
||||
result = append(result, '\n')
|
||||
mapping = append(mapping, origPos)
|
||||
origPos++ // skip \n in original
|
||||
}
|
||||
fmt.Fprintf(&diff, "%s %s\n", prefix, oldLines[i])
|
||||
|
||||
trimmed := strings.TrimRightFunc(line, unicode.IsSpace)
|
||||
|
||||
for j := 0; j < len(trimmed); {
|
||||
r, size := utf8.DecodeRuneInString(trimmed[j:])
|
||||
repl := normalizeRune(r)
|
||||
for k := 0; k < len(repl); k++ {
|
||||
mapping = append(mapping, origPos+j)
|
||||
}
|
||||
result = append(result, repl...)
|
||||
j += size
|
||||
}
|
||||
|
||||
origPos += len(line) // advance past full original line including trailing ws
|
||||
}
|
||||
|
||||
return diff.String()
|
||||
return string(result), mapping
|
||||
}
|
||||
|
||||
func countNewlines(s string) int {
|
||||
return strings.Count(s, "\n")
|
||||
// normalizeRune maps unicode quotes, dashes, and non-breaking spaces to
|
||||
// their ASCII equivalents. Returns the original rune as a string for all
|
||||
// other characters.
|
||||
func normalizeRune(r rune) string {
|
||||
switch r {
|
||||
case '\u201c', '\u201d': // left/right double quote
|
||||
return "\""
|
||||
case '\u2018', '\u2019': // left/right single quote
|
||||
return "'"
|
||||
case '\u2013', '\u2014': // en dash, em dash
|
||||
return "-"
|
||||
case '\u00a0': // non-breaking space
|
||||
return " "
|
||||
default:
|
||||
return string(r)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeForFuzzy normalizes s for fuzzy matching (without position mapping).
|
||||
// Used for the search string where position mapping is not needed.
|
||||
func normalizeForFuzzy(s string) string {
|
||||
norm, _ := normalizeWithMap(s)
|
||||
return norm
|
||||
}
|
||||
|
||||
// generateDiff creates a unified diff showing the change between old and new
|
||||
// file contents. Uses the go-udiff library for correct diff computation.
|
||||
func generateDiff(path, old, new string) string {
|
||||
return udiff.Unified(path, path, old, new)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,717 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
func writeFileOrFail(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fuzzyMatch — the core bug fix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFuzzyMatch_TrailingWhitespace(t *testing.T) {
|
||||
// The original bug: trailing whitespace on lines caused mapFuzzyIndex
|
||||
// to return wrong byte positions, corrupting the replacement splice.
|
||||
content := "line1 \nline2 \nline3 \nTAIL\n"
|
||||
search := "line2\nline3"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match, got none")
|
||||
}
|
||||
|
||||
matched := content[idx : idx+matchLen]
|
||||
want := "line2 \nline3 "
|
||||
if matched != want {
|
||||
t.Errorf("matched=%q, want=%q", matched, want)
|
||||
}
|
||||
|
||||
// Verify replacement is correct
|
||||
repl := content[:idx] + "REPLACED" + content[idx+matchLen:]
|
||||
wantRepl := "line1 \nREPLACED\nTAIL\n"
|
||||
if repl != wantRepl {
|
||||
t.Errorf("replacement=%q, want=%q", repl, wantRepl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_TrailingWhitespace_FirstLine(t *testing.T) {
|
||||
content := "line1 \nline2 \nline3\n"
|
||||
search := "line1\nline2"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match")
|
||||
}
|
||||
|
||||
matched := content[idx : idx+matchLen]
|
||||
want := "line1 \nline2 "
|
||||
if matched != want {
|
||||
t.Errorf("matched=%q, want=%q", matched, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_TrailingWhitespace_LastLine(t *testing.T) {
|
||||
content := "HEAD\nline1 \nline2 \n"
|
||||
search := "line1\nline2"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match")
|
||||
}
|
||||
|
||||
matched := content[idx : idx+matchLen]
|
||||
want := "line1 \nline2 "
|
||||
if matched != want {
|
||||
t.Errorf("matched=%q, want=%q", matched, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_TrailingWhitespace_AtEOF(t *testing.T) {
|
||||
// Match extends to the very end of the content
|
||||
content := "HEAD\nline1 \nline2 "
|
||||
search := "line1\nline2"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match")
|
||||
}
|
||||
|
||||
matched := content[idx : idx+matchLen]
|
||||
want := "line1 \nline2 "
|
||||
if matched != want {
|
||||
t.Errorf("matched=%q, want=%q", matched, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_UnicodeQuotes(t *testing.T) {
|
||||
content := "say \u201chello\u201d\n"
|
||||
search := "say \"hello\"\n"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match for unicode quotes")
|
||||
}
|
||||
|
||||
matched := content[idx : idx+matchLen]
|
||||
if matched != content { // entire content should match
|
||||
t.Errorf("matched=%q, want=%q", matched, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_SmartSingleQuotes(t *testing.T) {
|
||||
content := "it\u2019s a test\n"
|
||||
search := "it's a test\n"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match for smart single quotes")
|
||||
}
|
||||
matched := content[idx : idx+matchLen]
|
||||
if matched != content {
|
||||
t.Errorf("matched=%q, want=%q", matched, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_EmDash(t *testing.T) {
|
||||
content := "foo \u2014 bar\n"
|
||||
search := "foo - bar\n"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match for em dash")
|
||||
}
|
||||
matched := content[idx : idx+matchLen]
|
||||
if matched != content {
|
||||
t.Errorf("matched=%q, want=%q", matched, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_NonBreakingSpace(t *testing.T) {
|
||||
content := "hello\u00a0world\n"
|
||||
search := "hello world\n"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match for non-breaking space")
|
||||
}
|
||||
matched := content[idx : idx+matchLen]
|
||||
if matched != content {
|
||||
t.Errorf("matched=%q, want=%q", matched, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_NoMatch(t *testing.T) {
|
||||
content := "hello world\n"
|
||||
search := "goodbye world\n"
|
||||
|
||||
idx, _ := fuzzyMatch(content, search)
|
||||
if idx >= 0 {
|
||||
t.Error("expected no match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_AmbiguousReturnsNoMatch(t *testing.T) {
|
||||
// Two identical blocks — fuzzy match should refuse to pick one
|
||||
content := "block\nblock\n"
|
||||
search := "block"
|
||||
|
||||
idx, _ := fuzzyMatch(content, search)
|
||||
if idx >= 0 {
|
||||
t.Error("expected no match for ambiguous fuzzy hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_EmptySearch(t *testing.T) {
|
||||
idx, _ := fuzzyMatch("content", "")
|
||||
if idx >= 0 {
|
||||
t.Error("expected no match for empty search")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatch_MultiLineWithMixedWhitespace(t *testing.T) {
|
||||
content := "func foo() {\t \n\treturn 1 \n}\t \n"
|
||||
search := "func foo() {\n\treturn 1\n}"
|
||||
|
||||
idx, matchLen := fuzzyMatch(content, search)
|
||||
if idx < 0 {
|
||||
t.Fatal("expected fuzzy match")
|
||||
}
|
||||
|
||||
// Replacement should preserve surrounding content
|
||||
repl := content[:idx] + "func bar() {\n\treturn 2\n}" + content[idx+matchLen:]
|
||||
if !strings.HasPrefix(repl, "func bar()") {
|
||||
t.Errorf("unexpected replacement start: %q", repl[:20])
|
||||
}
|
||||
if !strings.HasSuffix(repl, "\n") {
|
||||
t.Errorf("replacement should end with newline: %q", repl)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeWithMap — position mapping correctness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNormalizeWithMap_NoTrailingWhitespace(t *testing.T) {
|
||||
s := "abc\ndef"
|
||||
norm, mapping := normalizeWithMap(s)
|
||||
if norm != s {
|
||||
t.Errorf("norm=%q, want=%q", norm, s)
|
||||
}
|
||||
// Each byte should map to itself
|
||||
for i, orig := range mapping {
|
||||
if orig != i {
|
||||
t.Errorf("mapping[%d]=%d, want=%d", i, orig, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWithMap_TrailingWhitespace(t *testing.T) {
|
||||
s := "ab \ncd"
|
||||
norm, mapping := normalizeWithMap(s)
|
||||
wantNorm := "ab\ncd"
|
||||
if norm != wantNorm {
|
||||
t.Errorf("norm=%q, want=%q", norm, wantNorm)
|
||||
}
|
||||
// 'a'→0, 'b'→1, '\n'→5, 'c'→6, 'd'→7
|
||||
wantMapping := []int{0, 1, 5, 6, 7}
|
||||
if len(mapping) != len(wantMapping) {
|
||||
t.Fatalf("mapping len=%d, want=%d", len(mapping), len(wantMapping))
|
||||
}
|
||||
for i, want := range wantMapping {
|
||||
if mapping[i] != want {
|
||||
t.Errorf("mapping[%d]=%d, want=%d", i, mapping[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWithMap_UnicodeReplacement(t *testing.T) {
|
||||
// \u201c is 3 bytes in UTF-8, replaced with " which is 1 byte
|
||||
s := "\u201chello\u201d"
|
||||
norm, mapping := normalizeWithMap(s)
|
||||
wantNorm := "\"hello\""
|
||||
if norm != wantNorm {
|
||||
t.Errorf("norm=%q, want=%q", norm, wantNorm)
|
||||
}
|
||||
// " maps to byte 0 (start of \u201c), h maps to 3, e→4, l→5, l→6, o→7, " maps to 8 (start of \u201d)
|
||||
wantMapping := []int{0, 3, 4, 5, 6, 7, 8}
|
||||
if len(mapping) != len(wantMapping) {
|
||||
t.Fatalf("mapping len=%d, want=%d", len(mapping), len(wantMapping))
|
||||
}
|
||||
for i, want := range wantMapping {
|
||||
if mapping[i] != want {
|
||||
t.Errorf("mapping[%d]=%d, want=%d", i, mapping[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWithMap_EmptyString(t *testing.T) {
|
||||
norm, mapping := normalizeWithMap("")
|
||||
if norm != "" {
|
||||
t.Errorf("norm=%q, want empty", norm)
|
||||
}
|
||||
if len(mapping) != 0 {
|
||||
t.Errorf("mapping len=%d, want 0", len(mapping))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWithMap_OnlyWhitespace(t *testing.T) {
|
||||
norm, _ := normalizeWithMap(" \n ")
|
||||
if norm != "\n" {
|
||||
t.Errorf("norm=%q, want %q", norm, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeForFuzzy — consistency with normalizeWithMap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNormalizeForFuzzy_ConsistentWithMap(t *testing.T) {
|
||||
inputs := []string{
|
||||
"hello \nworld ",
|
||||
"\u201chello\u201d\u2014world",
|
||||
"a\u00a0b\u2013c\n trailing \n",
|
||||
"no changes here",
|
||||
"",
|
||||
}
|
||||
for _, s := range inputs {
|
||||
norm := normalizeForFuzzy(s)
|
||||
normMap, _ := normalizeWithMap(s)
|
||||
if norm != normMap {
|
||||
t.Errorf("normalizeForFuzzy(%q) = %q, normalizeWithMap = %q", s, norm, normMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// generateDiff — correct unified diff output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGenerateDiff_SingleLineChange(t *testing.T) {
|
||||
old := "line1\nline2\nline3\nline4\nline5\nline6\nline7\n"
|
||||
new := "line1\nline2\nline3\nLINE4\nline5\nline6\nline7\n"
|
||||
|
||||
diff := generateDiff("test.go", old, new)
|
||||
|
||||
// Should contain standard unified diff markers
|
||||
if !strings.Contains(diff, "--- test.go") {
|
||||
t.Error("diff should contain --- header")
|
||||
}
|
||||
if !strings.Contains(diff, "+++ test.go") {
|
||||
t.Error("diff should contain +++ header")
|
||||
}
|
||||
if !strings.Contains(diff, "@@") {
|
||||
t.Error("diff should contain @@ hunk header")
|
||||
}
|
||||
|
||||
// Should show the actual change
|
||||
if !strings.Contains(diff, "-line4") {
|
||||
t.Error("diff should show removed line")
|
||||
}
|
||||
if !strings.Contains(diff, "+LINE4") {
|
||||
t.Error("diff should show added line")
|
||||
}
|
||||
|
||||
// Should NOT mark all remaining lines as changed (the old bug)
|
||||
deletedCount := strings.Count(diff, "\n-")
|
||||
if deletedCount > 2 { // at most 1 deleted line + some tolerance
|
||||
t.Errorf("diff shows %d deletions, expected ~1 (old bug: marked rest of file as deleted)", deletedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDiff_MultiLineChange(t *testing.T) {
|
||||
old := "aaa\nbbb\nccc\nddd\n"
|
||||
new := "aaa\nBBB\nCCC\nddd\n"
|
||||
|
||||
diff := generateDiff("x.go", old, new)
|
||||
if !strings.Contains(diff, "-bbb") {
|
||||
t.Error("diff should show bbb removed")
|
||||
}
|
||||
if !strings.Contains(diff, "-ccc") {
|
||||
t.Error("diff should show ccc removed")
|
||||
}
|
||||
if !strings.Contains(diff, "+BBB") {
|
||||
t.Error("diff should show BBB added")
|
||||
}
|
||||
if !strings.Contains(diff, "+CCC") {
|
||||
t.Error("diff should show CCC added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDiff_NoChange(t *testing.T) {
|
||||
content := "hello\nworld\n"
|
||||
diff := generateDiff("x.go", content, content)
|
||||
if diff != "" {
|
||||
t.Errorf("expected empty diff for identical content, got %q", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDiff_Addition(t *testing.T) {
|
||||
old := "line1\nline2\n"
|
||||
new := "line1\nnew line\nline2\n"
|
||||
|
||||
diff := generateDiff("x.go", old, new)
|
||||
if !strings.Contains(diff, "+new line") {
|
||||
t.Error("diff should show added line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDiff_Deletion(t *testing.T) {
|
||||
old := "line1\nremove me\nline2\n"
|
||||
new := "line1\nline2\n"
|
||||
|
||||
diff := generateDiff("x.go", old, new)
|
||||
if !strings.Contains(diff, "-remove me") {
|
||||
t.Error("diff should show deleted line")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// End-to-end: executeEdit via tool call
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExecuteEdit_ExactMatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.go")
|
||||
original := "func main() {\n\tfmt.Println(\"hello\")\n}\n"
|
||||
writeFileOrFail(t, path, original)
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "fmt.Println(\"hello\")",
|
||||
NewText: "fmt.Println(\"world\")",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("tool returned error: %s", resp.Content)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
want := "func main() {\n\tfmt.Println(\"world\")\n}\n"
|
||||
if string(got) != want {
|
||||
t.Errorf("file content=%q, want=%q", string(got), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_ExactMatch_DoesNotCorruptRest(t *testing.T) {
|
||||
// This is the key regression test for the screenshot bug: editing a
|
||||
// small section must NOT delete/corrupt the rest of the file.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "big.go")
|
||||
|
||||
var lines []string
|
||||
for i := 1; i <= 100; i++ {
|
||||
lines = append(lines, fmt.Sprintf("line_%03d_%s", i, strings.Repeat("x", 40)))
|
||||
}
|
||||
original := strings.Join(lines, "\n") + "\n"
|
||||
writeFileOrFail(t, path, original)
|
||||
|
||||
// Replace just line 50
|
||||
target := lines[49]
|
||||
replacement := "REPLACED_LINE_50"
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: target,
|
||||
NewText: replacement,
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("tool returned error: %s", resp.Content)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
gotLines := strings.Split(string(got), "\n")
|
||||
|
||||
// File should still have 101 elements (100 lines + trailing empty)
|
||||
if len(gotLines) != 101 {
|
||||
t.Fatalf("file has %d lines, want 101 (content was corrupted)", len(gotLines))
|
||||
}
|
||||
|
||||
// Line 50 should be replaced
|
||||
if gotLines[49] != replacement {
|
||||
t.Errorf("line 50=%q, want=%q", gotLines[49], replacement)
|
||||
}
|
||||
|
||||
// Lines before and after should be untouched
|
||||
if gotLines[0] != lines[0] {
|
||||
t.Errorf("line 1 corrupted: %q", gotLines[0])
|
||||
}
|
||||
if gotLines[98] != lines[98] {
|
||||
t.Errorf("line 99 corrupted: %q", gotLines[98])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_FuzzyMatch_TrailingWhitespace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ws.go")
|
||||
// File has trailing whitespace on some lines
|
||||
original := "func foo() { \n\treturn 1 \n}\nfunc bar() {\n}\n"
|
||||
writeFileOrFail(t, path, original)
|
||||
|
||||
// Search without trailing whitespace (common LLM behavior)
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "func foo() {\n\treturn 1\n}",
|
||||
NewText: "func foo() {\n\treturn 2\n}",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("tool returned error: %s", resp.Content)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
gotStr := string(got)
|
||||
|
||||
// The fuzzy match replaces the matched region (which includes trailing
|
||||
// whitespace) with the new_text. The key invariant is that the rest of
|
||||
// the file (func bar) must be preserved.
|
||||
if !strings.Contains(gotStr, "return 2") {
|
||||
t.Error("edit was not applied: missing 'return 2'")
|
||||
}
|
||||
if !strings.Contains(gotStr, "func bar()") {
|
||||
t.Errorf("file was corrupted: missing func bar(). got=%q", gotStr)
|
||||
}
|
||||
|
||||
// Verify response mentions fuzzy match
|
||||
if !strings.Contains(resp.Content, "fuzzy match") {
|
||||
t.Error("response should mention fuzzy match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_FuzzyMatch_DoesNotCorruptRest(t *testing.T) {
|
||||
// Regression test: fuzzy match must not corrupt content after the match.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "fuzzy.txt")
|
||||
|
||||
// 20 lines, each with trailing whitespace
|
||||
var lines []string
|
||||
for i := 1; i <= 20; i++ {
|
||||
lines = append(lines, strings.Repeat("x", 10)+" ") // trailing spaces
|
||||
}
|
||||
original := strings.Join(lines, "\n") + "\nEND\n"
|
||||
writeFileOrFail(t, path, original)
|
||||
|
||||
// Search for lines 10-11 without trailing whitespace
|
||||
search := strings.Repeat("x", 10) + "\n" + strings.Repeat("x", 10)
|
||||
// But this matches lines 1-2, 2-3, etc. — should fail due to ambiguity.
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: search,
|
||||
NewText: "REPLACED",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
|
||||
// This should either fail (ambiguous) or produce correct output.
|
||||
// With identical lines, fuzzy match should refuse (ambiguous).
|
||||
got, _ := os.ReadFile(path)
|
||||
if !resp.IsError {
|
||||
// If it didn't error, verify the file is not corrupted
|
||||
if !strings.HasSuffix(string(got), "END\n") {
|
||||
t.Error("file was corrupted: missing END marker")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_MultipleMatches_Fails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "dup.txt")
|
||||
writeFileOrFail(t, path, "hello\nworld\nhello\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "hello",
|
||||
NewText: "goodbye",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Error("expected error for multiple matches")
|
||||
}
|
||||
if !strings.Contains(resp.Content, "2 matches") {
|
||||
t.Errorf("expected '2 matches' in error, got: %s", resp.Content)
|
||||
}
|
||||
|
||||
// File should be untouched
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != "hello\nworld\nhello\n" {
|
||||
t.Error("file was modified despite error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_NoMatch_Fails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "nomatch.txt")
|
||||
writeFileOrFail(t, path, "hello world\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "nonexistent text",
|
||||
NewText: "replacement",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Error("expected error for no match")
|
||||
}
|
||||
|
||||
// File should be untouched
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != "hello world\n" {
|
||||
t.Error("file was modified despite error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_CRLFNormalization(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "crlf.txt")
|
||||
writeFileOrFail(t, path, "line1\r\nline2\r\nline3\r\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "line2",
|
||||
NewText: "LINE2",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("executeEdit error: %v", err)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("tool returned error: %s", resp.Content)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(got), "LINE2") {
|
||||
t.Error("edit was not applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_MissingPath(t *testing.T) {
|
||||
input, _ := json.Marshal(editArgs{
|
||||
OldText: "x",
|
||||
NewText: "y",
|
||||
})
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Error("expected error for missing path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_NonexistentFile(t *testing.T) {
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: "/tmp/nonexistent_edit_test_file_12345.go",
|
||||
OldText: "x",
|
||||
NewText: "y",
|
||||
})
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_DiffContainsHunkHeader(t *testing.T) {
|
||||
// The UI's extractDiffStartLine parses @@ -N from the result.
|
||||
// Verify the diff output contains it.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "hunk.go")
|
||||
var lines []string
|
||||
for i := 1; i <= 20; i++ {
|
||||
lines = append(lines, fmt.Sprintf("line_%02d_content", i))
|
||||
}
|
||||
writeFileOrFail(t, path, strings.Join(lines, "\n")+"\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "line_10_content",
|
||||
NewText: "REPLACED",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("tool error: %s", resp.Content)
|
||||
}
|
||||
if !strings.Contains(resp.Content, "@@ ") {
|
||||
t.Error("diff output should contain @@ hunk header for UI parsing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEdit_MetadataContainsFileDiffs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "meta.go")
|
||||
writeFileOrFail(t, path, "old content\n")
|
||||
|
||||
input, _ := json.Marshal(editArgs{
|
||||
Path: path,
|
||||
OldText: "old content",
|
||||
NewText: "new content",
|
||||
})
|
||||
|
||||
resp, err := executeEdit(t.Context(), fantasy.ToolCall{Input: string(input)}, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("error: %v", err)
|
||||
}
|
||||
|
||||
// Check metadata is present
|
||||
metaJSON := resp.Metadata
|
||||
if metaJSON == "" {
|
||||
t.Fatal("expected metadata on response")
|
||||
}
|
||||
|
||||
var meta map[string]any
|
||||
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
|
||||
t.Fatalf("metadata is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
diffs, ok := meta["file_diffs"]
|
||||
if !ok {
|
||||
t.Fatal("metadata missing file_diffs key")
|
||||
}
|
||||
|
||||
diffList, ok := diffs.([]any)
|
||||
if !ok || len(diffList) == 0 {
|
||||
t.Fatal("file_diffs should be a non-empty array")
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ func NewFindTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
},
|
||||
},
|
||||
Required: []string{"pattern"},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeFind(ctx, call, cfg.WorkDir)
|
||||
|
||||
@@ -59,6 +59,7 @@ func NewGrepTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
},
|
||||
},
|
||||
Required: []string{"pattern"},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeGrep(ctx, call, cfg.WorkDir)
|
||||
|
||||
@@ -33,6 +33,7 @@ func NewLsTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
},
|
||||
},
|
||||
Required: []string{},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeLs(ctx, call, cfg.WorkDir)
|
||||
|
||||
@@ -38,6 +38,7 @@ func NewReadTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeRead(ctx, call, cfg.WorkDir)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
const defaultSubagentTimeout = 5 * time.Minute
|
||||
const maxSubagentTimeout = 30 * time.Minute
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context-based subagent spawner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SubagentSpawnResult carries the outcome of an in-process subagent spawn.
|
||||
type SubagentSpawnResult struct {
|
||||
Response string
|
||||
Error error
|
||||
SessionID string
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
Elapsed time.Duration
|
||||
}
|
||||
|
||||
// SubagentSpawnFunc is a callback that spawns an in-process subagent. The
|
||||
// parent Kit instance injects this into the context so the core tool can
|
||||
// call back without importing pkg/kit (which would create a cycle).
|
||||
// The toolCallID parameter is the LLM-assigned ID of the spawn_subagent
|
||||
// tool call, enabling the parent to correlate subagent events.
|
||||
type SubagentSpawnFunc func(ctx context.Context, toolCallID, prompt, model, systemPrompt string, timeout time.Duration) (*SubagentSpawnResult, error)
|
||||
|
||||
type subagentCtxKey struct{}
|
||||
|
||||
// WithSubagentSpawner stores a spawn function in the context so that the
|
||||
// spawn_subagent core tool can create in-process subagents.
|
||||
func WithSubagentSpawner(ctx context.Context, fn SubagentSpawnFunc) context.Context {
|
||||
return context.WithValue(ctx, subagentCtxKey{}, fn)
|
||||
}
|
||||
|
||||
// getSubagentSpawner retrieves the spawn function from the context.
|
||||
func getSubagentSpawner(ctx context.Context) SubagentSpawnFunc {
|
||||
if fn, ok := ctx.Value(subagentCtxKey{}).(SubagentSpawnFunc); ok {
|
||||
return fn
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// spawn_subagent tool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type subagentArgs struct {
|
||||
Task string `json:"task"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// NewSubagentTool creates the spawn_subagent core tool.
|
||||
func NewSubagentTool(opts ...ToolOption) fantasy.AgentTool {
|
||||
return &coreTool{
|
||||
info: fantasy.ToolInfo{
|
||||
Name: "spawn_subagent",
|
||||
Description: `Spawn a subagent to perform a task autonomously.
|
||||
|
||||
The subagent runs as a separate in-process Kit instance with full tool access
|
||||
(except spawning further subagents). Use this to:
|
||||
- Delegate independent subtasks that can run in parallel
|
||||
- Perform research or analysis without blocking your main work
|
||||
- Execute tasks that benefit from a fresh context window
|
||||
|
||||
The subagent result is returned when it completes. For long-running tasks,
|
||||
consider breaking them into smaller focused subtasks.
|
||||
|
||||
Example use cases:
|
||||
- "Research the authentication patterns in this codebase"
|
||||
- "Write unit tests for the UserService class"
|
||||
- "Analyze the performance bottlenecks in the database queries"`,
|
||||
Parameters: map[string]any{
|
||||
"task": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The complete task description for the subagent to perform",
|
||||
},
|
||||
"model": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional model override (e.g. 'anthropic/claude-haiku-3-5-20241022' for faster/cheaper tasks)",
|
||||
},
|
||||
"system_prompt": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional system prompt for domain-specific guidance",
|
||||
},
|
||||
"timeout_seconds": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Maximum execution time in seconds (default: 300, max: 1800)",
|
||||
},
|
||||
},
|
||||
Required: []string{"task"},
|
||||
Parallel: true,
|
||||
},
|
||||
handler: func(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return executeSubagent(ctx, call)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func executeSubagent(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
var args subagentArgs
|
||||
if err := parseArgs(call.Input, &args); err != nil {
|
||||
return fantasy.NewTextErrorResponse("task parameter is required"), nil
|
||||
}
|
||||
if args.Task == "" {
|
||||
return fantasy.NewTextErrorResponse("task parameter is required"), nil
|
||||
}
|
||||
|
||||
// Determine timeout.
|
||||
timeout := defaultSubagentTimeout
|
||||
if args.TimeoutSeconds > 0 {
|
||||
timeout = min(time.Duration(args.TimeoutSeconds)*time.Second, maxSubagentTimeout)
|
||||
}
|
||||
|
||||
// Retrieve in-process spawner from context.
|
||||
spawner := getSubagentSpawner(ctx)
|
||||
if spawner == nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
"Error: subagent spawner not available. " +
|
||||
"Ensure Kit is initialized with subagent support.",
|
||||
), fmt.Errorf("no subagent spawner in context")
|
||||
}
|
||||
|
||||
// Detach from the parent's deadline so the subagent gets its own
|
||||
// independent timeout (applied downstream in Kit.Subagent). The parent
|
||||
// context may carry a tight deadline from the LLM generation loop or
|
||||
// other tool timeouts that would prematurely kill the subagent.
|
||||
// We preserve context values (spawner, etc.) and propagate parent
|
||||
// cancellation (e.g. user hits Ctrl-C) without inheriting the deadline.
|
||||
spawnCtx := detachedWithCancel(ctx)
|
||||
|
||||
// Spawn in-process subagent.
|
||||
result, err := spawner(spawnCtx, call.ID, args.Task, args.Model, args.SystemPrompt, timeout)
|
||||
if err != nil || result.Error != nil {
|
||||
spawnErr := err
|
||||
if spawnErr == nil {
|
||||
spawnErr = result.Error
|
||||
}
|
||||
response := fmt.Sprintf("Subagent failed after %ds.\n\nError: %v",
|
||||
int(result.Elapsed.Seconds()), spawnErr)
|
||||
if result.Response != "" {
|
||||
response += fmt.Sprintf("\n\nPartial output:\n%s", truncateResponse(result.Response, 8000))
|
||||
}
|
||||
return fantasy.NewTextErrorResponse(response), nil
|
||||
}
|
||||
|
||||
// Build successful response.
|
||||
response := fmt.Sprintf("Subagent completed successfully in %ds.", int(result.Elapsed.Seconds()))
|
||||
if result.InputTokens > 0 || result.OutputTokens > 0 {
|
||||
response += fmt.Sprintf(" (tokens: %d in / %d out)", result.InputTokens, result.OutputTokens)
|
||||
}
|
||||
response += fmt.Sprintf("\n\nResult:\n%s", truncateResponse(result.Response, 12000))
|
||||
|
||||
resp := fantasy.NewTextResponse(response)
|
||||
|
||||
// Attach subagent session ID as metadata when available.
|
||||
if result.SessionID != "" {
|
||||
resp = fantasy.WithResponseMetadata(resp, map[string]any{
|
||||
"subagent_session_id": result.SessionID,
|
||||
})
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context detachment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// detachedContext wraps a parent context, preserving its values but removing
|
||||
// its deadline and cancellation. This allows the subagent to have its own
|
||||
// independent timeout while still accessing context-stored values (e.g. the
|
||||
// subagent spawner function).
|
||||
type detachedContext struct {
|
||||
parent context.Context
|
||||
}
|
||||
|
||||
func (d detachedContext) Deadline() (time.Time, bool) { return time.Time{}, false }
|
||||
func (d detachedContext) Done() <-chan struct{} { return nil }
|
||||
func (d detachedContext) Err() error { return nil }
|
||||
func (d detachedContext) Value(key any) any { return d.parent.Value(key) }
|
||||
|
||||
// detachedWithCancel creates a new context that inherits values from the
|
||||
// parent but has no deadline. Cancellation of the parent is propagated: when
|
||||
// the parent is cancelled the returned context is also cancelled, but the
|
||||
// parent's deadline does not apply to the child.
|
||||
func detachedWithCancel(parent context.Context) context.Context {
|
||||
child, cancel := context.WithCancel(detachedContext{parent: parent})
|
||||
go func() {
|
||||
select {
|
||||
case <-parent.Done():
|
||||
cancel()
|
||||
case <-child.Done():
|
||||
}
|
||||
}()
|
||||
return child
|
||||
}
|
||||
|
||||
// truncateResponse limits the response length to avoid overwhelming context windows.
|
||||
func truncateResponse(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "\n\n... [truncated — " + fmt.Sprintf("%d", len(s)-maxLen) + " bytes omitted]"
|
||||
}
|
||||
+12
-6
@@ -1,7 +1,7 @@
|
||||
// Package core provides the built-in core tools for KIT's coding agent.
|
||||
// These tools are direct fantasy.AgentTool implementations — no MCP layer,
|
||||
// no JSON-RPC, no serialization overhead. They match the pi coding agent's
|
||||
// core tool set: bash, read, write, edit, grep, find, ls.
|
||||
// no JSON-RPC, no serialization overhead. Core tool set: bash, read, write,
|
||||
// edit, grep, find, ls.
|
||||
package core
|
||||
|
||||
import (
|
||||
@@ -65,7 +65,7 @@ func parseArgs(input string, target any) error {
|
||||
}
|
||||
|
||||
// CodingTools returns the default set of core tools for a coding agent:
|
||||
// bash, read, write, edit. This matches pi's codingTools collection.
|
||||
// bash, read, write, edit.
|
||||
func CodingTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
return []fantasy.AgentTool{
|
||||
NewBashTool(opts...),
|
||||
@@ -76,7 +76,7 @@ func CodingTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
}
|
||||
|
||||
// ReadOnlyTools returns tools for read-only exploration:
|
||||
// read, grep, find, ls. This matches pi's readOnlyTools collection.
|
||||
// read, grep, find, ls.
|
||||
func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
return []fantasy.AgentTool{
|
||||
NewReadTool(opts...),
|
||||
@@ -86,8 +86,9 @@ func ReadOnlyTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
}
|
||||
}
|
||||
|
||||
// AllTools returns all available core tools.
|
||||
func AllTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
// SubagentTools returns all core tools except spawn_subagent. This prevents
|
||||
// infinite recursion when a subagent is itself a Kit instance.
|
||||
func SubagentTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
return []fantasy.AgentTool{
|
||||
NewBashTool(opts...),
|
||||
NewReadTool(opts...),
|
||||
@@ -98,3 +99,8 @@ func AllTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
NewLsTool(opts...),
|
||||
}
|
||||
}
|
||||
|
||||
// AllTools returns all available core tools.
|
||||
func AllTools(opts ...ToolOption) []fantasy.AgentTool {
|
||||
return append(SubagentTools(opts...), NewSubagentTool(opts...))
|
||||
}
|
||||
|
||||
+35
-12
@@ -6,9 +6,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxLines = 2000
|
||||
defaultMaxBytes = 50 * 1024 // 50KB
|
||||
grepMaxLineLen = 500
|
||||
defaultMaxLines = 2000
|
||||
defaultMaxBytes = 50 * 1024 // 50KB
|
||||
defaultMaxLineLen = 2000 // max characters per line before truncation
|
||||
grepMaxLineLen = 500
|
||||
|
||||
// DefaultMaxLines is the exported default line limit for truncation.
|
||||
DefaultMaxLines = defaultMaxLines
|
||||
// DefaultMaxBytes is the exported default byte limit for truncation.
|
||||
DefaultMaxBytes = defaultMaxBytes
|
||||
// DefaultMaxLineLen is the exported default per-line character limit.
|
||||
DefaultMaxLineLen = defaultMaxLineLen
|
||||
)
|
||||
|
||||
// TruncationResult describes how output was truncated.
|
||||
@@ -20,9 +28,11 @@ type TruncationResult struct {
|
||||
Kept int // lines kept after truncation
|
||||
}
|
||||
|
||||
// truncateTail keeps the last maxLines lines and at most maxBytes bytes.
|
||||
// TruncateTail keeps the last maxLines lines and at most maxBytes bytes.
|
||||
// Individual lines longer than defaultMaxLineLen are truncated to prevent
|
||||
// extremely long single lines from blowing up the TUI when wrapped.
|
||||
// Used for bash output where the tail is most relevant.
|
||||
func truncateTail(content string, maxLines, maxBytes int) TruncationResult {
|
||||
func TruncateTail(content string, maxLines, maxBytes int) TruncationResult {
|
||||
if maxLines <= 0 {
|
||||
maxLines = defaultMaxLines
|
||||
}
|
||||
@@ -33,11 +43,11 @@ func truncateTail(content string, maxLines, maxBytes int) TruncationResult {
|
||||
lines := strings.Split(content, "\n")
|
||||
total := len(lines)
|
||||
|
||||
if len(content) <= maxBytes && total <= maxLines {
|
||||
return TruncationResult{Content: content, Total: total, Kept: total}
|
||||
}
|
||||
// Truncate individual long lines first to prevent single lines from
|
||||
// wrapping into hundreds of visual lines in the TUI.
|
||||
lines = truncateLongLines(lines, defaultMaxLineLen)
|
||||
|
||||
// Truncate by lines first (keep tail)
|
||||
// Truncate by lines (keep tail)
|
||||
truncBy := ""
|
||||
if total > maxLines {
|
||||
lines = lines[total-maxLines:]
|
||||
@@ -73,6 +83,7 @@ func truncateTail(content string, maxLines, maxBytes int) TruncationResult {
|
||||
}
|
||||
|
||||
// truncateHead keeps the first maxLines lines and at most maxBytes bytes.
|
||||
// Individual lines longer than defaultMaxLineLen are truncated.
|
||||
// Used for read, grep, find, ls output where the head is most relevant.
|
||||
func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
|
||||
if maxLines <= 0 {
|
||||
@@ -85,9 +96,8 @@ func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
|
||||
lines := strings.Split(content, "\n")
|
||||
total := len(lines)
|
||||
|
||||
if len(content) <= maxBytes && total <= maxLines {
|
||||
return TruncationResult{Content: content, Total: total, Kept: total}
|
||||
}
|
||||
// Truncate individual long lines first.
|
||||
lines = truncateLongLines(lines, defaultMaxLineLen)
|
||||
|
||||
truncBy := ""
|
||||
if total > maxLines {
|
||||
@@ -120,6 +130,19 @@ func truncateHead(content string, maxLines, maxBytes int) TruncationResult {
|
||||
}
|
||||
}
|
||||
|
||||
// truncateLongLines caps each line to maxLen characters, appending a
|
||||
// "[...N chars truncated]" marker to any line that exceeds the limit.
|
||||
// This prevents a single very long line (e.g. minified JSON/JS) from
|
||||
// wrapping into hundreds of visual rows and blowing up the TUI.
|
||||
func truncateLongLines(lines []string, maxLen int) []string {
|
||||
for i, line := range lines {
|
||||
if len(line) > maxLen {
|
||||
lines[i] = line[:maxLen] + fmt.Sprintf("... [%d chars truncated]", len(line)-maxLen)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// truncateLine truncates a single line to maxChars, appending "..." if cut.
|
||||
func truncateLine(line string, maxChars int) string {
|
||||
if maxChars <= 0 {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTruncateTail_LongLines(t *testing.T) {
|
||||
// A single line of 5000 chars should be truncated to defaultMaxLineLen.
|
||||
longLine := strings.Repeat("x", 5000)
|
||||
tr := TruncateTail(longLine, 2000, 50*1024)
|
||||
|
||||
if len(tr.Content) > defaultMaxLineLen+100 { // +100 for the "[...N chars truncated]" suffix
|
||||
t.Errorf("single long line not truncated: got %d chars, want <= %d", len(tr.Content), defaultMaxLineLen+100)
|
||||
}
|
||||
if !strings.Contains(tr.Content, "chars truncated]") {
|
||||
t.Error("truncated line should contain truncation marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateTail_NormalLines(t *testing.T) {
|
||||
// Lines within the limit should pass through unchanged.
|
||||
content := "line1\nline2\nline3"
|
||||
tr := TruncateTail(content, 2000, 50*1024)
|
||||
if tr.Content != content {
|
||||
t.Errorf("got %q, want %q", tr.Content, content)
|
||||
}
|
||||
if tr.Truncated {
|
||||
t.Error("should not be marked as truncated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateTail_LineCount(t *testing.T) {
|
||||
lines := make([]string, 100)
|
||||
for i := range lines {
|
||||
lines[i] = "line"
|
||||
}
|
||||
content := strings.Join(lines, "\n")
|
||||
tr := TruncateTail(content, 10, 50*1024)
|
||||
|
||||
if !tr.Truncated {
|
||||
t.Error("should be marked as truncated")
|
||||
}
|
||||
if tr.Total != 100 {
|
||||
t.Errorf("total = %d, want 100", tr.Total)
|
||||
}
|
||||
if tr.Kept != 10 {
|
||||
t.Errorf("kept = %d, want 10", tr.Kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateHead_LongLines(t *testing.T) {
|
||||
longLine := strings.Repeat("y", 5000)
|
||||
tr := truncateHead(longLine, 2000, 50*1024)
|
||||
|
||||
if len(tr.Content) > defaultMaxLineLen+100 {
|
||||
t.Errorf("single long line not truncated: got %d chars, want <= %d", len(tr.Content), defaultMaxLineLen+100)
|
||||
}
|
||||
if !strings.Contains(tr.Content, "chars truncated]") {
|
||||
t.Error("truncated line should contain truncation marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateHead_NormalLines(t *testing.T) {
|
||||
content := "line1\nline2\nline3"
|
||||
tr := truncateHead(content, 2000, 50*1024)
|
||||
if tr.Content != content {
|
||||
t.Errorf("got %q, want %q", tr.Content, content)
|
||||
}
|
||||
if tr.Truncated {
|
||||
t.Error("should not be marked as truncated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateHead_LineCount(t *testing.T) {
|
||||
lines := make([]string, 100)
|
||||
for i := range lines {
|
||||
lines[i] = "line"
|
||||
}
|
||||
content := strings.Join(lines, "\n")
|
||||
tr := truncateHead(content, 10, 50*1024)
|
||||
|
||||
if !tr.Truncated {
|
||||
t.Error("should be marked as truncated")
|
||||
}
|
||||
if tr.Total != 100 {
|
||||
t.Errorf("total = %d, want 100", tr.Total)
|
||||
}
|
||||
if tr.Kept != 10 {
|
||||
t.Errorf("kept = %d, want 10", tr.Kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateLongLines(t *testing.T) {
|
||||
lines := []string{
|
||||
"short",
|
||||
strings.Repeat("a", 3000),
|
||||
"also short",
|
||||
}
|
||||
result := truncateLongLines(lines, 100)
|
||||
|
||||
if result[0] != "short" {
|
||||
t.Error("short line should be unchanged")
|
||||
}
|
||||
if len(result[1]) > 200 { // 100 chars + marker
|
||||
t.Errorf("long line not truncated: len=%d", len(result[1]))
|
||||
}
|
||||
if !strings.Contains(result[1], "chars truncated]") {
|
||||
t.Error("should contain truncation marker")
|
||||
}
|
||||
if result[2] != "also short" {
|
||||
t.Error("short line should be unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateTail_MixedLongAndManyLines(t *testing.T) {
|
||||
// 50 lines, each 3000 chars — tests both per-line and total truncation.
|
||||
lines := make([]string, 50)
|
||||
for i := range lines {
|
||||
lines[i] = strings.Repeat("z", 3000)
|
||||
}
|
||||
content := strings.Join(lines, "\n")
|
||||
|
||||
tr := TruncateTail(content, 10, 50*1024)
|
||||
|
||||
// Should keep 10 lines.
|
||||
if tr.Kept != 10 {
|
||||
t.Errorf("kept = %d, want 10", tr.Kept)
|
||||
}
|
||||
// Each line should be capped at ~defaultMaxLineLen.
|
||||
resultLines := strings.Split(tr.Content, "\n")
|
||||
for i, line := range resultLines {
|
||||
if len(line) > defaultMaxLineLen+100 {
|
||||
t.Errorf("line %d too long: %d chars", i, len(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateLine(t *testing.T) {
|
||||
short := "hello"
|
||||
if truncateLine(short, 10) != short {
|
||||
t.Error("short line should be unchanged")
|
||||
}
|
||||
|
||||
long := strings.Repeat("x", 100)
|
||||
result := truncateLine(long, 10)
|
||||
if len(result) != 13 { // 10 + "..."
|
||||
t.Errorf("got len %d, want 13", len(result))
|
||||
}
|
||||
|
||||
// Default max for 0 — input shorter than default, so unchanged
|
||||
result2 := truncateLine(long, 0)
|
||||
if result2 != long {
|
||||
t.Errorf("100-char line should be unchanged when maxChars defaults to %d", grepMaxLineLen)
|
||||
}
|
||||
|
||||
// Longer input with default
|
||||
veryLong := strings.Repeat("x", 1000)
|
||||
result3 := truncateLine(veryLong, 0)
|
||||
if len(result3) != grepMaxLineLen+3 {
|
||||
t.Errorf("got len %d, want %d", len(result3), grepMaxLineLen+3)
|
||||
}
|
||||
}
|
||||
+32
-1
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
@@ -53,6 +54,14 @@ func executeWrite(ctx context.Context, call fantasy.ToolCall, workDir string) (f
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid path: %v", err)), nil
|
||||
}
|
||||
|
||||
// Read existing content before writing (for diff metadata).
|
||||
var beforeContent string
|
||||
isNew := true
|
||||
if existing, readErr := os.ReadFile(absPath); readErr == nil {
|
||||
beforeContent = string(existing)
|
||||
isNew = false
|
||||
}
|
||||
|
||||
// Create parent directories
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
@@ -63,5 +72,27 @@ func executeWrite(ctx context.Context, call fantasy.ToolCall, workDir string) (f
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to write file: %v", err)), nil
|
||||
}
|
||||
|
||||
return fantasy.NewTextResponse(fmt.Sprintf("Wrote %d bytes to %s", len(args.Content), args.Path)), nil
|
||||
resp := fantasy.NewTextResponse(fmt.Sprintf("Wrote %d bytes to %s", len(args.Content), args.Path))
|
||||
return fantasy.WithResponseMetadata(resp, writeDiffMeta(absPath, beforeContent, args.Content, isNew)), nil
|
||||
}
|
||||
|
||||
// writeDiffMeta builds the structured metadata attached to write tool responses.
|
||||
func writeDiffMeta(path, beforeContent, afterContent string, isNew bool) map[string]any {
|
||||
additions := strings.Count(afterContent, "\n") + 1
|
||||
deletions := 0
|
||||
if !isNew {
|
||||
deletions = strings.Count(beforeContent, "\n") + 1
|
||||
}
|
||||
return map[string]any{
|
||||
"file_diffs": []map[string]any{{
|
||||
"path": path,
|
||||
"additions": additions,
|
||||
"deletions": deletions,
|
||||
"is_new": isNew,
|
||||
"diff_blocks": []map[string]any{{
|
||||
"old_text": beforeContent,
|
||||
"new_text": afterContent,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
+968
-26
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
// Package extensions implements a Pi-style in-process extension system for KIT.
|
||||
// Package extensions implements an in-process extension system for KIT.
|
||||
// Extensions are plain Go files loaded at runtime via Yaegi (a Go interpreter).
|
||||
// They register event handlers using an API object, enabling tool interception,
|
||||
// input transformation, and lifecycle observation — all without recompilation.
|
||||
@@ -48,6 +48,26 @@ const (
|
||||
|
||||
// SessionShutdown fires when the application is closing.
|
||||
SessionShutdown EventType = "session_shutdown"
|
||||
|
||||
// ModelChange fires after the active model is changed via ctx.SetModel().
|
||||
ModelChange EventType = "model_change"
|
||||
|
||||
// ContextPrepare fires after context is built from the session tree and
|
||||
// before the messages are sent to the LLM. Handlers can filter, reorder,
|
||||
// or inject messages into the context window.
|
||||
ContextPrepare EventType = "context_prepare"
|
||||
|
||||
// BeforeFork fires before the session tree is branched to a different
|
||||
// entry point. Handlers can cancel the fork by returning Cancel=true.
|
||||
BeforeFork EventType = "before_fork"
|
||||
|
||||
// BeforeSessionSwitch fires before the session is switched to a new
|
||||
// branch (e.g. /new command). Handlers can cancel by returning Cancel=true.
|
||||
BeforeSessionSwitch EventType = "before_session_switch"
|
||||
|
||||
// BeforeCompact fires before context compaction runs. Handlers can
|
||||
// cancel compaction by returning Cancel=true.
|
||||
BeforeCompact EventType = "before_compact"
|
||||
)
|
||||
|
||||
// AllEventTypes returns every supported event type.
|
||||
@@ -57,6 +77,8 @@ func AllEventTypes() []EventType {
|
||||
Input, BeforeAgentStart, AgentStart, AgentEnd,
|
||||
MessageStart, MessageUpdate, MessageEnd,
|
||||
SessionStart, SessionShutdown,
|
||||
ModelChange, ContextPrepare,
|
||||
BeforeFork, BeforeSessionSwitch, BeforeCompact,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import "testing"
|
||||
|
||||
func TestAllEventTypes_Count(t *testing.T) {
|
||||
all := AllEventTypes()
|
||||
if len(all) != 13 {
|
||||
t.Fatalf("expected 13 event types, got %d", len(all))
|
||||
if len(all) != 18 {
|
||||
t.Fatalf("expected 18 event types, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ func TestEventType_TypeMethod(t *testing.T) {
|
||||
{MessageEndEvent{Content: "done"}, MessageEnd},
|
||||
{SessionStartEvent{SessionID: "abc"}, SessionStart},
|
||||
{SessionShutdownEvent{}, SessionShutdown},
|
||||
{ModelChangeEvent{NewModel: "a/b"}, ModelChange},
|
||||
{ContextPrepareEvent{Messages: []ContextMessage{{Index: 0, Role: "user", Content: "hi"}}}, ContextPrepare},
|
||||
{BeforeForkEvent{TargetID: "abc"}, BeforeFork},
|
||||
{BeforeSessionSwitchEvent{Reason: "new"}, BeforeSessionSwitch},
|
||||
{BeforeCompactEvent{EstimatedTokens: 1000}, BeforeCompact},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,219 @@ func findExtensionsInDir(dir string) []string {
|
||||
return results
|
||||
}
|
||||
|
||||
// findExtensionsInRepo scans a git repository for extensions using opinionated conventions.
|
||||
// Extensions are ONLY recognized in:
|
||||
// 1. Root-level *.go files
|
||||
// 2. Files in examples/extensions/ or examples/ext/ subdirectories
|
||||
// 3. Files in any top-level ext/ directory
|
||||
// 4. Files in any subdirectory that ends in -ext/ or -extensions/
|
||||
//
|
||||
// Everything else (cmd/, internal/, pkg/, etc.) is ignored.
|
||||
func findExtensionsInRepo(repoPath string) []string {
|
||||
var results []string
|
||||
multiFileDirs := make(map[string]bool)
|
||||
|
||||
_ = filepath.Walk(repoPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, _ := filepath.Rel(repoPath, path)
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
|
||||
// Skip directories we know don't contain extensions
|
||||
if info.IsDir() {
|
||||
switch info.Name() {
|
||||
case ".git", ".github", "node_modules", "vendor", "dist", "build":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Skip internal code directories
|
||||
if strings.HasPrefix(relPath, "internal/") ||
|
||||
strings.HasPrefix(relPath, "cmd/") ||
|
||||
strings.HasPrefix(relPath, "pkg/") ||
|
||||
strings.HasPrefix(relPath, "test/") ||
|
||||
strings.HasPrefix(relPath, "tests/") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Root directory - scan it
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
base := info.Name()
|
||||
isExtDir := base == "extensions" || base == "ext" ||
|
||||
strings.HasSuffix(base, "-extensions") || strings.HasSuffix(base, "-ext")
|
||||
|
||||
isExamplesSubdir := relPath == "examples" || strings.HasPrefix(relPath, "examples/")
|
||||
|
||||
if !isExtDir && !isExamplesSubdir {
|
||||
mainPath := filepath.Join(path, "main.go")
|
||||
if _, err := os.Stat(mainPath); err == nil {
|
||||
if relPath == base { // Top-level directory
|
||||
if !multiFileDirs[relPath] {
|
||||
multiFileDirs[relPath] = true
|
||||
results = append(results, mainPath)
|
||||
}
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if isExamplesSubdir || isExtDir {
|
||||
if !multiFileDirs[relPath] {
|
||||
multiFileDirs[relPath] = true
|
||||
results = append(results, mainPath)
|
||||
}
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Check for main.go
|
||||
mainPath := filepath.Join(path, "main.go")
|
||||
if _, err := os.Stat(mainPath); err == nil {
|
||||
if !multiFileDirs[relPath] {
|
||||
multiFileDirs[relPath] = true
|
||||
results = append(results, mainPath)
|
||||
}
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// It's a file
|
||||
if !strings.HasSuffix(info.Name(), ".go") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if info.Name() == "main.go" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parentDir := filepath.Dir(relPath)
|
||||
if parentDir == "." {
|
||||
// Root-level .go file - valid extension
|
||||
results = append(results, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Must be in valid extension directory
|
||||
isValidExtDir := false
|
||||
if strings.HasPrefix(parentDir, "examples/extensions/") ||
|
||||
parentDir == "examples/extensions" {
|
||||
isValidExtDir = true
|
||||
} else if strings.HasPrefix(parentDir, "examples/ext/") ||
|
||||
parentDir == "examples/ext" {
|
||||
isValidExtDir = true
|
||||
} else if strings.HasPrefix(parentDir, "ext/") ||
|
||||
parentDir == "ext" {
|
||||
isValidExtDir = true
|
||||
} else if strings.Contains(parentDir, "-extensions/") ||
|
||||
strings.HasSuffix(parentDir, "-extensions") {
|
||||
isValidExtDir = true
|
||||
} else if strings.Contains(parentDir, "-ext/") ||
|
||||
strings.HasSuffix(parentDir, "-ext") {
|
||||
isValidExtDir = true
|
||||
}
|
||||
|
||||
if !isValidExtDir {
|
||||
return nil
|
||||
}
|
||||
|
||||
results = append(results, path)
|
||||
return nil
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Each git package is stored at <gitRoot>/<host>/<owner>/<repo>/ 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 using convention-based scanning
|
||||
results = append(results, findExtensionsInRepo(repoPath)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// globalExtensionsDir returns the global extensions directory, respecting
|
||||
// $XDG_CONFIG_HOME. Defaults to ~/.config/kit/extensions.
|
||||
func globalExtensionsDir() string {
|
||||
@@ -283,6 +508,48 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onModelChange: func(h func(ModelChangeEvent, Context)) {
|
||||
reg(ModelChange, func(e Event, c Context) Result {
|
||||
h(e.(ModelChangeEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onContextPrepare: func(h func(ContextPrepareEvent, Context) *ContextPrepareResult) {
|
||||
reg(ContextPrepare, func(e Event, c Context) Result {
|
||||
r := h(e.(ContextPrepareEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onBeforeFork: func(h func(BeforeForkEvent, Context) *BeforeForkResult) {
|
||||
reg(BeforeFork, func(e Event, c Context) Result {
|
||||
r := h(e.(BeforeForkEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onBeforeSessionSwitch: func(h func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) {
|
||||
reg(BeforeSessionSwitch, func(e Event, c Context) Result {
|
||||
r := h(e.(BeforeSessionSwitchEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onBeforeCompact: func(h func(BeforeCompactEvent, Context) *BeforeCompactResult) {
|
||||
reg(BeforeCompact, func(e Event, c Context) Result {
|
||||
r := h(e.(BeforeCompactEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
registerToolFn: func(tool ToolDef) {
|
||||
ext.Tools = append(ext.Tools, tool)
|
||||
},
|
||||
@@ -292,6 +559,21 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
|
||||
registerToolRendererFn: func(config ToolRenderConfig) {
|
||||
ext.ToolRenderers = append(ext.ToolRenderers, config)
|
||||
},
|
||||
registerMessageRendererFn: func(config MessageRendererConfig) {
|
||||
ext.MessageRenderers = append(ext.MessageRenderers, config)
|
||||
},
|
||||
onCustomEvent: func(name string, handler func(string)) {
|
||||
if ext.CustomEventHandlers == nil {
|
||||
ext.CustomEventHandlers = make(map[string][]func(string))
|
||||
}
|
||||
ext.CustomEventHandlers[name] = append(ext.CustomEventHandlers[name], handler)
|
||||
},
|
||||
registerOption: func(opt OptionDef) {
|
||||
ext.Options = append(ext.Options, opt)
|
||||
},
|
||||
registerShortcutFn: func(def ShortcutDef, handler func(Context)) {
|
||||
ext.Shortcuts = append(ext.Shortcuts, ShortcutEntry{Def: def, Handler: handler})
|
||||
},
|
||||
}
|
||||
|
||||
// Call Init — the extension registers its handlers, tools, commands.
|
||||
|
||||
@@ -304,6 +304,15 @@ func Init(api ext.API) {
|
||||
func TestLoadExtensions_SkipsBadFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Isolate from host environment so globally-installed extensions
|
||||
// are not discovered alongside the test fixtures.
|
||||
isolated := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, "config"))
|
||||
t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, "data"))
|
||||
origWd, _ := os.Getwd()
|
||||
_ = os.Chdir(isolated)
|
||||
t.Cleanup(func() { _ = os.Chdir(origWd) })
|
||||
|
||||
// Good extension
|
||||
good := `package main
|
||||
import "kit/ext"
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
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 using opinionated conventions.
|
||||
// Extensions are ONLY recognized in these specific locations:
|
||||
// 1. Root-level *.go files
|
||||
// 2. Files in examples/extensions/ or examples/ext/ subdirectories
|
||||
// 3. Files in any top-level ext/ directory
|
||||
// 4. Files in any subdirectory that ends in -ext/ or -extensions/
|
||||
//
|
||||
// Everything else (cmd/, internal/, pkg/, etc.) is ignored.
|
||||
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
|
||||
multiFileDirs := make(map[string]bool)
|
||||
|
||||
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, _ := filepath.Rel(dir, path)
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
|
||||
// Skip directories we know don't contain extensions
|
||||
if info.IsDir() {
|
||||
// Never scan these directories
|
||||
switch info.Name() {
|
||||
case ".git", ".github", "node_modules", "vendor", "dist", "build":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Skip internal code directories
|
||||
if strings.HasPrefix(relPath, "internal/") ||
|
||||
strings.HasPrefix(relPath, "cmd/") ||
|
||||
strings.HasPrefix(relPath, "pkg/") ||
|
||||
strings.HasPrefix(relPath, "test/") ||
|
||||
strings.HasPrefix(relPath, "tests/") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Root directory - scan it
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this directory is an extension location by name
|
||||
// Pattern: must be named "extensions", "ext", or end with those
|
||||
base := info.Name()
|
||||
isExtDir := base == "extensions" || base == "ext" ||
|
||||
strings.HasSuffix(base, "-extensions") || strings.HasSuffix(base, "-ext")
|
||||
|
||||
// Or check if it's a subdirectory of examples/ that might contain extensions
|
||||
isExamplesSubdir := relPath == "examples" || strings.HasPrefix(relPath, "examples/")
|
||||
|
||||
if !isExtDir && !isExamplesSubdir {
|
||||
// Check for main.go before skipping
|
||||
mainPath := filepath.Join(path, "main.go")
|
||||
if _, err := os.Stat(mainPath); err == nil {
|
||||
// This is a package with main.go at root level
|
||||
if relPath == base { // Top-level directory
|
||||
if !multiFileDirs[relPath] {
|
||||
multiFileDirs[relPath] = true
|
||||
previews = append(previews, ExtensionPreview{
|
||||
Path: "./" + relPath + "/main.go",
|
||||
Name: deriveExtensionName(relPath+"/main.go", true),
|
||||
IsMain: true,
|
||||
})
|
||||
}
|
||||
return filepath.SkipDir
|
||||
}
|
||||
// Inside a valid extensions directory
|
||||
if isExamplesSubdir || isExtDir {
|
||||
if !multiFileDirs[relPath] {
|
||||
multiFileDirs[relPath] = true
|
||||
previews = append(previews, ExtensionPreview{
|
||||
Path: "./" + relPath + "/main.go",
|
||||
Name: deriveExtensionName(relPath+"/main.go", true),
|
||||
IsMain: true,
|
||||
})
|
||||
}
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
|
||||
// Not an extension location
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Check for main.go in this directory
|
||||
mainPath := filepath.Join(path, "main.go")
|
||||
if _, err := os.Stat(mainPath); err == nil {
|
||||
if !multiFileDirs[relPath] {
|
||||
multiFileDirs[relPath] = true
|
||||
previews = append(previews, ExtensionPreview{
|
||||
Path: "./" + relPath + "/main.go",
|
||||
Name: deriveExtensionName(relPath+"/main.go", true),
|
||||
IsMain: true,
|
||||
})
|
||||
}
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Scan this extensions directory
|
||||
return nil
|
||||
}
|
||||
|
||||
// It's a file - check if it's a valid extension
|
||||
if !strings.HasSuffix(info.Name(), ".go") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if info.Name() == "main.go" {
|
||||
return nil // Already handled above
|
||||
}
|
||||
|
||||
// Check if parent is a valid extension location
|
||||
parentDir := filepath.Dir(relPath)
|
||||
if parentDir == "." {
|
||||
// Root-level .go file - valid extension
|
||||
previews = append(previews, ExtensionPreview{
|
||||
Path: "./" + relPath,
|
||||
Name: deriveExtensionName(relPath, false),
|
||||
IsMain: false,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we're in a valid extension directory
|
||||
// Valid locations are:
|
||||
// - examples/extensions/*
|
||||
// - examples/ext/*
|
||||
// - ext/* (top-level)
|
||||
// - Any *-extensions/* or *-ext/* directory
|
||||
isValidExtDir := false
|
||||
if strings.HasPrefix(parentDir, "examples/extensions/") ||
|
||||
parentDir == "examples/extensions" {
|
||||
isValidExtDir = true
|
||||
} else if strings.HasPrefix(parentDir, "examples/ext/") ||
|
||||
parentDir == "examples/ext" {
|
||||
isValidExtDir = true
|
||||
} else if strings.HasPrefix(parentDir, "ext/") ||
|
||||
parentDir == "ext" {
|
||||
isValidExtDir = true
|
||||
} else if strings.Contains(parentDir, "-extensions/") ||
|
||||
strings.HasSuffix(parentDir, "-extensions") {
|
||||
isValidExtDir = true
|
||||
} else if strings.Contains(parentDir, "-ext/") ||
|
||||
strings.HasSuffix(parentDir, "-ext") {
|
||||
isValidExtDir = true
|
||||
}
|
||||
|
||||
if !isValidExtDir {
|
||||
return nil
|
||||
}
|
||||
|
||||
previews = append(previews, ExtensionPreview{
|
||||
Path: "./" + relPath,
|
||||
Name: deriveExtensionName(relPath, 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 immediate parent directory name for main.go files
|
||||
name := filepath.Base(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)
|
||||
}
|
||||
+333
-20
@@ -2,34 +2,52 @@ package extensions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Runner manages loaded extensions and dispatches events to their handlers
|
||||
// sequentially, mirroring Pi's ExtensionRunner. Handlers execute in extension
|
||||
// sequentially. Handlers execute in extension
|
||||
// load order; for cancellable events the first blocking result wins.
|
||||
type Runner struct {
|
||||
extensions []LoadedExtension
|
||||
ctx Context
|
||||
widgets map[string]WidgetConfig // keyed by widget ID
|
||||
header *HeaderFooterConfig // nil = no custom header
|
||||
footer *HeaderFooterConfig // nil = no custom footer
|
||||
customEditor *EditorConfig // nil = no custom editor interceptor
|
||||
mu sync.RWMutex
|
||||
extensions []LoadedExtension
|
||||
ctx Context
|
||||
widgets map[string]WidgetConfig // keyed by widget ID
|
||||
statusEntries map[string]StatusBarEntry // keyed by status key
|
||||
header *HeaderFooterConfig // nil = no custom header
|
||||
footer *HeaderFooterConfig // nil = no custom footer
|
||||
customEditor *EditorConfig // nil = no custom editor interceptor
|
||||
uiVisibility *UIVisibility // nil = show everything (default)
|
||||
disabledTools map[string]bool // nil = all tools enabled
|
||||
customEventSubs map[string][]func(string) // inter-extension event bus
|
||||
optionOverrides map[string]string // runtime option overrides
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// ShortcutEntry pairs a shortcut definition with its handler.
|
||||
type ShortcutEntry struct {
|
||||
Def ShortcutDef
|
||||
Handler func(Context)
|
||||
}
|
||||
|
||||
// LoadedExtension represents a single extension that has been discovered,
|
||||
// loaded, and initialised. It holds the registered handlers and any custom
|
||||
// tools, commands, or tool renderers the extension provided.
|
||||
type LoadedExtension struct {
|
||||
Path string
|
||||
Handlers map[EventType][]HandlerFunc
|
||||
Tools []ToolDef
|
||||
Commands []CommandDef
|
||||
ToolRenderers []ToolRenderConfig
|
||||
Path string
|
||||
Handlers map[EventType][]HandlerFunc
|
||||
Tools []ToolDef
|
||||
Commands []CommandDef
|
||||
ToolRenderers []ToolRenderConfig
|
||||
MessageRenderers []MessageRendererConfig // named message renderers
|
||||
CustomEventHandlers map[string][]func(string) // inter-extension event bus
|
||||
Options []OptionDef // registered configuration options
|
||||
Shortcuts []ShortcutEntry // global keyboard shortcuts
|
||||
}
|
||||
|
||||
// NewRunner creates a Runner from a set of loaded extensions.
|
||||
@@ -45,6 +63,13 @@ func (r *Runner) SetContext(ctx Context) {
|
||||
r.ctx = ctx
|
||||
}
|
||||
|
||||
// GetContext returns a snapshot of the current runtime context. Thread-safe.
|
||||
func (r *Runner) GetContext() Context {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.ctx
|
||||
}
|
||||
|
||||
// HasHandlers returns true if any loaded extension has at least one handler
|
||||
// registered for the given event type.
|
||||
func (r *Runner) HasHandlers(event EventType) bool {
|
||||
@@ -121,13 +146,6 @@ func (r *Runner) RegisteredCommands() []CommandDef {
|
||||
return cmds
|
||||
}
|
||||
|
||||
// GetContext returns the current runtime context. Thread-safe.
|
||||
func (r *Runner) GetContext() Context {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.ctx
|
||||
}
|
||||
|
||||
// Extensions returns the loaded extensions for inspection (e.g. CLI list).
|
||||
func (r *Runner) Extensions() []LoadedExtension {
|
||||
return r.extensions
|
||||
@@ -177,6 +195,45 @@ func (r *Runner) GetWidgets(placement WidgetPlacement) []WidgetConfig {
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status bar management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetStatusEntry places or updates a keyed status bar entry. Thread-safe.
|
||||
func (r *Runner) SetStatusEntry(entry StatusBarEntry) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.statusEntries == nil {
|
||||
r.statusEntries = make(map[string]StatusBarEntry)
|
||||
}
|
||||
r.statusEntries[entry.Key] = entry
|
||||
}
|
||||
|
||||
// RemoveStatusEntry removes a status bar entry by key. Thread-safe.
|
||||
func (r *Runner) RemoveStatusEntry(key string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.statusEntries, key)
|
||||
}
|
||||
|
||||
// GetStatusEntries returns all status bar entries, sorted by priority
|
||||
// (ascending). Thread-safe.
|
||||
func (r *Runner) GetStatusEntries() []StatusBarEntry {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
result := make([]StatusBarEntry, 0, len(r.statusEntries))
|
||||
for _, e := range r.statusEntries {
|
||||
result = append(result, e)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Priority != result[j].Priority {
|
||||
return result[i].Priority < result[j].Priority
|
||||
}
|
||||
return result[i].Key < result[j].Key
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header/Footer management
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -269,6 +326,29 @@ func (r *Runner) GetEditor() *EditorConfig {
|
||||
return &e
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI visibility management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetUIVisibility updates the UI visibility overrides. Thread-safe.
|
||||
func (r *Runner) SetUIVisibility(v UIVisibility) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.uiVisibility = &v
|
||||
}
|
||||
|
||||
// GetUIVisibility returns the current UI visibility overrides, or nil if
|
||||
// none have been set (meaning show everything). Thread-safe.
|
||||
func (r *Runner) GetUIVisibility() *UIVisibility {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.uiVisibility == nil {
|
||||
return nil
|
||||
}
|
||||
v := *r.uiVisibility
|
||||
return &v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool renderer management
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -290,6 +370,233 @@ func (r *Runner) GetToolRenderer(toolName string) *ToolRenderConfig {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message renderer management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetMessageRenderer returns the named message renderer, or nil if no
|
||||
// extension registered a renderer with that name. If multiple extensions
|
||||
// register the same name, the last one (by load order) wins.
|
||||
func (r *Runner) GetMessageRenderer(name string) *MessageRendererConfig {
|
||||
for i := len(r.extensions) - 1; i >= 0; i-- {
|
||||
for j := len(r.extensions[i].MessageRenderers) - 1; j >= 0; j-- {
|
||||
if r.extensions[i].MessageRenderers[j].Name == name {
|
||||
config := r.extensions[i].MessageRenderers[j]
|
||||
return &config
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hot-reload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Reload replaces the loaded extensions with a fresh set and clears all
|
||||
// dynamic state (widgets, status, header/footer, editor, visibility,
|
||||
// disabled tools, custom event subscriptions). Option overrides are
|
||||
// preserved across reloads since they represent user intent.
|
||||
//
|
||||
// The caller is responsible for emitting SessionShutdown before calling
|
||||
// Reload and SessionStart after.
|
||||
func (r *Runner) Reload(exts []LoadedExtension) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.extensions = exts
|
||||
r.widgets = nil
|
||||
r.statusEntries = nil
|
||||
r.header = nil
|
||||
r.footer = nil
|
||||
r.customEditor = nil
|
||||
r.uiVisibility = nil
|
||||
r.disabledTools = nil
|
||||
r.customEventSubs = nil
|
||||
// optionOverrides are intentionally preserved.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inter-extension event bus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SubscribeCustomEvent registers a handler for a named custom event. Handlers
|
||||
// execute in registration order when EmitCustomEvent is called. Thread-safe.
|
||||
func (r *Runner) SubscribeCustomEvent(name string, handler func(string)) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.customEventSubs == nil {
|
||||
r.customEventSubs = make(map[string][]func(string))
|
||||
}
|
||||
r.customEventSubs[name] = append(r.customEventSubs[name], handler)
|
||||
}
|
||||
|
||||
// EmitCustomEvent dispatches a named event to all subscribed handlers.
|
||||
// Handlers run synchronously in extension load order. Panics are recovered
|
||||
// and logged. Thread-safe.
|
||||
func (r *Runner) EmitCustomEvent(name, data string) {
|
||||
// Collect handlers: extension-registered (Init-time) + dynamic subs.
|
||||
r.mu.RLock()
|
||||
dynamicHandlers := r.customEventSubs[name]
|
||||
r.mu.RUnlock()
|
||||
|
||||
safeInvoke := func(h func(string)) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Warn("custom event handler panicked",
|
||||
"event", name,
|
||||
"err", fmt.Sprintf("%v", rec))
|
||||
}
|
||||
}()
|
||||
h(data)
|
||||
}
|
||||
|
||||
// Extension-registered handlers first (in load order).
|
||||
for i := range r.extensions {
|
||||
for _, h := range r.extensions[i].CustomEventHandlers[name] {
|
||||
safeInvoke(h)
|
||||
}
|
||||
}
|
||||
// Then dynamic subscriptions.
|
||||
for _, h := range dynamicHandlers {
|
||||
safeInvoke(h)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SetActiveTools restricts the tool set to the named tools. All tools not in
|
||||
// the list are disabled. Passing nil or an empty slice re-enables all tools.
|
||||
// Thread-safe.
|
||||
func (r *Runner) SetActiveTools(names []string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(names) == 0 {
|
||||
r.disabledTools = nil
|
||||
return
|
||||
}
|
||||
active := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
active[n] = true
|
||||
}
|
||||
r.disabledTools = active // non-nil = only these tools are allowed
|
||||
}
|
||||
|
||||
// IsToolDisabled returns true if the tool has been disabled via SetActiveTools.
|
||||
// Thread-safe.
|
||||
func (r *Runner) IsToolDisabled(toolName string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if r.disabledTools == nil {
|
||||
return false // no filter = all enabled
|
||||
}
|
||||
return !r.disabledTools[toolName]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetOption resolves a named option value in priority order:
|
||||
// 1. Runtime override (via SetOption)
|
||||
// 2. Environment variable: KIT_OPT_<NAME> (uppercased, dashes → underscores)
|
||||
// 3. Viper config: options.<name>
|
||||
// 4. Default value from RegisterOption
|
||||
//
|
||||
// Returns empty string if the option was never registered.
|
||||
// Thread-safe.
|
||||
func (r *Runner) GetOption(name string) string {
|
||||
// 1. Runtime override.
|
||||
r.mu.RLock()
|
||||
if v, ok := r.optionOverrides[name]; ok {
|
||||
r.mu.RUnlock()
|
||||
return v
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
// 2. Environment variable: KIT_OPT_<NAME>
|
||||
envKey := "KIT_OPT_" + strings.ToUpper(strings.ReplaceAll(name, "-", "_"))
|
||||
if v := os.Getenv(envKey); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
// 3. Viper config: options.<name>
|
||||
configKey := "options." + name
|
||||
if v := viper.GetString(configKey); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
// 4. Default from registered option defs.
|
||||
for i := range r.extensions {
|
||||
for _, opt := range r.extensions[i].Options {
|
||||
if opt.Name == name {
|
||||
return opt.Default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetOption stores a runtime override for a named option. This takes highest
|
||||
// priority over env vars, config, and defaults. Thread-safe.
|
||||
func (r *Runner) SetOption(name, value string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.optionOverrides == nil {
|
||||
r.optionOverrides = make(map[string]string)
|
||||
}
|
||||
r.optionOverrides[name] = value
|
||||
}
|
||||
|
||||
// RegisteredOptions returns all option definitions from all loaded extensions.
|
||||
func (r *Runner) RegisteredOptions() []OptionDef {
|
||||
var opts []OptionDef
|
||||
for i := range r.extensions {
|
||||
opts = append(opts, r.extensions[i].Options...)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard shortcuts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetShortcuts returns all registered keyboard shortcuts as a map of
|
||||
// key binding → handler. If multiple extensions register the same key,
|
||||
// the last registration wins. Thread-safe (reads extension list which is
|
||||
// immutable after loading).
|
||||
func (r *Runner) GetShortcuts() map[string]ShortcutEntry {
|
||||
result := make(map[string]ShortcutEntry)
|
||||
for i := range r.extensions {
|
||||
for _, sc := range r.extensions[i].Shortcuts {
|
||||
result[sc.Def.Key] = sc
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// RegisteredShortcuts returns all shortcut definitions from all loaded
|
||||
// extensions. Used for help/listing commands.
|
||||
func (r *Runner) RegisteredShortcuts() []ShortcutDef {
|
||||
var defs []ShortcutDef
|
||||
seen := make(map[string]bool)
|
||||
// Iterate in reverse so last registration for a key wins.
|
||||
for i := len(r.extensions) - 1; i >= 0; i-- {
|
||||
for _, sc := range r.extensions[i].Shortcuts {
|
||||
if !seen[sc.Def.Key] {
|
||||
seen[sc.Def.Key] = true
|
||||
defs = append(defs, sc.Def)
|
||||
}
|
||||
}
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -311,6 +618,12 @@ func isBlocking(result Result) bool {
|
||||
return r.Block
|
||||
case InputResult:
|
||||
return r.Action == "handled"
|
||||
case BeforeForkResult:
|
||||
return r.Cancel
|
||||
case BeforeSessionSwitchResult:
|
||||
return r.Cancel
|
||||
case BeforeCompactResult:
|
||||
return r.Cancel
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
// Package extensions provides subagent spawning capabilities for Kit extensions.
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subagent types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SubagentConfig configures a subagent spawn.
|
||||
type SubagentConfig struct {
|
||||
// Prompt is the task/instruction for the subagent (required).
|
||||
Prompt string
|
||||
|
||||
// Model overrides the parent's model (e.g. "anthropic/claude-haiku-3-5-20241022").
|
||||
// Empty string uses the parent's current model.
|
||||
Model string
|
||||
|
||||
// SystemPrompt provides domain-specific instructions.
|
||||
// Empty string uses the default system prompt.
|
||||
SystemPrompt string
|
||||
|
||||
// Timeout limits execution time. Zero means 5 minute default.
|
||||
Timeout time.Duration
|
||||
|
||||
// OnOutput streams stderr output chunks as the subagent runs.
|
||||
// Called from a goroutine; must be safe for concurrent use.
|
||||
OnOutput func(chunk string)
|
||||
|
||||
// OnEvent receives real-time events from the subagent's execution:
|
||||
// text chunks, tool calls, tool results, reasoning deltas, etc.
|
||||
// Called synchronously from the subagent's event loop.
|
||||
OnEvent func(SubagentEvent)
|
||||
|
||||
// OnComplete is called when the subagent finishes (success or error).
|
||||
// Called from a goroutine; must be safe for concurrent use.
|
||||
OnComplete func(result SubagentResult)
|
||||
|
||||
// Blocking, when true, makes SpawnSubagent wait for completion and
|
||||
// return the result directly. When false (default), spawns in background
|
||||
// and returns immediately with a handle.
|
||||
Blocking bool
|
||||
|
||||
// NoSession, when true, runs the subagent without persisting a session
|
||||
// file. By default (false), subagent sessions are persisted so they can
|
||||
// be loaded for replay/inspection. Set to true for ephemeral tasks
|
||||
// where session history is not needed.
|
||||
NoSession bool
|
||||
|
||||
// ParentSessionID links the subagent's session to the parent (optional).
|
||||
// When set, the subagent's session header includes a parent reference
|
||||
// so viewers can navigate the session tree.
|
||||
ParentSessionID string
|
||||
}
|
||||
|
||||
// SubagentEvent carries a real-time event from a running subagent. Extensions
|
||||
// use the Type field to determine what happened and read the relevant fields.
|
||||
// This is a concrete struct (not an interface) for Yaegi compatibility.
|
||||
type SubagentEvent struct {
|
||||
// Type identifies the event: "text", "reasoning", "tool_call",
|
||||
// "tool_result", "tool_execution_start", "tool_execution_end",
|
||||
// "turn_start", "turn_end".
|
||||
Type string
|
||||
|
||||
// Content carries text for "text" and "reasoning" events.
|
||||
Content string
|
||||
|
||||
// ToolCallID is set on tool_call, tool_result, tool_execution_start,
|
||||
// and tool_execution_end events.
|
||||
ToolCallID string
|
||||
// ToolName is set on tool-related events.
|
||||
ToolName string
|
||||
// ToolKind is set on tool-related events.
|
||||
ToolKind string
|
||||
// ToolArgs is set on tool_call events (JSON-encoded).
|
||||
ToolArgs string
|
||||
// ToolResult is set on tool_result events.
|
||||
ToolResult string
|
||||
// IsError is set on tool_result events.
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// SubagentResult contains the outcome of a subagent execution.
|
||||
type SubagentResult struct {
|
||||
// Response is the subagent's final text response.
|
||||
Response string
|
||||
|
||||
// Error is set if the subagent failed (nil on success).
|
||||
Error error
|
||||
|
||||
// ExitCode is the subprocess exit code (0 = success).
|
||||
ExitCode int
|
||||
|
||||
// Elapsed is the total execution time.
|
||||
Elapsed time.Duration
|
||||
|
||||
// Usage contains token usage if available.
|
||||
Usage *SubagentUsage
|
||||
|
||||
// SessionID is the subagent's session identifier, if available.
|
||||
// Populated when the subagent persists its session (requires running
|
||||
// without --no-session). Empty for ephemeral sessions.
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// SubagentUsage contains token usage from the subagent's run.
|
||||
type SubagentUsage struct {
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
}
|
||||
|
||||
// SubagentHandle provides control over a running subagent.
|
||||
type SubagentHandle struct {
|
||||
// ID is a unique identifier for this subagent instance.
|
||||
ID string
|
||||
|
||||
proc *os.Process
|
||||
done chan struct{}
|
||||
result *SubagentResult
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Kill terminates the subagent process.
|
||||
func (h *SubagentHandle) Kill() error {
|
||||
h.mu.Lock()
|
||||
proc := h.proc
|
||||
h.mu.Unlock()
|
||||
if proc != nil {
|
||||
return proc.Kill()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks until the subagent completes and returns the result.
|
||||
func (h *SubagentHandle) Wait() SubagentResult {
|
||||
<-h.done
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.result != nil {
|
||||
return *h.result
|
||||
}
|
||||
return SubagentResult{Error: fmt.Errorf("subagent completed without result")}
|
||||
}
|
||||
|
||||
// Done returns a channel that closes when the subagent completes.
|
||||
func (h *SubagentHandle) Done() <-chan struct{} {
|
||||
return h.done
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// subagentJSONOutput matches the JSON envelope produced by `kit --json`.
|
||||
type subagentJSONOutput struct {
|
||||
Response string `json:"response"`
|
||||
StopReason string `json:"stop_reason,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Usage *struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
} `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
var subagentCounter uint64
|
||||
|
||||
func generateSubagentID() string {
|
||||
n := atomic.AddUint64(&subagentCounter, 1)
|
||||
return fmt.Sprintf("sub-%d-%d", time.Now().UnixNano(), n)
|
||||
}
|
||||
|
||||
func findKitBinary() string {
|
||||
// Try the current process executable first.
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if _, err := os.Stat(exe); err == nil {
|
||||
return exe
|
||||
}
|
||||
}
|
||||
// Fall back to PATH lookup.
|
||||
if p, err := exec.LookPath("kit"); err == nil {
|
||||
return p
|
||||
}
|
||||
return "kit"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SpawnSubagent implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SpawnSubagent spawns a child Kit instance to perform a task.
|
||||
//
|
||||
// When config.Blocking is true, blocks until completion and returns the result
|
||||
// directly (handle is nil). When false, returns immediately with a handle for
|
||||
// monitoring/cancellation.
|
||||
//
|
||||
// The subagent runs with --json --no-session --no-extensions flags by default,
|
||||
// ensuring isolation from the parent's extensions and session state.
|
||||
func SpawnSubagent(cfg SubagentConfig) (*SubagentHandle, *SubagentResult, error) {
|
||||
if cfg.Prompt == "" {
|
||||
return nil, nil, fmt.Errorf("prompt is required")
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
|
||||
kitBinary := findKitBinary()
|
||||
|
||||
// Build subprocess arguments.
|
||||
args := []string{
|
||||
"--json",
|
||||
"--no-extensions",
|
||||
}
|
||||
if cfg.NoSession {
|
||||
args = append(args, "--no-session")
|
||||
}
|
||||
if cfg.Model != "" {
|
||||
args = append(args, "--model", cfg.Model)
|
||||
}
|
||||
|
||||
// Handle system prompt - write to temp file if provided.
|
||||
var tmpFile *os.File
|
||||
if cfg.SystemPrompt != "" {
|
||||
var err error
|
||||
tmpFile, err = os.CreateTemp("", "kit-subagent-*.txt")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
if _, err := tmpFile.WriteString(cfg.SystemPrompt); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
return nil, nil, fmt.Errorf("write system prompt: %w", err)
|
||||
}
|
||||
_ = tmpFile.Close()
|
||||
args = append(args, "--system-prompt", tmpFile.Name())
|
||||
}
|
||||
|
||||
// Add the prompt as a positional argument.
|
||||
args = append(args, cfg.Prompt)
|
||||
|
||||
// Create command with timeout context.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
|
||||
cmd := exec.CommandContext(ctx, kitBinary, args...)
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
if tmpFile != nil {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}
|
||||
return nil, nil, fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
if tmpFile != nil {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}
|
||||
return nil, nil, fmt.Errorf("stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
handle := &SubagentHandle{
|
||||
ID: generateSubagentID(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Start the subprocess.
|
||||
start := time.Now()
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
if tmpFile != nil {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}
|
||||
return nil, nil, fmt.Errorf("start subprocess: %w", err)
|
||||
}
|
||||
|
||||
handle.mu.Lock()
|
||||
handle.proc = cmd.Process
|
||||
handle.mu.Unlock()
|
||||
|
||||
// Run the subprocess monitoring in a goroutine.
|
||||
go func() {
|
||||
defer close(handle.done)
|
||||
defer cancel()
|
||||
if tmpFile != nil {
|
||||
defer func() { _ = os.Remove(tmpFile.Name()) }()
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var stdoutBuf strings.Builder
|
||||
|
||||
// Read stderr (live output).
|
||||
wg.Go(func() {
|
||||
scanner := bufio.NewScanner(stderr)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if cfg.OnOutput != nil && strings.TrimSpace(line) != "" {
|
||||
cfg.OnOutput(line + "\n")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Read stdout (JSON output).
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
stdoutBuf.WriteString(scanner.Text() + "\n")
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
waitErr := cmd.Wait()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Build result.
|
||||
result := SubagentResult{Elapsed: elapsed}
|
||||
if waitErr != nil {
|
||||
result.Error = waitErr
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
result.ExitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON output.
|
||||
raw := strings.TrimSpace(stdoutBuf.String())
|
||||
var parsed subagentJSONOutput
|
||||
if raw != "" && json.Unmarshal([]byte(raw), &parsed) == nil {
|
||||
result.Response = parsed.Response
|
||||
result.SessionID = parsed.SessionID
|
||||
if parsed.Usage != nil {
|
||||
result.Usage = &SubagentUsage{
|
||||
InputTokens: parsed.Usage.InputTokens,
|
||||
OutputTokens: parsed.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: use raw stdout.
|
||||
result.Response = raw
|
||||
}
|
||||
|
||||
handle.mu.Lock()
|
||||
handle.result = &result
|
||||
handle.proc = nil
|
||||
handle.mu.Unlock()
|
||||
|
||||
if cfg.OnComplete != nil {
|
||||
cfg.OnComplete(result)
|
||||
}
|
||||
}()
|
||||
|
||||
if cfg.Blocking {
|
||||
// Wait for completion and return result directly.
|
||||
<-handle.done
|
||||
handle.mu.Lock()
|
||||
r := handle.result
|
||||
handle.mu.Unlock()
|
||||
return nil, r, nil
|
||||
}
|
||||
|
||||
return handle, nil, nil
|
||||
}
|
||||
@@ -23,9 +23,31 @@ func Symbols() interp.Exports {
|
||||
"API": reflect.ValueOf((*API)(nil)),
|
||||
"Context": reflect.ValueOf((*Context)(nil)),
|
||||
"ToolDef": reflect.ValueOf((*ToolDef)(nil)),
|
||||
"ToolContext": reflect.ValueOf((*ToolContext)(nil)),
|
||||
"ShortcutDef": reflect.ValueOf((*ShortcutDef)(nil)),
|
||||
"CommandDef": reflect.ValueOf((*CommandDef)(nil)),
|
||||
"PrintBlockOpts": reflect.ValueOf((*PrintBlockOpts)(nil)),
|
||||
|
||||
// Session types
|
||||
"SessionMessage": reflect.ValueOf((*SessionMessage)(nil)),
|
||||
"ExtensionEntry": reflect.ValueOf((*ExtensionEntry)(nil)),
|
||||
|
||||
// Option types
|
||||
"OptionDef": reflect.ValueOf((*OptionDef)(nil)),
|
||||
|
||||
// Model info types
|
||||
"ModelInfoEntry": reflect.ValueOf((*ModelInfoEntry)(nil)),
|
||||
|
||||
// Tool info types
|
||||
"ToolInfo": reflect.ValueOf((*ToolInfo)(nil)),
|
||||
|
||||
// LLM completion types
|
||||
"CompleteRequest": reflect.ValueOf((*CompleteRequest)(nil)),
|
||||
"CompleteResponse": reflect.ValueOf((*CompleteResponse)(nil)),
|
||||
|
||||
// Status bar types
|
||||
"StatusBarEntry": reflect.ValueOf((*StatusBarEntry)(nil)),
|
||||
|
||||
// Widget types
|
||||
"WidgetConfig": reflect.ValueOf((*WidgetConfig)(nil)),
|
||||
"WidgetContent": reflect.ValueOf((*WidgetContent)(nil)),
|
||||
@@ -37,6 +59,12 @@ func Symbols() interp.Exports {
|
||||
// Header/Footer types
|
||||
"HeaderFooterConfig": reflect.ValueOf((*HeaderFooterConfig)(nil)),
|
||||
|
||||
// UI visibility
|
||||
"UIVisibility": reflect.ValueOf((*UIVisibility)(nil)),
|
||||
|
||||
// Context stats
|
||||
"ContextStats": reflect.ValueOf((*ContextStats)(nil)),
|
||||
|
||||
// Overlay types
|
||||
"OverlayAnchor": reflect.ValueOf((*OverlayAnchor)(nil)),
|
||||
"OverlayCenter": reflect.ValueOf(OverlayCenter),
|
||||
@@ -49,6 +77,9 @@ func Symbols() interp.Exports {
|
||||
// Tool renderer types
|
||||
"ToolRenderConfig": reflect.ValueOf((*ToolRenderConfig)(nil)),
|
||||
|
||||
// Message renderer types
|
||||
"MessageRendererConfig": reflect.ValueOf((*MessageRendererConfig)(nil)),
|
||||
|
||||
// Editor interceptor types
|
||||
"EditorKeyActionType": reflect.ValueOf((*EditorKeyActionType)(nil)),
|
||||
"EditorKeyPassthrough": reflect.ValueOf(EditorKeyPassthrough),
|
||||
@@ -59,12 +90,38 @@ 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)),
|
||||
"ContextPrepareEvent": reflect.ValueOf((*ContextPrepareEvent)(nil)),
|
||||
"ContextPrepareResult": reflect.ValueOf((*ContextPrepareResult)(nil)),
|
||||
|
||||
// Session lifecycle types
|
||||
"BeforeForkEvent": reflect.ValueOf((*BeforeForkEvent)(nil)),
|
||||
"BeforeForkResult": reflect.ValueOf((*BeforeForkResult)(nil)),
|
||||
"BeforeSessionSwitchEvent": reflect.ValueOf((*BeforeSessionSwitchEvent)(nil)),
|
||||
"BeforeSessionSwitchResult": reflect.ValueOf((*BeforeSessionSwitchResult)(nil)),
|
||||
"BeforeCompactEvent": reflect.ValueOf((*BeforeCompactEvent)(nil)),
|
||||
"BeforeCompactResult": reflect.ValueOf((*BeforeCompactResult)(nil)),
|
||||
|
||||
// Subagent types
|
||||
"SubagentConfig": reflect.ValueOf((*SubagentConfig)(nil)),
|
||||
"SubagentResult": reflect.ValueOf((*SubagentResult)(nil)),
|
||||
"SubagentUsage": reflect.ValueOf((*SubagentUsage)(nil)),
|
||||
"SubagentHandle": reflect.ValueOf((*SubagentHandle)(nil)),
|
||||
"SubagentEvent": reflect.ValueOf((*SubagentEvent)(nil)),
|
||||
|
||||
// Theme types
|
||||
"ThemeColor": reflect.ValueOf((*ThemeColor)(nil)),
|
||||
"ThemeColorConfig": reflect.ValueOf((*ThemeColorConfig)(nil)),
|
||||
|
||||
// Event structs
|
||||
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
|
||||
@@ -84,6 +141,7 @@ func Symbols() interp.Exports {
|
||||
"MessageEndEvent": reflect.ValueOf((*MessageEndEvent)(nil)),
|
||||
"SessionStartEvent": reflect.ValueOf((*SessionStartEvent)(nil)),
|
||||
"SessionShutdownEvent": reflect.ValueOf((*SessionShutdownEvent)(nil)),
|
||||
"ModelChangeEvent": reflect.ValueOf((*ModelChangeEvent)(nil)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package extensions
|
||||
|
||||
// NewTestAPI creates an API object wired for testing.
|
||||
// This is used by the test harness to load extensions and verify behavior.
|
||||
// The registration functions wire handlers directly to the provided extension.
|
||||
func NewTestAPI(ext *LoadedExtension) API {
|
||||
reg := func(event EventType, fn HandlerFunc) {
|
||||
ext.Handlers[event] = append(ext.Handlers[event], fn)
|
||||
}
|
||||
|
||||
return API{
|
||||
onToolCall: func(h func(ToolCallEvent, Context) *ToolCallResult) {
|
||||
reg(ToolCall, func(e Event, c Context) Result {
|
||||
r := h(e.(ToolCallEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onToolExecStart: func(h func(ToolExecutionStartEvent, Context)) {
|
||||
reg(ToolExecutionStart, func(e Event, c Context) Result {
|
||||
h(e.(ToolExecutionStartEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onToolExecEnd: func(h func(ToolExecutionEndEvent, Context)) {
|
||||
reg(ToolExecutionEnd, func(e Event, c Context) Result {
|
||||
h(e.(ToolExecutionEndEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onToolResult: func(h func(ToolResultEvent, Context) *ToolResultResult) {
|
||||
reg(ToolResult, func(e Event, c Context) Result {
|
||||
r := h(e.(ToolResultEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onInput: func(h func(InputEvent, Context) *InputResult) {
|
||||
reg(Input, func(e Event, c Context) Result {
|
||||
r := h(e.(InputEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onBeforeAgentStart: func(h func(BeforeAgentStartEvent, Context) *BeforeAgentStartResult) {
|
||||
reg(BeforeAgentStart, func(e Event, c Context) Result {
|
||||
r := h(e.(BeforeAgentStartEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onAgentStart: func(h func(AgentStartEvent, Context)) {
|
||||
reg(AgentStart, func(e Event, c Context) Result {
|
||||
h(e.(AgentStartEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onAgentEnd: func(h func(AgentEndEvent, Context)) {
|
||||
reg(AgentEnd, func(e Event, c Context) Result {
|
||||
h(e.(AgentEndEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onMessageStart: func(h func(MessageStartEvent, Context)) {
|
||||
reg(MessageStart, func(e Event, c Context) Result {
|
||||
h(e.(MessageStartEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onMessageUpdate: func(h func(MessageUpdateEvent, Context)) {
|
||||
reg(MessageUpdate, func(e Event, c Context) Result {
|
||||
h(e.(MessageUpdateEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onMessageEnd: func(h func(MessageEndEvent, Context)) {
|
||||
reg(MessageEnd, func(e Event, c Context) Result {
|
||||
h(e.(MessageEndEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onSessionStart: func(h func(SessionStartEvent, Context)) {
|
||||
reg(SessionStart, func(e Event, c Context) Result {
|
||||
h(e.(SessionStartEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onSessionShutdown: func(h func(SessionShutdownEvent, Context)) {
|
||||
reg(SessionShutdown, func(e Event, c Context) Result {
|
||||
h(e.(SessionShutdownEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onModelChange: func(h func(ModelChangeEvent, Context)) {
|
||||
reg(ModelChange, func(e Event, c Context) Result {
|
||||
h(e.(ModelChangeEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onContextPrepare: func(h func(ContextPrepareEvent, Context) *ContextPrepareResult) {
|
||||
reg(ContextPrepare, func(e Event, c Context) Result {
|
||||
r := h(e.(ContextPrepareEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onBeforeFork: func(h func(BeforeForkEvent, Context) *BeforeForkResult) {
|
||||
reg(BeforeFork, func(e Event, c Context) Result {
|
||||
r := h(e.(BeforeForkEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onBeforeSessionSwitch: func(h func(BeforeSessionSwitchEvent, Context) *BeforeSessionSwitchResult) {
|
||||
reg(BeforeSessionSwitch, func(e Event, c Context) Result {
|
||||
r := h(e.(BeforeSessionSwitchEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
onBeforeCompact: func(h func(BeforeCompactEvent, Context) *BeforeCompactResult) {
|
||||
reg(BeforeCompact, func(e Event, c Context) Result {
|
||||
r := h(e.(BeforeCompactEvent), c)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return *r
|
||||
})
|
||||
},
|
||||
registerToolFn: func(tool ToolDef) {
|
||||
ext.Tools = append(ext.Tools, tool)
|
||||
},
|
||||
registerCmdFn: func(cmd CommandDef) {
|
||||
ext.Commands = append(ext.Commands, cmd)
|
||||
},
|
||||
registerToolRendererFn: func(config ToolRenderConfig) {
|
||||
ext.ToolRenderers = append(ext.ToolRenderers, config)
|
||||
},
|
||||
onCustomEvent: func(name string, handler func(string)) {
|
||||
if ext.CustomEventHandlers == nil {
|
||||
ext.CustomEventHandlers = make(map[string][]func(string))
|
||||
}
|
||||
ext.CustomEventHandlers[name] = append(ext.CustomEventHandlers[name], handler)
|
||||
},
|
||||
registerOption: func(opt OptionDef) {
|
||||
ext.Options = append(ext.Options, opt)
|
||||
},
|
||||
registerShortcutFn: func(def ShortcutDef, handler func(Context)) {
|
||||
ext.Shortcuts = append(ext.Shortcuts, ShortcutEntry{Def: def, Handler: handler})
|
||||
},
|
||||
registerMessageRendererFn: func(config MessageRendererConfig) {
|
||||
ext.MessageRenderers = append(ext.MessageRenderers, config)
|
||||
},
|
||||
}
|
||||
}
|
||||
+128
-18
@@ -2,6 +2,7 @@ package extensions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"charm.land/fantasy"
|
||||
@@ -9,19 +10,17 @@ import (
|
||||
|
||||
// WrapToolsWithExtensions wraps each tool so that ToolCall and ToolResult
|
||||
// events are emitted through the extension runner before and after execution.
|
||||
// This is the Go equivalent of Pi's wrapper.ts pattern.
|
||||
//
|
||||
|
||||
// If the runner has no relevant handlers the original tools are returned
|
||||
// unchanged (zero overhead).
|
||||
func WrapToolsWithExtensions(tools []fantasy.AgentTool, runner *Runner) []fantasy.AgentTool {
|
||||
if runner == nil {
|
||||
return tools
|
||||
}
|
||||
if !runner.HasHandlers(ToolCall) && !runner.HasHandlers(ToolResult) &&
|
||||
!runner.HasHandlers(ToolExecutionStart) && !runner.HasHandlers(ToolExecutionEnd) {
|
||||
return tools
|
||||
}
|
||||
|
||||
// Always wrap tools through the runner so that SetActiveTools
|
||||
// (disabled-tool checking) and event handlers both work. The
|
||||
// overhead for disabled-tool checking is a single map lookup
|
||||
// per tool call, which is negligible.
|
||||
wrapped := make([]fantasy.AgentTool, len(tools))
|
||||
for i, tool := range tools {
|
||||
wrapped[i] = &wrappedTool{inner: tool, runner: runner}
|
||||
@@ -31,14 +30,47 @@ func WrapToolsWithExtensions(tools []fantasy.AgentTool, runner *Runner) []fantas
|
||||
|
||||
// ExtensionToolsAsFantasy converts ToolDef values registered by extensions
|
||||
// into fantasy.AgentTool implementations so the LLM can invoke them.
|
||||
func ExtensionToolsAsFantasy(defs []ToolDef) []fantasy.AgentTool {
|
||||
// The runner is optional; if provided, ToolContext.OnProgress routes
|
||||
// progress messages through the runner's Print function.
|
||||
func ExtensionToolsAsFantasy(defs []ToolDef, runner *Runner) []fantasy.AgentTool {
|
||||
tools := make([]fantasy.AgentTool, 0, len(defs))
|
||||
for _, def := range defs {
|
||||
tools = append(tools, &extensionTool{def: def})
|
||||
tools = append(tools, &extensionTool{def: def, runner: runner})
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// coreToolKinds maps built-in tool names to their kind classification.
|
||||
var coreToolKinds = map[string]string{
|
||||
"bash": "execute",
|
||||
"edit": "edit",
|
||||
"write": "edit",
|
||||
"read": "read",
|
||||
"ls": "read",
|
||||
"grep": "search",
|
||||
"find": "search",
|
||||
"spawn_subagent": "agent",
|
||||
}
|
||||
|
||||
// toolKindFor returns the ToolKind for a given tool name, defaulting to
|
||||
// "execute" for unknown tools (including MCP tools).
|
||||
func toolKindFor(toolName string) string {
|
||||
if kind, ok := coreToolKinds[toolName]; ok {
|
||||
return kind
|
||||
}
|
||||
return "execute"
|
||||
}
|
||||
|
||||
// parseToolArgsJSON attempts to parse JSON-encoded tool args into a map.
|
||||
// Returns nil on failure (non-fatal convenience parsing).
|
||||
func parseToolArgsJSON(input string) map[string]any {
|
||||
var parsed map[string]any
|
||||
if json.Unmarshal([]byte(input), &parsed) == nil {
|
||||
return parsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wrappedTool — intercepts tool calls through the extension runner
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -55,12 +87,24 @@ func (w *wrappedTool) SetProviderOptions(o fantasy.ProviderOptions) { w.inner.Se
|
||||
func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
toolName := w.inner.Info().Name
|
||||
|
||||
// 0. Check if tool is disabled via SetActiveTools.
|
||||
if w.runner.IsToolDisabled(toolName) {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
fmt.Sprintf("Error: tool %q is currently disabled", toolName)),
|
||||
fmt.Errorf("tool %q disabled by extension", toolName)
|
||||
}
|
||||
|
||||
kind := toolKindFor(toolName)
|
||||
|
||||
// 1. Emit ToolCall — extensions can block execution.
|
||||
if w.runner.HasHandlers(ToolCall) {
|
||||
result, _ := w.runner.Emit(ToolCallEvent{
|
||||
ToolName: toolName,
|
||||
ToolCallID: call.ID,
|
||||
ToolKind: kind,
|
||||
Input: call.Input,
|
||||
ParsedArgs: parseToolArgsJSON(call.Input),
|
||||
Source: "llm",
|
||||
})
|
||||
if r, ok := result.(ToolCallResult); ok && r.Block {
|
||||
reason := r.Reason
|
||||
@@ -74,7 +118,7 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
|
||||
|
||||
// 2. Emit ToolExecutionStart.
|
||||
if w.runner.HasHandlers(ToolExecutionStart) {
|
||||
_, _ = w.runner.Emit(ToolExecutionStartEvent{ToolName: toolName})
|
||||
_, _ = w.runner.Emit(ToolExecutionStartEvent{ToolCallID: call.ID, ToolName: toolName, ToolKind: kind})
|
||||
}
|
||||
|
||||
// 3. Execute the actual tool.
|
||||
@@ -82,16 +126,19 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
|
||||
|
||||
// 4. Emit ToolExecutionEnd.
|
||||
if w.runner.HasHandlers(ToolExecutionEnd) {
|
||||
_, _ = w.runner.Emit(ToolExecutionEndEvent{ToolName: toolName})
|
||||
_, _ = w.runner.Emit(ToolExecutionEndEvent{ToolCallID: call.ID, ToolName: toolName, ToolKind: kind})
|
||||
}
|
||||
|
||||
// 5. Emit ToolResult — extensions can modify output.
|
||||
if w.runner.HasHandlers(ToolResult) {
|
||||
result, _ := w.runner.Emit(ToolResultEvent{
|
||||
ToolName: toolName,
|
||||
Input: call.Input,
|
||||
Content: resp.Content,
|
||||
IsError: err != nil || resp.IsError,
|
||||
ToolCallID: call.ID,
|
||||
ToolName: toolName,
|
||||
ToolKind: kind,
|
||||
Input: call.Input,
|
||||
Content: resp.Content,
|
||||
IsError: err != nil || resp.IsError,
|
||||
Metadata: resp.Metadata,
|
||||
})
|
||||
if r, ok := result.(ToolResultResult); ok {
|
||||
if r.Content != nil {
|
||||
@@ -112,21 +159,84 @@ func (w *wrappedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.T
|
||||
|
||||
type extensionTool struct {
|
||||
def ToolDef
|
||||
runner *Runner // optional; enables ToolContext.OnProgress
|
||||
providerOptions fantasy.ProviderOptions
|
||||
}
|
||||
|
||||
func (t *extensionTool) Info() fantasy.ToolInfo {
|
||||
return fantasy.ToolInfo{
|
||||
info := fantasy.ToolInfo{
|
||||
Name: t.def.Name,
|
||||
Description: t.def.Description,
|
||||
}
|
||||
|
||||
// Parse the extension's JSON Schema and extract the properties map.
|
||||
// Fantasy expects Parameters to contain property definitions directly
|
||||
// (e.g. {"command": {"type":"string"}}) and wraps them into a full
|
||||
// JSON Schema object internally. If the extension provides a full
|
||||
// schema with "type":"object" and "properties", we extract just the
|
||||
// properties. Required fields are also extracted if present.
|
||||
if t.def.Parameters != "" {
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal([]byte(t.def.Parameters), &schema); err == nil {
|
||||
if props, ok := schema["properties"].(map[string]any); ok {
|
||||
info.Parameters = props
|
||||
} else {
|
||||
// Schema doesn't have "properties" — use as-is (may be
|
||||
// a flat property map already matching fantasy's format).
|
||||
info.Parameters = schema
|
||||
}
|
||||
// Extract required fields if present.
|
||||
if req, ok := schema["required"].([]any); ok {
|
||||
for _, r := range req {
|
||||
if s, ok := r.(string); ok {
|
||||
info.Required = append(info.Required, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Parameters and Required are never nil — the OpenAI Responses API
|
||||
// rejects tools where these fields serialize to JSON null instead of
|
||||
// empty object/array.
|
||||
if info.Parameters == nil {
|
||||
info.Parameters = map[string]any{}
|
||||
}
|
||||
if info.Required == nil {
|
||||
info.Required = []string{}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func (t *extensionTool) ProviderOptions() fantasy.ProviderOptions { return t.providerOptions }
|
||||
func (t *extensionTool) SetProviderOptions(o fantasy.ProviderOptions) { t.providerOptions = o }
|
||||
|
||||
func (t *extensionTool) Run(_ context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
result, err := t.def.Execute(call.Input)
|
||||
func (t *extensionTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
var result string
|
||||
var err error
|
||||
|
||||
if t.def.ExecuteWithContext != nil {
|
||||
tc := ToolContext{
|
||||
IsCancelled: func() bool {
|
||||
return ctx.Err() != nil
|
||||
},
|
||||
OnProgress: func(text string) {
|
||||
if t.runner != nil {
|
||||
t.runner.mu.RLock()
|
||||
printFn := t.runner.ctx.Print
|
||||
t.runner.mu.RUnlock()
|
||||
if printFn != nil {
|
||||
printFn(text)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
result, err = t.def.ExecuteWithContext(call.Input, tc)
|
||||
} else {
|
||||
result, err = t.def.Execute(call.Input)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), err
|
||||
}
|
||||
|
||||
@@ -48,8 +48,13 @@ func TestWrapToolsWithExtensions_NoRelevantHandlers(t *testing.T) {
|
||||
}))
|
||||
tools := []fantasy.AgentTool{newMockTool("test")}
|
||||
result := WrapToolsWithExtensions(tools, r)
|
||||
if result[0] != tools[0] {
|
||||
t.Error("expected original tool when no tool handlers exist")
|
||||
// Tools are always wrapped now (for SetActiveTools support),
|
||||
// but Info() should pass through correctly.
|
||||
if result[0] == tools[0] {
|
||||
t.Error("expected wrapped tool (always wraps for SetActiveTools)")
|
||||
}
|
||||
if result[0].Info().Name != "test" {
|
||||
t.Errorf("expected name 'test', got %q", result[0].Info().Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +107,22 @@ func TestWrappedTool_NormalExecution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrappedTool_SourceField(t *testing.T) {
|
||||
var gotSource string
|
||||
r := makeRunner(makeHandlerExt("source.go", map[EventType][]HandlerFunc{
|
||||
ToolCall: {func(e Event, c Context) Result {
|
||||
gotSource = e.(ToolCallEvent).Source
|
||||
return nil
|
||||
}},
|
||||
}))
|
||||
|
||||
tools := WrapToolsWithExtensions([]fantasy.AgentTool{newMockTool("bash")}, r)
|
||||
_, _ = tools[0].Run(context.Background(), fantasy.ToolCall{ID: "1", Input: "{}"})
|
||||
if gotSource != "llm" {
|
||||
t.Errorf("expected Source='llm', got %q", gotSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrappedTool_BlockExecution(t *testing.T) {
|
||||
var toolRan bool
|
||||
r := makeRunner(makeHandlerExt("blocker.go", map[EventType][]HandlerFunc{
|
||||
@@ -181,7 +202,7 @@ func TestExtensionToolsAsFantasy(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tools := ExtensionToolsAsFantasy(defs)
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("expected 1 tool, got %d", len(tools))
|
||||
}
|
||||
@@ -211,7 +232,7 @@ func TestExtensionTool_Error(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tools := ExtensionToolsAsFantasy(defs)
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "x"})
|
||||
if err == nil {
|
||||
t.Error("expected error")
|
||||
@@ -221,9 +242,104 @@ func TestExtensionTool_Error(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_ExecuteWithContext(t *testing.T) {
|
||||
var gotCancelled bool
|
||||
var gotProgress []string
|
||||
|
||||
defs := []ToolDef{
|
||||
{
|
||||
Name: "rich",
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
gotCancelled = tc.IsCancelled()
|
||||
tc.OnProgress("step 1")
|
||||
tc.OnProgress("step 2")
|
||||
return "done: " + input, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Without runner, OnProgress is a no-op.
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "done: test" {
|
||||
t.Errorf("expected 'done: test', got %q", resp.Content)
|
||||
}
|
||||
if gotCancelled {
|
||||
t.Error("expected IsCancelled=false for non-cancelled context")
|
||||
}
|
||||
|
||||
// With runner, OnProgress routes through Print.
|
||||
runner := NewRunner(nil)
|
||||
runner.SetContext(Context{
|
||||
Print: func(text string) { gotProgress = append(gotProgress, text) },
|
||||
})
|
||||
defs2 := []ToolDef{
|
||||
{
|
||||
Name: "rich2",
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
tc.OnProgress("hello")
|
||||
return "ok", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
tools2 := ExtensionToolsAsFantasy(defs2, runner)
|
||||
_, err = tools2[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(gotProgress) != 1 || gotProgress[0] != "hello" {
|
||||
t.Errorf("expected [hello], got %v", gotProgress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_ExecuteWithContextPriority(t *testing.T) {
|
||||
// When both Execute and ExecuteWithContext are set, ExecuteWithContext wins.
|
||||
defs := []ToolDef{
|
||||
{
|
||||
Name: "both",
|
||||
Execute: func(input string) (string, error) { return "simple", nil },
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
return "rich", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
resp, err := tools[0].Run(context.Background(), fantasy.ToolCall{Input: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "rich" {
|
||||
t.Errorf("expected 'rich' (ExecuteWithContext), got %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_CancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
var sawCancelled bool
|
||||
defs := []ToolDef{
|
||||
{
|
||||
Name: "checkcancel",
|
||||
ExecuteWithContext: func(input string, tc ToolContext) (string, error) {
|
||||
sawCancelled = tc.IsCancelled()
|
||||
return "ok", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
_, _ = tools[0].Run(ctx, fantasy.ToolCall{Input: ""})
|
||||
if !sawCancelled {
|
||||
t.Error("expected IsCancelled=true for cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTool_ProviderOptions(t *testing.T) {
|
||||
defs := []ToolDef{{Name: "test", Execute: func(string) (string, error) { return "", nil }}}
|
||||
tools := ExtensionToolsAsFantasy(defs)
|
||||
tools := ExtensionToolsAsFantasy(defs, nil)
|
||||
|
||||
// Initially nil.
|
||||
opts := tools[0].ProviderOptions()
|
||||
|
||||
@@ -79,6 +79,7 @@ func BuildProviderConfig() (*models.ProviderConfig, string, error) {
|
||||
NumGPU: &numGPU,
|
||||
MainGPU: &mainGPU,
|
||||
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
|
||||
ThinkingLevel: models.ParseThinkingLevel(viper.GetString("thinking-level")),
|
||||
}
|
||||
|
||||
return cfg, systemPrompt, nil
|
||||
@@ -186,7 +187,7 @@ func loadExtensions() (*extensions.Runner, extensionCreationOpts, error) {
|
||||
return extensions.WrapToolsWithExtensions(tools, runner)
|
||||
}
|
||||
|
||||
extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools())
|
||||
extTools := extensions.ExtensionToolsAsFantasy(runner.RegisteredTools(), runner)
|
||||
|
||||
return runner, extensionCreationOpts{
|
||||
toolWrapper: wrapper,
|
||||
|
||||
@@ -58,6 +58,16 @@ type ToolResult struct {
|
||||
|
||||
func (ToolResult) isPart() {}
|
||||
|
||||
// ImageContent holds image data within a message. The data is stored as raw
|
||||
// bytes (not base64-encoded); serialization handles encoding. MediaType is a
|
||||
// MIME type such as "image/png" or "image/jpeg".
|
||||
type ImageContent struct {
|
||||
Data []byte `json:"data"`
|
||||
MediaType string `json:"media_type"`
|
||||
}
|
||||
|
||||
func (ImageContent) isPart() {}
|
||||
|
||||
// Finish marks the end of an assistant turn, carrying the stop reason.
|
||||
type Finish struct {
|
||||
Reason string `json:"reason"` // "end_turn", "tool_use", "max_tokens", etc.
|
||||
@@ -129,6 +139,17 @@ func (m *Message) ToolResults() []ToolResult {
|
||||
return results
|
||||
}
|
||||
|
||||
// Images returns all ImageContent parts from this message.
|
||||
func (m *Message) Images() []ImageContent {
|
||||
var images []ImageContent
|
||||
for _, part := range m.Parts {
|
||||
if ic, ok := part.(ImageContent); ok {
|
||||
images = append(images, ic)
|
||||
}
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
// Reasoning returns the ReasoningContent if present, or a zero value.
|
||||
func (m *Message) Reasoning() ReasoningContent {
|
||||
for _, part := range m.Parts {
|
||||
@@ -170,6 +191,7 @@ const (
|
||||
toolCallType partType = "tool_call"
|
||||
toolResultType partType = "tool_result"
|
||||
finishType partType = "finish"
|
||||
imageType partType = "image"
|
||||
)
|
||||
|
||||
type partWrapper struct {
|
||||
@@ -194,6 +216,8 @@ func MarshalParts(parts []ContentPart) ([]byte, error) {
|
||||
pt = toolResultType
|
||||
case Finish:
|
||||
pt = finishType
|
||||
case ImageContent:
|
||||
pt = imageType
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown content part type: %T", part)
|
||||
}
|
||||
@@ -247,6 +271,12 @@ func UnmarshalParts(data []byte) ([]ContentPart, error) {
|
||||
return nil, fmt.Errorf("failed to unmarshal finish part: %w", err)
|
||||
}
|
||||
part = p
|
||||
case imageType:
|
||||
var p ImageContent
|
||||
if err := json.Unmarshal(w.Data, &p); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal image part: %w", err)
|
||||
}
|
||||
part = p
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown part type: %s", w.Type)
|
||||
}
|
||||
@@ -323,13 +353,25 @@ func (m *Message) ToFantasyMessages() []fantasy.Message {
|
||||
}}
|
||||
|
||||
case RoleUser:
|
||||
var parts []fantasy.MessagePart
|
||||
text := m.Content()
|
||||
if text == "" {
|
||||
if text != "" {
|
||||
parts = append(parts, fantasy.TextPart{Text: text})
|
||||
}
|
||||
for _, part := range m.Parts {
|
||||
if ic, ok := part.(ImageContent); ok {
|
||||
parts = append(parts, fantasy.FilePart{
|
||||
Data: ic.Data,
|
||||
MediaType: ic.MediaType,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []fantasy.Message{{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: text}},
|
||||
Content: parts,
|
||||
}}
|
||||
|
||||
case RoleSystem:
|
||||
@@ -388,6 +430,13 @@ func FromFantasyMessage(msg fantasy.Message) Message {
|
||||
Thinking: p.Text,
|
||||
})
|
||||
}
|
||||
case fantasy.FilePart:
|
||||
if len(p.Data) > 0 {
|
||||
m.Parts = append(m.Parts, ImageContent{
|
||||
Data: p.Data,
|
||||
MediaType: p.MediaType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// ProviderPool manages reusable LLM provider instances to reduce overhead
|
||||
// when spawning multiple subagents or making repeated completion calls.
|
||||
type ProviderPool struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]*pooledProvider
|
||||
ttl time.Duration
|
||||
closed bool
|
||||
closeCh chan struct{}
|
||||
}
|
||||
|
||||
type pooledProvider struct {
|
||||
model fantasy.LanguageModel
|
||||
closer func() error
|
||||
providerOpts fantasy.ProviderOptions
|
||||
created time.Time
|
||||
lastUsed time.Time
|
||||
refs int32
|
||||
}
|
||||
|
||||
// DefaultPoolTTL is the default time-to-live for idle pooled providers.
|
||||
const DefaultPoolTTL = 5 * time.Minute
|
||||
|
||||
// globalPool is the singleton provider pool instance.
|
||||
var globalPool *ProviderPool
|
||||
var poolOnce sync.Once
|
||||
|
||||
// GetGlobalPool returns the singleton provider pool instance.
|
||||
func GetGlobalPool() *ProviderPool {
|
||||
poolOnce.Do(func() {
|
||||
globalPool = NewProviderPool(DefaultPoolTTL)
|
||||
})
|
||||
return globalPool
|
||||
}
|
||||
|
||||
// NewProviderPool creates a provider pool with the given TTL for idle providers.
|
||||
func NewProviderPool(ttl time.Duration) *ProviderPool {
|
||||
p := &ProviderPool{
|
||||
providers: make(map[string]*pooledProvider),
|
||||
ttl: ttl,
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
go p.cleanupLoop()
|
||||
return p
|
||||
}
|
||||
|
||||
// Get returns a provider for the model string, creating one if needed.
|
||||
// The returned release function must be called when the provider is no longer
|
||||
// needed. The provider may be reused by subsequent Get calls.
|
||||
func (p *ProviderPool) Get(ctx context.Context, modelString string) (fantasy.LanguageModel, fantasy.ProviderOptions, func(), error) {
|
||||
p.mu.Lock()
|
||||
|
||||
// Check if we have an existing provider.
|
||||
if pp, ok := p.providers[modelString]; ok {
|
||||
pp.refs++
|
||||
pp.lastUsed = time.Now()
|
||||
p.mu.Unlock()
|
||||
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
|
||||
}
|
||||
|
||||
p.mu.Unlock()
|
||||
|
||||
// Create a new provider outside the lock.
|
||||
config := &ProviderConfig{ModelString: modelString}
|
||||
result, err := CreateProvider(ctx, config)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Double-check: another goroutine may have created one while we were unlocked.
|
||||
if pp, ok := p.providers[modelString]; ok {
|
||||
// Close the one we just created and use the existing one.
|
||||
if result.Closer != nil {
|
||||
_ = result.Closer.Close()
|
||||
}
|
||||
pp.refs++
|
||||
pp.lastUsed = time.Now()
|
||||
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
|
||||
}
|
||||
|
||||
var closerFn func() error
|
||||
if result.Closer != nil {
|
||||
closerFn = result.Closer.Close
|
||||
}
|
||||
|
||||
pp := &pooledProvider{
|
||||
model: result.Model,
|
||||
closer: closerFn,
|
||||
providerOpts: result.ProviderOptions,
|
||||
created: time.Now(),
|
||||
lastUsed: time.Now(),
|
||||
refs: 1,
|
||||
}
|
||||
p.providers[modelString] = pp
|
||||
|
||||
return pp.model, pp.providerOpts, func() { p.release(modelString) }, nil
|
||||
}
|
||||
|
||||
func (p *ProviderPool) release(modelString string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if pp, ok := p.providers[modelString]; ok {
|
||||
pp.refs--
|
||||
pp.lastUsed = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProviderPool) cleanupLoop() {
|
||||
ticker := time.NewTicker(p.ttl / 2)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.closeCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProviderPool) cleanup() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, pp := range p.providers {
|
||||
// Only clean up providers with no active references and past TTL.
|
||||
if pp.refs <= 0 && now.Sub(pp.lastUsed) > p.ttl {
|
||||
if pp.closer != nil {
|
||||
_ = pp.closer()
|
||||
}
|
||||
delete(p.providers, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the pool and releases all providers.
|
||||
func (p *ProviderPool) Close() {
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
p.closed = true
|
||||
close(p.closeCh)
|
||||
|
||||
for key, pp := range p.providers {
|
||||
if pp.closer != nil {
|
||||
_ = pp.closer()
|
||||
}
|
||||
delete(p.providers, key)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
+225
-26
@@ -37,19 +37,42 @@ func resolveModelAlias(provider, modelName string) string {
|
||||
registry := GetGlobalRegistry()
|
||||
|
||||
aliasMap := map[string]string{
|
||||
"claude-opus-latest": "claude-opus-4-20250514",
|
||||
"claude-sonnet-latest": "claude-sonnet-4-5-20250929",
|
||||
"claude-4-opus-latest": "claude-opus-4-20250514",
|
||||
"claude-4-sonnet-latest": "claude-sonnet-4-5-20250929",
|
||||
|
||||
// Anthropic aliases
|
||||
"claude-opus-latest": "claude-opus-4-6",
|
||||
"claude-sonnet-latest": "claude-sonnet-4-6",
|
||||
"claude-haiku-latest": "claude-haiku-4-5",
|
||||
"claude-4-opus-latest": "claude-opus-4-6",
|
||||
"claude-4-sonnet-latest": "claude-sonnet-4-6",
|
||||
"claude-4-haiku-latest": "claude-haiku-4-5",
|
||||
"claude-3-5-haiku-latest": "claude-3-5-haiku-20241022",
|
||||
"claude-3-5-sonnet-latest": "claude-3-5-sonnet-20241022",
|
||||
"claude-3-7-sonnet-latest": "claude-3-7-sonnet-20250219",
|
||||
"claude-3-opus-latest": "claude-3-opus-20240229",
|
||||
|
||||
// OpenAI aliases
|
||||
"gpt-5-latest": "gpt-5.4",
|
||||
"gpt-5-chat-latest": "gpt-5.4",
|
||||
"gpt-4-latest": "gpt-4o",
|
||||
"gpt-4": "gpt-4o",
|
||||
"gpt-3.5": "gpt-3.5-turbo",
|
||||
"gpt-3.5-latest": "gpt-3.5-turbo",
|
||||
"o1-latest": "o1",
|
||||
"o3-latest": "o3",
|
||||
"o4-latest": "o4-mini",
|
||||
"codex-latest": "codex-mini-latest",
|
||||
|
||||
// Google Gemini aliases
|
||||
"gemini-pro-latest": "gemini-2.5-pro",
|
||||
"gemini-flash": "gemini-2.5-flash",
|
||||
"gemini-pro": "gemini-2.5-pro",
|
||||
"gemini-2-flash": "gemini-2.0-flash",
|
||||
"gemini-2-pro": "gemini-2.5-pro",
|
||||
"gemini-1.5-flash": "gemini-1.5-flash",
|
||||
"gemini-1.5-pro": "gemini-1.5-pro",
|
||||
}
|
||||
|
||||
if resolved, exists := aliasMap[modelName]; exists {
|
||||
if _, err := registry.ValidateModel(provider, resolved); err == nil {
|
||||
if registry.LookupModel(provider, resolved) != nil {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
@@ -57,6 +80,66 @@ func resolveModelAlias(provider, modelName string) string {
|
||||
return modelName
|
||||
}
|
||||
|
||||
// ThinkingLevel controls extended thinking / reasoning budget for supported models.
|
||||
type ThinkingLevel string
|
||||
|
||||
const (
|
||||
ThinkingOff ThinkingLevel = "off"
|
||||
ThinkingMinimal ThinkingLevel = "minimal"
|
||||
ThinkingLow ThinkingLevel = "low"
|
||||
ThinkingMedium ThinkingLevel = "medium"
|
||||
ThinkingHigh ThinkingLevel = "high"
|
||||
)
|
||||
|
||||
// ThinkingLevels returns the ordered list of available thinking levels for cycling.
|
||||
func ThinkingLevels() []ThinkingLevel {
|
||||
return []ThinkingLevel{ThinkingOff, ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh}
|
||||
}
|
||||
|
||||
// thinkingBudgetTokens returns the token budget for a thinking level, or 0 for "off".
|
||||
func thinkingBudgetTokens(level ThinkingLevel) int64 {
|
||||
switch level {
|
||||
case ThinkingMinimal:
|
||||
return 1024
|
||||
case ThinkingLow:
|
||||
return 4096
|
||||
case ThinkingMedium:
|
||||
return 10240
|
||||
case ThinkingHigh:
|
||||
return 20480
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ThinkingLevelDescription returns a human-readable description of a thinking level.
|
||||
func ThinkingLevelDescription(level ThinkingLevel) string {
|
||||
switch level {
|
||||
case ThinkingOff:
|
||||
return "No reasoning"
|
||||
case ThinkingMinimal:
|
||||
return "Very brief reasoning (~1k tokens)"
|
||||
case ThinkingLow:
|
||||
return "Light reasoning (~4k tokens)"
|
||||
case ThinkingMedium:
|
||||
return "Moderate reasoning (~10k tokens)"
|
||||
case ThinkingHigh:
|
||||
return "Deep reasoning (~20k tokens)"
|
||||
default:
|
||||
return "No reasoning"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseThinkingLevel converts a string to a ThinkingLevel, defaulting to ThinkingOff.
|
||||
func ParseThinkingLevel(s string) ThinkingLevel {
|
||||
switch ThinkingLevel(s) {
|
||||
case ThinkingMinimal, ThinkingLow, ThinkingMedium, ThinkingHigh:
|
||||
return ThinkingLevel(s)
|
||||
default:
|
||||
return ThinkingOff
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderConfig holds configuration for creating LLM providers.
|
||||
type ProviderConfig struct {
|
||||
ModelString string
|
||||
@@ -71,6 +154,7 @@ type ProviderConfig struct {
|
||||
NumGPU *int32
|
||||
MainGPU *int32
|
||||
TLSSkipVerify bool
|
||||
ThinkingLevel ThinkingLevel
|
||||
}
|
||||
|
||||
// ProviderResult contains the result of provider creation.
|
||||
@@ -82,6 +166,9 @@ type ProviderResult struct {
|
||||
// Closer is an optional cleanup function for providers that hold
|
||||
// resources (e.g. kronk's loaded models). May be nil.
|
||||
Closer io.Closer
|
||||
// ProviderOptions contains provider-specific options to be passed to the
|
||||
// fantasy agent (e.g. OpenAI Responses API reasoning options).
|
||||
ProviderOptions fantasy.ProviderOptions
|
||||
}
|
||||
|
||||
// ParseModelString parses a model string in "provider/model" format (e.g. "anthropic/claude-sonnet-4-5").
|
||||
@@ -98,16 +185,6 @@ func ParseModelString(modelString string) (provider, model string, err error) {
|
||||
return "", "", fmt.Errorf("invalid model format %q: expected provider/model (e.g. anthropic/claude-sonnet-4-5)", modelString)
|
||||
}
|
||||
|
||||
// Legacy colon-separated format
|
||||
if strings.Contains(modelString, ":") {
|
||||
parts := strings.SplitN(modelString, ":", 2)
|
||||
if len(parts) == 2 && parts[0] != "" && parts[1] != "" {
|
||||
fmt.Fprintf(os.Stderr, "Warning: model format %q uses deprecated colon separator. Use %s/%s instead.\n",
|
||||
modelString, parts[0], parts[1])
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("invalid model format %q: expected provider/model (e.g. anthropic/claude-sonnet-4-5)", modelString)
|
||||
}
|
||||
|
||||
@@ -126,8 +203,8 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolve model aliases (for OAuth compatibility)
|
||||
if provider == "anthropic" || provider == "google-vertex-anthropic" {
|
||||
// Resolve model aliases to full model names
|
||||
if provider == "anthropic" || provider == "google-vertex-anthropic" || provider == "openai" || provider == "google" {
|
||||
modelName = resolveModelAlias(provider, modelName)
|
||||
}
|
||||
|
||||
@@ -146,10 +223,11 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
|
||||
}
|
||||
}
|
||||
|
||||
// Validate environment variables
|
||||
if err := registry.ValidateEnvironment(provider, config.ProviderAPIKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// NOTE: We intentionally skip registry.ValidateEnvironment() here.
|
||||
// Each create*Provider function handles its own auth resolution and
|
||||
// produces provider-specific error messages. The early env-var check
|
||||
// was too narrow — it didn't account for stored credentials (e.g.
|
||||
// OAuth tokens from 'kit auth login') and blocked valid auth paths.
|
||||
|
||||
// Validate config against known model limits when metadata is available
|
||||
if modelInfo != nil {
|
||||
@@ -256,6 +334,8 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
|
||||
// createAutoRoutedAnthropicProvider creates an anthropic provider for
|
||||
// third-party providers with anthropic-compatible APIs (e.g. minimax).
|
||||
func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
|
||||
@@ -297,6 +377,7 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
|
||||
|
||||
var opts []openai.Option
|
||||
opts = append(opts, openai.WithAPIKey(apiKey))
|
||||
opts = append(opts, openai.WithUseResponsesAPI())
|
||||
|
||||
if config.ProviderURL != "" {
|
||||
opts = append(opts, openai.WithBaseURL(config.ProviderURL))
|
||||
@@ -316,7 +397,9 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
|
||||
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
providerOpts := buildOpenAIProviderOptions(config, modelName)
|
||||
|
||||
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
|
||||
}
|
||||
|
||||
// resolveAPIKey returns the first non-empty API key from the explicit key
|
||||
@@ -347,7 +430,102 @@ func validateModelConfig(config *ProviderConfig, modelInfo *ModelInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// clearConflictingAnthropicSamplingParams ensures that temperature and top_p are
|
||||
// not both sent to the Anthropic API, which rejects requests containing both.
|
||||
// When both are set (typically from defaults), top_p is cleared so that
|
||||
// temperature takes precedence.
|
||||
func clearConflictingAnthropicSamplingParams(config *ProviderConfig) {
|
||||
if config.Temperature != nil && config.TopP != nil {
|
||||
config.TopP = nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildOpenAIProviderOptions returns fantasy.ProviderOptions configured for
|
||||
// OpenAI Responses API models. For reasoning models it sets reasoning_summary
|
||||
// to "auto", includes encrypted reasoning content, and maps the ThinkingLevel
|
||||
// to an OpenAI ReasoningEffort. For non-responses or non-reasoning models the
|
||||
// returned map is nil (no extra options needed).
|
||||
func buildOpenAIProviderOptions(config *ProviderConfig, modelName string) fantasy.ProviderOptions {
|
||||
if !openai.IsResponsesModel(modelName) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if openai.IsResponsesReasoningModel(modelName) {
|
||||
reasoningSummary := "auto"
|
||||
opts := &openai.ResponsesProviderOptions{
|
||||
ReasoningSummary: &reasoningSummary,
|
||||
Include: []openai.IncludeType{
|
||||
openai.IncludeReasoningEncryptedContent,
|
||||
},
|
||||
}
|
||||
|
||||
// Map ThinkingLevel to OpenAI ReasoningEffort.
|
||||
if effort := thinkingLevelToReasoningEffort(config.ThinkingLevel); effort != nil {
|
||||
opts.ReasoningEffort = effort
|
||||
}
|
||||
|
||||
return fantasy.ProviderOptions{
|
||||
openai.Name: opts,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// thinkingLevelToReasoningEffort maps a ThinkingLevel to an OpenAI ReasoningEffort.
|
||||
// Returns nil for ThinkingOff (use the model's default).
|
||||
func thinkingLevelToReasoningEffort(level ThinkingLevel) *openai.ReasoningEffort {
|
||||
switch level {
|
||||
case ThinkingMinimal:
|
||||
return openai.ReasoningEffortOption(openai.ReasoningEffortMinimal)
|
||||
case ThinkingLow:
|
||||
return openai.ReasoningEffortOption(openai.ReasoningEffortLow)
|
||||
case ThinkingMedium:
|
||||
return openai.ReasoningEffortOption(openai.ReasoningEffortMedium)
|
||||
case ThinkingHigh:
|
||||
return openai.ReasoningEffortOption(openai.ReasoningEffortHigh)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildAnthropicProviderOptions returns fantasy.ProviderOptions configured for
|
||||
// Anthropic models with extended thinking. When thinking is enabled, it sets
|
||||
// SendReasoning to true and configures the thinking budget. For thinking-off
|
||||
// or non-reasoning models the returned map is nil.
|
||||
//
|
||||
// Anthropic requires max_tokens > thinking.budget_tokens. If the configured
|
||||
// MaxTokens is too low, it is bumped to budget + 4096 to leave room for the
|
||||
// actual response.
|
||||
func buildAnthropicProviderOptions(config *ProviderConfig, modelName string) fantasy.ProviderOptions {
|
||||
if config.ThinkingLevel == "" || config.ThinkingLevel == ThinkingOff {
|
||||
return nil
|
||||
}
|
||||
|
||||
budget := thinkingBudgetTokens(config.ThinkingLevel)
|
||||
if budget == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure MaxTokens exceeds the thinking budget (Anthropic requirement).
|
||||
minRequired := int(budget) + 4096
|
||||
if config.MaxTokens < minRequired {
|
||||
config.MaxTokens = minRequired
|
||||
}
|
||||
|
||||
sendReasoning := true
|
||||
opts := &anthropic.ProviderOptions{
|
||||
SendReasoning: &sendReasoning,
|
||||
Thinking: &anthropic.ThinkingProviderOption{
|
||||
BudgetTokens: budget,
|
||||
},
|
||||
}
|
||||
return anthropic.NewProviderOptions(opts)
|
||||
}
|
||||
|
||||
func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
apiKey, source, err := auth.GetAnthropicAPIKey(config.ProviderAPIKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -383,10 +561,15 @@ func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelN
|
||||
return nil, fmt.Errorf("failed to create Anthropic model: %w", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
// Build provider options for extended thinking (reasoning budget).
|
||||
providerOpts := buildAnthropicProviderOptions(config, modelName)
|
||||
|
||||
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
|
||||
}
|
||||
|
||||
func createVertexAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
clearConflictingAnthropicSamplingParams(config)
|
||||
|
||||
projectID := firstNonEmpty(
|
||||
os.Getenv("GOOGLE_VERTEX_PROJECT"),
|
||||
os.Getenv("ANTHROPIC_VERTEX_PROJECT_ID"),
|
||||
@@ -434,6 +617,7 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
|
||||
var opts []openai.Option
|
||||
opts = append(opts, openai.WithAPIKey(apiKey))
|
||||
opts = append(opts, openai.WithUseResponsesAPI())
|
||||
|
||||
if config.ProviderURL != "" {
|
||||
opts = append(opts, openai.WithBaseURL(config.ProviderURL))
|
||||
@@ -453,7 +637,10 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
|
||||
return nil, fmt.Errorf("failed to create OpenAI model: %w", err)
|
||||
}
|
||||
|
||||
return &ProviderResult{Model: model}, nil
|
||||
// Build provider options for OpenAI Responses API reasoning models.
|
||||
providerOpts := buildOpenAIProviderOptions(config, modelName)
|
||||
|
||||
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
|
||||
}
|
||||
|
||||
func createGoogleProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
|
||||
@@ -869,9 +1056,21 @@ type oauthTransport struct {
|
||||
}
|
||||
|
||||
func (t *oauthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Resolve the freshest available token. The credential manager
|
||||
// automatically refreshes tokens nearing expiry (5-minute buffer).
|
||||
// This keeps long-lived sessions (e.g. ACP) working across token
|
||||
// renewals. Falls back to the originally-provided token if the
|
||||
// credential manager is unavailable.
|
||||
token := t.accessToken
|
||||
if cm, err := auth.NewCredentialManager(); err == nil {
|
||||
if fresh, err := cm.GetValidAccessToken(); err == nil && fresh != "" {
|
||||
token = fresh
|
||||
}
|
||||
}
|
||||
|
||||
newReq := req.Clone(req.Context())
|
||||
newReq.Header.Del("x-api-key")
|
||||
newReq.Header.Set("Authorization", "Bearer "+t.accessToken)
|
||||
newReq.Header.Set("Authorization", "Bearer "+token)
|
||||
newReq.Header.Set("anthropic-beta", "oauth-2025-04-20")
|
||||
newReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ func TestCreateOAuthHTTPClient(t *testing.T) {
|
||||
|
||||
if client == nil {
|
||||
t.Fatal("expected non-nil client")
|
||||
return
|
||||
}
|
||||
|
||||
// Check that the transport is an oauthTransport
|
||||
|
||||
+21
-22
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/kit/internal/auth"
|
||||
)
|
||||
|
||||
//go:embed embedded_models.json
|
||||
@@ -145,24 +147,8 @@ func (r *ModelsRegistry) LookupModel(provider, modelID string) *ModelInfo {
|
||||
return &modelInfo
|
||||
}
|
||||
|
||||
// ValidateModel validates if a model exists and returns detailed information.
|
||||
// Deprecated: Use LookupModel instead — it returns nil for unknown models
|
||||
// rather than an error, letting the provider API be the authority.
|
||||
func (r *ModelsRegistry) ValidateModel(provider, modelID string) (*ModelInfo, error) {
|
||||
if info := r.LookupModel(provider, modelID); info != nil {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
providerInfo, exists := r.providers[provider]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("unsupported provider: %s", provider)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("model %s not found for provider %s", modelID, providerInfo.ID)
|
||||
}
|
||||
|
||||
// GetRequiredEnvVars returns the required environment variables for a provider.
|
||||
func (r *ModelsRegistry) GetRequiredEnvVars(provider string) ([]string, error) {
|
||||
// getRequiredEnvVars returns the required environment variables for a provider.
|
||||
func (r *ModelsRegistry) getRequiredEnvVars(provider string) ([]string, error) {
|
||||
providerInfo, exists := r.providers[provider]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("unsupported provider: %s", provider)
|
||||
@@ -171,15 +157,28 @@ func (r *ModelsRegistry) GetRequiredEnvVars(provider string) ([]string, error) {
|
||||
return providerInfo.Env, nil
|
||||
}
|
||||
|
||||
// ValidateEnvironment checks if required environment variables are set.
|
||||
// Returns nil for providers not in the registry (unknown providers are
|
||||
// assumed to handle auth themselves or via --provider-api-key).
|
||||
// ValidateEnvironment checks if required credentials are available for a
|
||||
// provider. It checks the explicit API key, stored credentials (for
|
||||
// providers that support them, such as Anthropic OAuth), and environment
|
||||
// variables. Returns nil for providers not in the registry (unknown
|
||||
// providers are assumed to handle auth themselves or via --provider-api-key).
|
||||
func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) error {
|
||||
if apiKey != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
envVars, err := r.GetRequiredEnvVars(provider)
|
||||
// For anthropic, also check stored credentials (OAuth / API key)
|
||||
// since auth resolution goes through the credential manager, not
|
||||
// just environment variables.
|
||||
if provider == "anthropic" {
|
||||
if cm, err := auth.NewCredentialManager(); err == nil {
|
||||
if has, _ := cm.HasAnthropicCredentials(); has {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
envVars, err := r.getRequiredEnvVars(provider)
|
||||
if err != nil {
|
||||
// Unknown provider — nothing to validate
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// frontmatterSep is the YAML frontmatter delimiter.
|
||||
const frontmatterSep = "---"
|
||||
|
||||
// Frontmatter represents the YAML frontmatter in a prompt template file.
|
||||
type Frontmatter struct {
|
||||
// Description summarises what this template provides.
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
|
||||
// ParseFrontmatter parses YAML frontmatter content into a Frontmatter struct.
|
||||
func ParseFrontmatter(content string) (*Frontmatter, error) {
|
||||
var fm Frontmatter
|
||||
if err := yaml.Unmarshal([]byte(content), &fm); err != nil {
|
||||
return nil, fmt.Errorf("parsing frontmatter: %w", err)
|
||||
}
|
||||
return &fm, nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
)
|
||||
|
||||
// LoadOptions configures how templates are discovered and loaded.
|
||||
type LoadOptions struct {
|
||||
// Cwd is the current working directory for project-local discovery.
|
||||
// If empty, the current working directory is used.
|
||||
Cwd string
|
||||
// HomeDir is the user's home directory. If empty, os.UserHomeDir() is used.
|
||||
HomeDir string
|
||||
// ExtraPaths are additional explicit paths to search for templates.
|
||||
ExtraPaths []string
|
||||
// ConfigPaths are paths from configuration files to search.
|
||||
ConfigPaths []string
|
||||
// IncludeDefaults determines whether to include built-in default templates.
|
||||
IncludeDefaults bool
|
||||
}
|
||||
|
||||
// Diagnostic reports a template collision or loading issue.
|
||||
type Diagnostic struct {
|
||||
// Name is the template name that had a collision.
|
||||
Name string
|
||||
// KeptPath is the path of the template that was kept (higher precedence).
|
||||
KeptPath string
|
||||
// DroppedPath is the path of the template that was dropped.
|
||||
DroppedPath string
|
||||
// Reason explains why the collision occurred.
|
||||
Reason string
|
||||
}
|
||||
|
||||
// LoadAll discovers and loads all prompt templates from standard locations
|
||||
// and any extra paths. Templates are loaded in order of precedence (lowest
|
||||
// to highest), with later templates overriding earlier ones of the same name.
|
||||
//
|
||||
// Discovery paths searched in order:
|
||||
// 1. Default templates (if IncludeDefaults)
|
||||
// 2. ~/.kit/prompts/ (global user templates)
|
||||
// 3. .kit/prompts/ (project-local templates)
|
||||
// 4. ConfigPaths (from configuration)
|
||||
// 5. ExtraPaths (explicit paths, highest precedence)
|
||||
func LoadAll(opts LoadOptions) ([]*PromptTemplate, []Diagnostic, error) {
|
||||
if opts.Cwd == "" {
|
||||
opts.Cwd, _ = os.Getwd()
|
||||
}
|
||||
|
||||
if opts.HomeDir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("getting home directory: %w", err)
|
||||
}
|
||||
opts.HomeDir = home
|
||||
}
|
||||
|
||||
var all []*PromptTemplate
|
||||
var diagnostics []Diagnostic
|
||||
seen := make(map[string]*PromptTemplate) // name -> template
|
||||
|
||||
// Helper to add templates with deduplication tracking
|
||||
addTemplates := func(templates []*PromptTemplate, source string) {
|
||||
for _, tpl := range templates {
|
||||
if existing, ok := seen[tpl.Name]; ok {
|
||||
// Collision: report diagnostic, keep existing (lower precedence wins)
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Name: tpl.Name,
|
||||
KeptPath: existing.FilePath,
|
||||
DroppedPath: tpl.FilePath,
|
||||
Reason: fmt.Sprintf("template from %s overridden by %s", source, existing.Source),
|
||||
})
|
||||
log.Debug("template collision",
|
||||
"name", tpl.Name,
|
||||
"dropped", tpl.FilePath,
|
||||
"kept", existing.FilePath)
|
||||
} else {
|
||||
tpl.Source = source
|
||||
seen[tpl.Name] = tpl
|
||||
all = append(all, tpl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Default templates (lowest precedence)
|
||||
if opts.IncludeDefaults {
|
||||
defaults := loadDefaultTemplates()
|
||||
addTemplates(defaults, "default")
|
||||
}
|
||||
|
||||
// 2. Global user templates: ~/.kit/prompts/
|
||||
globalDir := filepath.Join(opts.HomeDir, ".kit", "prompts")
|
||||
if templates, err := LoadFromDir(globalDir); err == nil {
|
||||
addTemplates(templates, "global")
|
||||
}
|
||||
|
||||
// 3. Project-local templates: .kit/prompts/
|
||||
localDir := filepath.Join(opts.Cwd, ".kit", "prompts")
|
||||
if templates, err := LoadFromDir(localDir); err == nil {
|
||||
addTemplates(templates, "local")
|
||||
}
|
||||
|
||||
// 4. Config paths
|
||||
for _, path := range opts.ConfigPaths {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
if templates, err := LoadFromDir(path); err == nil {
|
||||
addTemplates(templates, "config")
|
||||
}
|
||||
} else if strings.HasSuffix(path, ".md") {
|
||||
if tpl, err := ParseTemplate(path); err == nil {
|
||||
addTemplates([]*PromptTemplate{tpl}, "config")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Extra paths (highest precedence)
|
||||
for _, path := range opts.ExtraPaths {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
if templates, err := LoadFromDir(path); err == nil {
|
||||
addTemplates(templates, "explicit")
|
||||
}
|
||||
} else if strings.HasSuffix(path, ".md") {
|
||||
if tpl, err := ParseTemplate(path); err == nil {
|
||||
addTemplates([]*PromptTemplate{tpl}, "explicit")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return all, diagnostics, nil
|
||||
}
|
||||
|
||||
// LoadFromDir scans a directory for .md files and loads them as templates.
|
||||
// It looks for *.md files directly in the directory.
|
||||
// Files that fail to parse are logged and skipped.
|
||||
func LoadFromDir(dir string) ([]*PromptTemplate, error) {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return nil, nil // directory doesn't exist — not an error
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading prompts directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
var templates []*PromptTemplate
|
||||
var errs []string
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
full := filepath.Join(dir, name)
|
||||
tpl, err := ParseTemplate(full)
|
||||
if err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
continue
|
||||
}
|
||||
templates = append(templates, tpl)
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return templates, fmt.Errorf("some templates failed to load: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
// Deduplicate removes duplicate templates by name, keeping the first occurrence.
|
||||
// It returns the deduplicated list and diagnostics for any collisions.
|
||||
// This is a standalone function for when you need to deduplicate an existing list.
|
||||
func Deduplicate(templates []*PromptTemplate) ([]*PromptTemplate, []Diagnostic) {
|
||||
seen := make(map[string]*PromptTemplate)
|
||||
var result []*PromptTemplate
|
||||
var diagnostics []Diagnostic
|
||||
|
||||
for _, tpl := range templates {
|
||||
if existing, ok := seen[tpl.Name]; ok {
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Name: tpl.Name,
|
||||
KeptPath: existing.FilePath,
|
||||
DroppedPath: tpl.FilePath,
|
||||
Reason: "duplicate template name (first-match-wins)",
|
||||
})
|
||||
} else {
|
||||
seen[tpl.Name] = tpl
|
||||
result = append(result, tpl)
|
||||
}
|
||||
}
|
||||
|
||||
return result, diagnostics
|
||||
}
|
||||
|
||||
// loadDefaultTemplates returns the built-in default templates.
|
||||
// These are embedded templates that ship with Kit.
|
||||
func loadDefaultTemplates() []*PromptTemplate {
|
||||
// Default templates can be added here as needed
|
||||
// For now, return an empty slice - users can define their own templates
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadAll_Integration(t *testing.T) {
|
||||
// Create a temp directory for testing
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create the .kit/prompts subdirectory structure
|
||||
promptsDir := filepath.Join(tempDir, ".kit", "prompts")
|
||||
if err := os.MkdirAll(promptsDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create prompts dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a test template file
|
||||
templateContent := `---
|
||||
description: Test template for integration
|
||||
---
|
||||
Review $1 with focus on $2`
|
||||
|
||||
testFile := filepath.Join(promptsDir, "test.md")
|
||||
if err := os.WriteFile(testFile, []byte(templateContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test loading from the temp directory
|
||||
tpls, diags, err := LoadAll(LoadOptions{
|
||||
HomeDir: tempDir,
|
||||
IncludeDefaults: false, // Skip default locations for this test
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAll failed: %v", err)
|
||||
}
|
||||
|
||||
if len(diags) > 0 {
|
||||
t.Logf("Got %d diagnostics", len(diags))
|
||||
}
|
||||
|
||||
if len(tpls) != 1 {
|
||||
t.Fatalf("Expected 1 template, got %d", len(tpls))
|
||||
}
|
||||
|
||||
tpl := tpls[0]
|
||||
if tpl.Name != "test" {
|
||||
t.Errorf("Expected name 'test', got '%s'", tpl.Name)
|
||||
}
|
||||
|
||||
if tpl.Description != "Test template for integration" {
|
||||
t.Errorf("Expected description 'Test template for integration', got '%s'", tpl.Description)
|
||||
}
|
||||
|
||||
// Test expansion
|
||||
expanded := tpl.Expand("code security")
|
||||
expected := "Review code with focus on security"
|
||||
if expanded != expected {
|
||||
t.Errorf("Expected '%s', got '%s'", expected, expanded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTemplate_WithFrontmatter(t *testing.T) {
|
||||
// Create a temp file with frontmatter
|
||||
tempDir := t.TempDir()
|
||||
templateContent := `---
|
||||
description: A test template
|
||||
---
|
||||
Create a $1 component with $2 features`
|
||||
|
||||
testFile := filepath.Join(tempDir, "component.md")
|
||||
if err := os.WriteFile(testFile, []byte(templateContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
tpl, err := ParseTemplate(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTemplate failed: %v", err)
|
||||
}
|
||||
|
||||
if tpl.Name != "component" {
|
||||
t.Errorf("Expected name 'component', got '%s'", tpl.Name)
|
||||
}
|
||||
|
||||
if tpl.Description != "A test template" {
|
||||
t.Errorf("Expected description 'A test template', got '%s'", tpl.Description)
|
||||
}
|
||||
|
||||
expectedContent := "Create a $1 component with $2 features"
|
||||
if tpl.Content != expectedContent {
|
||||
t.Errorf("Expected content '%s', got '%s'", expectedContent, tpl.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTemplate_WithoutFrontmatter(t *testing.T) {
|
||||
// Create a temp file without frontmatter
|
||||
tempDir := t.TempDir()
|
||||
templateContent := `Simple template without frontmatter
|
||||
Supports $1 and $2 placeholders`
|
||||
|
||||
testFile := filepath.Join(tempDir, "simple.md")
|
||||
if err := os.WriteFile(testFile, []byte(templateContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
tpl, err := ParseTemplate(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTemplate failed: %v", err)
|
||||
}
|
||||
|
||||
if tpl.Name != "simple" {
|
||||
t.Errorf("Expected name 'simple', got '%s'", tpl.Name)
|
||||
}
|
||||
|
||||
// Description should be empty since there's no frontmatter
|
||||
if tpl.Description != "" {
|
||||
t.Errorf("Expected empty description, got '%s'", tpl.Description)
|
||||
}
|
||||
|
||||
// Content should include everything
|
||||
if tpl.Content != templateContent {
|
||||
t.Errorf("Content mismatch\nExpected:\n%s\nGot:\n%s", templateContent, tpl.Content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PromptTemplate is a named prompt template with shell-style argument placeholders.
|
||||
// It supports Pi-style $1, $2, $@, $ARGUMENTS, ${@:N}, ${@:N:L} syntax.
|
||||
type PromptTemplate struct {
|
||||
// Name is the human-readable identifier for this template.
|
||||
Name string
|
||||
// Description summarises what this template provides.
|
||||
Description string
|
||||
// Content is the raw template text with placeholders.
|
||||
Content string
|
||||
// Source indicates where the template was loaded from (e.g., "default", "user").
|
||||
Source string
|
||||
// FilePath is the absolute filesystem path the template was loaded from.
|
||||
FilePath string
|
||||
}
|
||||
|
||||
// ParseTemplate reads a template from a file. The template name is derived
|
||||
// from the filename (without extension). If the file contains YAML frontmatter,
|
||||
// the description is extracted from it.
|
||||
func ParseTemplate(path string) (*PromptTemplate, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading template %s: %w", path, err)
|
||||
}
|
||||
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = path
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
tpl := &PromptTemplate{
|
||||
FilePath: abs,
|
||||
Content: content,
|
||||
}
|
||||
|
||||
// Parse frontmatter if present
|
||||
if strings.HasPrefix(strings.TrimSpace(content), frontmatterSep) {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
rest := trimmed[len(frontmatterSep):]
|
||||
frontmatter, body, found := strings.Cut(rest, "\n"+frontmatterSep)
|
||||
if found {
|
||||
body = strings.TrimPrefix(body, "\n")
|
||||
fm, err := ParseFrontmatter(frontmatter)
|
||||
if err == nil {
|
||||
tpl.Description = fm.Description
|
||||
}
|
||||
tpl.Content = strings.TrimSpace(body)
|
||||
}
|
||||
}
|
||||
|
||||
// Derive name from filename
|
||||
base := filepath.Base(path)
|
||||
ext := filepath.Ext(base)
|
||||
tpl.Name = strings.TrimSuffix(base, ext)
|
||||
|
||||
return tpl, nil
|
||||
}
|
||||
|
||||
// ParseCommandArgs splits a command line into arguments respecting quotes.
|
||||
// It handles single quotes, double quotes, and backslash escaping.
|
||||
func ParseCommandArgs(input string) []string {
|
||||
var args []string
|
||||
var current strings.Builder
|
||||
inSingleQuote := false
|
||||
inDoubleQuote := false
|
||||
escaped := false
|
||||
|
||||
for i, r := range input {
|
||||
if escaped {
|
||||
current.WriteRune(r)
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
|
||||
if r == '\\' && !inSingleQuote {
|
||||
// Backslash escapes next char, but not in single quotes
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
|
||||
if r == '\'' && !inDoubleQuote {
|
||||
inSingleQuote = !inSingleQuote
|
||||
continue
|
||||
}
|
||||
|
||||
if r == '"' && !inSingleQuote {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
continue
|
||||
}
|
||||
|
||||
if r == ' ' && !inSingleQuote && !inDoubleQuote {
|
||||
if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
current.WriteRune(r)
|
||||
_ = i // silence unused warning when we need position later
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// argPlaceholder matches shell-style argument placeholders:
|
||||
// - $1, $2, etc. - positional arguments
|
||||
// - $@ - all arguments
|
||||
// - $ARGUMENTS - all arguments (alias for $@)
|
||||
// - ${@:N} - arguments from N onwards
|
||||
// - ${@:N:L} - L arguments starting from N
|
||||
var argPlaceholder = regexp.MustCompile(`\$\{(\d+)\}|\$\{(\d+):(\d+)\}|\$\{ARGUMENTS\}|\$\{@(:\d+)?(:\d+)?\}|\$(\d+)|\$@|\$ARGUMENTS`)
|
||||
|
||||
// SubstituteArgs replaces argument placeholders in content with values from args.
|
||||
// Supported placeholders:
|
||||
// - $N, ${N} - the Nth argument (1-indexed)
|
||||
// - $@, $ARGUMENTS, ${ARGUMENTS} - all arguments joined with spaces
|
||||
// - ${@:N} - arguments from index N onwards (0-indexed)
|
||||
// - ${@:N:L} - L arguments starting from index N (0-indexed)
|
||||
func SubstituteArgs(content string, args []string) string {
|
||||
return argPlaceholder.ReplaceAllStringFunc(content, func(match string) string {
|
||||
// Check for ${N} or ${N:M} format
|
||||
if strings.HasPrefix(match, "${") && strings.Contains(match, "}") {
|
||||
inner := match[2 : len(match)-1] // Remove ${ and }
|
||||
|
||||
// Check for ${ARGUMENTS}
|
||||
if inner == "ARGUMENTS" {
|
||||
return strings.Join(args, " ")
|
||||
}
|
||||
|
||||
// Check for ${@...} format
|
||||
if strings.HasPrefix(inner, "@") {
|
||||
return expandAtArgs(inner, args)
|
||||
}
|
||||
|
||||
// Check for ${N:M} format (positional with length)
|
||||
if colonIdx := strings.Index(inner, ":"); colonIdx > 0 {
|
||||
startStr := inner[:colonIdx]
|
||||
rest := inner[colonIdx+1:]
|
||||
|
||||
start, err := strconv.Atoi(startStr)
|
||||
if err != nil || start < 1 {
|
||||
return match
|
||||
}
|
||||
|
||||
// Check if there's a second colon for length ${N:M:L}
|
||||
lengthStr, _, ok := strings.Cut(rest, ":")
|
||||
if ok {
|
||||
length, err := strconv.Atoi(lengthStr)
|
||||
if err != nil || length < 0 {
|
||||
return match
|
||||
}
|
||||
return joinArgsRange(args, start-1, length)
|
||||
}
|
||||
|
||||
// Single colon ${N:M} - M is length
|
||||
length, err := strconv.Atoi(rest)
|
||||
if err != nil || length < 0 {
|
||||
return match
|
||||
}
|
||||
return joinArgsRange(args, start-1, length)
|
||||
}
|
||||
|
||||
// Simple ${N} format
|
||||
n, err := strconv.Atoi(inner)
|
||||
if err != nil || n < 1 {
|
||||
return match
|
||||
}
|
||||
if n <= len(args) {
|
||||
return args[n-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check for $N format (without braces)
|
||||
if strings.HasPrefix(match, "$") && !strings.HasPrefix(match, "${") {
|
||||
suffix := match[1:]
|
||||
|
||||
// $@ or $ARGUMENTS
|
||||
if suffix == "@" || suffix == "ARGUMENTS" {
|
||||
return strings.Join(args, " ")
|
||||
}
|
||||
|
||||
// $N
|
||||
n, err := strconv.Atoi(suffix)
|
||||
if err != nil || n < 1 {
|
||||
return match
|
||||
}
|
||||
if n <= len(args) {
|
||||
return args[n-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
// expandAtArgs handles ${@...} patterns (1-indexed like bash)
|
||||
func expandAtArgs(inner string, args []string) string {
|
||||
// Remove the @ prefix
|
||||
rest := inner[1:]
|
||||
|
||||
if rest == "" {
|
||||
// ${@} - all arguments
|
||||
return strings.Join(args, " ")
|
||||
}
|
||||
|
||||
// Must start with :
|
||||
if !strings.HasPrefix(rest, ":") {
|
||||
return "${" + inner + "}"
|
||||
}
|
||||
rest = rest[1:]
|
||||
|
||||
// Parse start index
|
||||
startStr, lengthStr, hasLength := strings.Cut(rest, ":")
|
||||
|
||||
start, err := strconv.Atoi(startStr)
|
||||
if err != nil || start < 0 {
|
||||
return "${" + inner + "}"
|
||||
}
|
||||
|
||||
// Convert from 1-indexed to 0-indexed (bash convention)
|
||||
// Treat 0 as 1 (bash convention: args start at 1)
|
||||
if start > 0 {
|
||||
start--
|
||||
}
|
||||
|
||||
if hasLength {
|
||||
length, err := strconv.Atoi(lengthStr)
|
||||
if err != nil || length < 0 {
|
||||
return "${" + inner + "}"
|
||||
}
|
||||
return joinArgsRange(args, start, length)
|
||||
}
|
||||
|
||||
// ${@:N} - from N to end
|
||||
if start >= len(args) {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(args[start:], " ")
|
||||
}
|
||||
|
||||
// joinArgsRange joins args from start index, taking up to length elements
|
||||
func joinArgsRange(args []string, start, length int) string {
|
||||
if start >= len(args) || length <= 0 {
|
||||
return ""
|
||||
}
|
||||
end := start + length
|
||||
end = min(end, len(args))
|
||||
return strings.Join(args[start:end], " ")
|
||||
}
|
||||
|
||||
// Expand substitutes arguments into the template content and returns the result.
|
||||
// It first parses args from the input string, then substitutes them into the template.
|
||||
func (t *PromptTemplate) Expand(argsInput string) string {
|
||||
args := ParseCommandArgs(argsInput)
|
||||
return SubstituteArgs(t.Content, args)
|
||||
}
|
||||
|
||||
// ExpandWithArgs substitutes the provided arguments into the template content.
|
||||
func (t *PromptTemplate) ExpandWithArgs(args []string) string {
|
||||
return SubstituteArgs(t.Content, args)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCommandArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{"", []string{}},
|
||||
{"hello", []string{"hello"}},
|
||||
{"hello world", []string{"hello", "world"}},
|
||||
{`"hello world"`, []string{"hello world"}},
|
||||
{`'hello world'`, []string{"hello world"}},
|
||||
{`hello "world foo" bar`, []string{"hello", "world foo", "bar"}},
|
||||
{`hello 'world foo' bar`, []string{"hello", "world foo", "bar"}},
|
||||
{`hello \"world\"`, []string{"hello", `"world"`}},
|
||||
{`hello \\world`, []string{"hello", `\world`}},
|
||||
{` hello world `, []string{"hello", "world"}},
|
||||
{`Button "onClick handler" "disabled support"`, []string{"Button", "onClick handler", "disabled support"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := ParseCommandArgs(tt.input)
|
||||
if len(got) != len(tt.expected) {
|
||||
t.Errorf("ParseCommandArgs(%q) = %v, want %v", tt.input, got, tt.expected)
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.expected[i] {
|
||||
t.Errorf("ParseCommandArgs(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.expected[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubstituteArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
args []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no placeholders",
|
||||
content: "Hello world",
|
||||
args: []string{},
|
||||
expected: "Hello world",
|
||||
},
|
||||
{
|
||||
name: "positional $1",
|
||||
content: "Hello $1",
|
||||
args: []string{"world"},
|
||||
expected: "Hello world",
|
||||
},
|
||||
{
|
||||
name: "positional $1 $2",
|
||||
content: "$1 and $2",
|
||||
args: []string{"first", "second"},
|
||||
expected: "first and second",
|
||||
},
|
||||
{
|
||||
name: "missing arg",
|
||||
content: "Hello $1 and $2",
|
||||
args: []string{"world"},
|
||||
expected: "Hello world and ",
|
||||
},
|
||||
{
|
||||
name: "$@ wildcard",
|
||||
content: "Args: $@",
|
||||
args: []string{"a", "b", "c"},
|
||||
expected: "Args: a b c",
|
||||
},
|
||||
{
|
||||
name: "$ARGUMENTS wildcard",
|
||||
content: "Args: $ARGUMENTS",
|
||||
args: []string{"a", "b", "c"},
|
||||
expected: "Args: a b c",
|
||||
},
|
||||
{
|
||||
name: "${@} all args",
|
||||
content: "Args: ${@}",
|
||||
args: []string{"a", "b", "c"},
|
||||
expected: "Args: a b c",
|
||||
},
|
||||
{
|
||||
name: "${@:2} slice from index 2",
|
||||
content: "Rest: ${@:2}",
|
||||
args: []string{"a", "b", "c", "d"},
|
||||
expected: "Rest: b c d",
|
||||
},
|
||||
{
|
||||
name: "${@:1:2} slice with length",
|
||||
content: "First two: ${@:1:2}",
|
||||
args: []string{"a", "b", "c", "d"},
|
||||
expected: "First two: a b",
|
||||
},
|
||||
{
|
||||
name: "${@:0} from start",
|
||||
content: "All: ${@:0}",
|
||||
args: []string{"a", "b", "c"},
|
||||
expected: "All: a b c",
|
||||
},
|
||||
{
|
||||
name: "${@:3:1} single arg",
|
||||
content: "Third: ${@:3:1}",
|
||||
args: []string{"a", "b", "c", "d"},
|
||||
expected: "Third: c",
|
||||
},
|
||||
{
|
||||
name: "combined placeholders",
|
||||
content: "Create $1 with features: $ARGUMENTS",
|
||||
args: []string{"Button", "onClick", "disabled"},
|
||||
expected: "Create Button with features: Button onClick disabled",
|
||||
},
|
||||
{
|
||||
name: "slice beyond bounds",
|
||||
content: "${@:10}",
|
||||
args: []string{"a", "b"},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty args with wildcard",
|
||||
content: "Args: $@",
|
||||
args: []string{},
|
||||
expected: "Args: ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SubstituteArgs(tt.content, tt.args)
|
||||
if got != tt.expected {
|
||||
t.Errorf("SubstituteArgs(%q, %v) = %q, want %q", tt.content, tt.args, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantDesc string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple description",
|
||||
content: "description: Review code\n",
|
||||
wantDesc: "Review code",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
content: "",
|
||||
wantDesc: "",
|
||||
},
|
||||
{
|
||||
name: "invalid yaml",
|
||||
content: "description: [unclosed",
|
||||
wantDesc: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fm, err := ParseFrontmatter(tt.content)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseFrontmatter() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if fm.Description != tt.wantDesc {
|
||||
t.Errorf("ParseFrontmatter() Description = %q, want %q", fm.Description, tt.wantDesc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptTemplateExpand(t *testing.T) {
|
||||
tpl := &PromptTemplate{
|
||||
Name: "component",
|
||||
Description: "Create a component",
|
||||
Content: "Create a React component named $1 with features: $ARGUMENTS",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
input: "Button",
|
||||
expected: "Create a React component named Button with features: Button",
|
||||
},
|
||||
{
|
||||
input: `Button "onClick handler"`,
|
||||
expected: "Create a React component named Button with features: Button onClick handler",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := tpl.Expand(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("Expand(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user