mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 941f1daf0b | |||
| ab7e2bda61 | |||
| 741520927c | |||
| 4c1bda9541 | |||
| 3b69b13556 | |||
| 83a959a379 | |||
| 3491e05e9e | |||
| 0a54a8aa05 | |||
| 3cb3e5dba1 | |||
| 31966c469f | |||
| f03625d6e5 | |||
| d06641dc0a | |||
| bbf1106e27 | |||
| babed03a3d | |||
| 1cd074836f | |||
| ab3ce260c8 | |||
| 8e8cc3946d | |||
| e18e36625e | |||
| be55bc03f1 | |||
| 09919b6307 | |||
| 7a2de4cc3c | |||
| acd7fd7f45 | |||
| 3446f38516 | |||
| db4bb19bac | |||
| d1cffb85ef | |||
| 329cd4ea4a | |||
| 4e779d576f | |||
| fc054f50e8 | |||
| d8f1b32885 | |||
| 1e2a3e2589 | |||
| c7f43917b1 | |||
| 6a8833a7b1 | |||
| 82cbf1d457 | |||
| ab09d5c9e4 | |||
| 2347e0e506 | |||
| 3e1c19442b | |||
| 3fc0ad906e | |||
| f373c34f54 | |||
| 1206837af4 | |||
| 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 |
@@ -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
|
||||
+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,228 @@
|
||||
//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 {
|
||||
gopls := runGopls(cwd, absPath)
|
||||
lint := runGolangCILint(cwd, "./...")
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//go:build ignore
|
||||
|
||||
// subagent-monitor — live horizontal widget strip for spawned subagents
|
||||
//
|
||||
// Subscribes to subagents spawned by the main Kit agent and displays a
|
||||
// single widget just above the input box. Each subagent occupies one column
|
||||
// in a side-by-side horizontal layout. Columns show scrolling real-time
|
||||
// output as the subagent works. When a subagent finishes its column is
|
||||
// removed automatically.
|
||||
//
|
||||
// Yaegi-safe design notes:
|
||||
// - No sync.Mutex (Yaegi has reflection issues with sync primitives)
|
||||
// - No channels in maps (Yaegi panics on range over map[string]chan)
|
||||
// - All ctx.* calls guarded with nil checks
|
||||
// - Simple data structures only
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-subagent state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type submonEntry struct {
|
||||
id int
|
||||
callID string
|
||||
task string
|
||||
lines []string
|
||||
started time.Time
|
||||
elapsed time.Duration
|
||||
}
|
||||
|
||||
const (
|
||||
submonColWidth = 34 // visible character width per column
|
||||
submonMaxLines = 5 // scrolling output lines per column
|
||||
submonColGap = 2 // spaces between columns
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Package-level state - all simple types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
submonCtx ext.Context
|
||||
submonHasCtx bool
|
||||
submonEntries []*submonEntry
|
||||
submonNextID int
|
||||
)
|
||||
|
||||
func submonInit() {
|
||||
submonEntries = nil
|
||||
submonNextID = 1
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func submonPad(s string, w int) string {
|
||||
r := []rune(s)
|
||||
if len(r) >= w {
|
||||
return string(r[:w])
|
||||
}
|
||||
return s + strings.Repeat(" ", w-len(r))
|
||||
}
|
||||
|
||||
func submonTrunc(s string, w int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= w {
|
||||
return s
|
||||
}
|
||||
if w <= 1 {
|
||||
return "…"
|
||||
}
|
||||
return string(r[:w-1]) + "…"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Widget rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func submonRenderColumn(e *submonEntry) []string {
|
||||
var rows []string
|
||||
|
||||
// Calculate elapsed time on-demand to avoid race conditions with ticker
|
||||
elapsed := e.elapsed
|
||||
if elapsed == 0 && !e.started.IsZero() {
|
||||
elapsed = time.Since(e.started)
|
||||
}
|
||||
secs := int(elapsed.Seconds())
|
||||
timeStr := fmt.Sprintf("%ds", secs)
|
||||
taskMax := submonColWidth - len(timeStr) - 3
|
||||
taskPart := submonTrunc(e.task, taskMax)
|
||||
header := fmt.Sprintf("#%d %s %s", e.id, taskPart, timeStr)
|
||||
rows = append(rows, submonPad(header, submonColWidth))
|
||||
|
||||
display := e.lines
|
||||
if len(display) > submonMaxLines {
|
||||
display = display[len(display)-submonMaxLines:]
|
||||
}
|
||||
for _, l := range display {
|
||||
rows = append(rows, submonPad(" "+submonTrunc(l, submonColWidth-2), submonColWidth))
|
||||
}
|
||||
for len(rows) < submonMaxLines+1 {
|
||||
if len(rows) == 1 && len(e.lines) == 0 {
|
||||
rows = append(rows, submonPad(" waiting…", submonColWidth))
|
||||
} else {
|
||||
rows = append(rows, strings.Repeat(" ", submonColWidth))
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func submonBuildWidget() string {
|
||||
if len(submonEntries) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
numCols := len(submonEntries)
|
||||
numRows := submonMaxLines + 1
|
||||
cols := make([][]string, numCols)
|
||||
for i, e := range submonEntries {
|
||||
rows := submonRenderColumn(e)
|
||||
col := make([]string, numRows)
|
||||
for j := 0; j < numRows; j++ {
|
||||
if j < len(rows) {
|
||||
col[j] = rows[j]
|
||||
} else {
|
||||
col[j] = strings.Repeat(" ", submonColWidth)
|
||||
}
|
||||
}
|
||||
cols[i] = col
|
||||
}
|
||||
|
||||
gap := strings.Repeat(" ", submonColGap)
|
||||
var sb strings.Builder
|
||||
for row := 0; row < numRows; row++ {
|
||||
for ci := range cols {
|
||||
if ci > 0 {
|
||||
sb.WriteString(gap)
|
||||
}
|
||||
sb.WriteString(cols[ci][row])
|
||||
}
|
||||
if row < numRows-1 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func submonPushWidget() {
|
||||
if !submonHasCtx {
|
||||
return
|
||||
}
|
||||
if submonCtx.SetWidget == nil {
|
||||
return
|
||||
}
|
||||
|
||||
text := submonBuildWidget()
|
||||
if len(submonEntries) == 0 {
|
||||
if submonCtx.RemoveWidget != nil {
|
||||
submonCtx.RemoveWidget("submon")
|
||||
}
|
||||
return
|
||||
}
|
||||
submonCtx.SetWidget(ext.WidgetConfig{
|
||||
ID: "submon",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{Text: text},
|
||||
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
||||
Priority: 0,
|
||||
})
|
||||
}
|
||||
|
||||
func submonAppendLine(e *submonEntry, line string) {
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if strings.TrimSpace(line) == "" {
|
||||
return
|
||||
}
|
||||
e.lines = append(e.lines, line)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Init(api ext.API) {
|
||||
submonInit()
|
||||
|
||||
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
||||
submonCtx = ctx
|
||||
submonHasCtx = true
|
||||
submonInit()
|
||||
if ctx.RemoveWidget != nil {
|
||||
ctx.RemoveWidget("submon")
|
||||
}
|
||||
})
|
||||
|
||||
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
||||
submonCtx = ctx
|
||||
submonHasCtx = true
|
||||
})
|
||||
|
||||
// ── SubagentStart ────────────────────────────────────────────────────────
|
||||
api.OnSubagentStart(func(e ext.SubagentStartEvent, ctx ext.Context) {
|
||||
submonCtx = ctx
|
||||
submonHasCtx = true
|
||||
|
||||
id := submonNextID
|
||||
submonNextID++
|
||||
entry := &submonEntry{
|
||||
id: id,
|
||||
callID: e.ToolCallID,
|
||||
task: e.Task,
|
||||
started: time.Now(),
|
||||
}
|
||||
submonEntries = append(submonEntries, entry)
|
||||
|
||||
submonPushWidget()
|
||||
})
|
||||
|
||||
// ── SubagentChunk ────────────────────────────────────────────────────────
|
||||
api.OnSubagentChunk(func(e ext.SubagentChunkEvent, ctx ext.Context) {
|
||||
submonCtx = ctx
|
||||
submonHasCtx = true
|
||||
|
||||
var entry *submonEntry
|
||||
for _, en := range submonEntries {
|
||||
if en.callID == e.ToolCallID {
|
||||
entry = en
|
||||
break
|
||||
}
|
||||
}
|
||||
if entry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch e.ChunkType {
|
||||
case "text":
|
||||
for _, line := range strings.Split(e.Content, "\n") {
|
||||
submonAppendLine(entry, line)
|
||||
}
|
||||
case "tool_call":
|
||||
submonAppendLine(entry, "→ "+e.ToolName)
|
||||
case "tool_execution_start":
|
||||
submonAppendLine(entry, "⚙ "+e.ToolName)
|
||||
case "tool_result":
|
||||
if e.IsError {
|
||||
submonAppendLine(entry, "✗ "+e.ToolName)
|
||||
} else {
|
||||
submonAppendLine(entry, "✓ "+e.ToolName)
|
||||
}
|
||||
}
|
||||
|
||||
submonPushWidget()
|
||||
})
|
||||
|
||||
// ── SubagentEnd ──────────────────────────────────────────────────────────
|
||||
api.OnSubagentEnd(func(e ext.SubagentEndEvent, ctx ext.Context) {
|
||||
submonCtx = ctx
|
||||
submonHasCtx = true
|
||||
|
||||
var entry *submonEntry
|
||||
for _, en := range submonEntries {
|
||||
if en.callID == e.ToolCallID {
|
||||
entry = en
|
||||
break
|
||||
}
|
||||
}
|
||||
if entry != nil {
|
||||
entry.elapsed = time.Since(entry.started)
|
||||
if e.ErrorMsg != "" {
|
||||
submonAppendLine(entry, "✗ "+submonTrunc(e.ErrorMsg, submonColWidth-2))
|
||||
}
|
||||
}
|
||||
|
||||
submonPushWidget()
|
||||
|
||||
// Remove the entry immediately (no goroutine to avoid races)
|
||||
newEntries := submonEntries[:0]
|
||||
for _, en := range submonEntries {
|
||||
if en.callID != e.ToolCallID {
|
||||
newEntries = append(newEntries, en)
|
||||
}
|
||||
}
|
||||
submonEntries = newEntries
|
||||
submonPushWidget()
|
||||
})
|
||||
|
||||
// ── SessionShutdown ──────────────────────────────────────────────────────
|
||||
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
||||
submonInit()
|
||||
// Guard ctx access - may be nil during shutdown
|
||||
if ctx.RemoveWidget != nil {
|
||||
ctx.RemoveWidget("submon")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,769 @@
|
||||
|
||||
# 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, OnToolOutput, OnToolExecutionEnd, OnToolResult, OnInput, OnMessageStart, OnMessageUpdate, OnMessageEnd, OnModelChange, OnContextPrepare, OnBeforeFork, OnBeforeSessionSwitch, OnBeforeCompact, OnCustomEvent, OnSubagentStart, OnSubagentChunk, OnSubagentEnd
|
||||
|
||||
**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
|
||||
- `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
|
||||
|
||||
Also see `.kit/extensions/go-edit-lint.go` (in this repo) for a project-local extension example that runs gopls and golangci-lint on Go file edits.
|
||||
|
||||
### 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
|
||||
- **Custom** - Any OpenAI-compatible endpoint via `--provider-url`
|
||||
- **Auto-routed** - Any provider from models.dev database
|
||||
|
||||
### Custom Provider
|
||||
|
||||
Use `custom/custom` when pointing Kit at any OpenAI-compatible endpoint with `--provider-url`:
|
||||
|
||||
```bash
|
||||
kit --provider-url "http://localhost:8080/v1" "Hello"
|
||||
```
|
||||
|
||||
This automatically defaults to `custom/custom` without needing to specify a model. The custom provider routes through fantasy's `openaicompat` provider and supports:
|
||||
|
||||
- Zero cost tracking (input/output = 0)
|
||||
- 262K context window, 65K output limit
|
||||
- Reasoning and temperature support
|
||||
- Optional `CUSTOM_API_KEY` environment variable or `--provider-api-key` flag
|
||||
|
||||
### 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
|
||||
}
|
||||
+325
-28
@@ -1,11 +1,15 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/mark3labs/kit/internal/auth"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -14,7 +18,7 @@ import (
|
||||
// authCmd represents the auth command for managing AI provider authentication.
|
||||
// This command provides subcommands for login, logout, and status checking
|
||||
// of authentication credentials for various AI providers, with OAuth support
|
||||
// for providers like Anthropic.
|
||||
// for providers like Anthropic and OpenAI.
|
||||
var authCmd = &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Manage authentication credentials for AI providers",
|
||||
@@ -25,9 +29,11 @@ using OAuth flows. Stored credentials take precedence over environment variables
|
||||
|
||||
Available providers:
|
||||
- anthropic: Anthropic Claude API (OAuth)
|
||||
- openai: OpenAI API (OAuth and API key)
|
||||
|
||||
Examples:
|
||||
kit auth login anthropic
|
||||
kit auth login openai
|
||||
kit auth logout anthropic
|
||||
kit auth status`,
|
||||
}
|
||||
@@ -46,9 +52,11 @@ environment variables when making API calls.
|
||||
|
||||
Available providers:
|
||||
- anthropic: Anthropic Claude API (OAuth)
|
||||
- openai: OpenAI ChatGPT Plus/Pro (Codex OAuth)
|
||||
|
||||
Example:
|
||||
kit auth login anthropic`,
|
||||
kit auth login anthropic
|
||||
kit auth login openai`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAuthLogin,
|
||||
}
|
||||
@@ -61,14 +69,16 @@ var authLogoutCmd = &cobra.Command{
|
||||
Short: "Remove stored authentication credentials for a provider",
|
||||
Long: `Remove stored authentication credentials for an AI provider.
|
||||
|
||||
This will delete the stored API key for the specified provider. You will need
|
||||
to use environment variables or command-line flags for authentication after logout.
|
||||
This will delete the stored API key or OAuth credentials for the specified provider.
|
||||
You will need to use environment variables or command-line flags for authentication after logout.
|
||||
|
||||
Available providers:
|
||||
- anthropic: Anthropic Claude API
|
||||
- openai: OpenAI API
|
||||
|
||||
Example:
|
||||
kit auth logout anthropic`,
|
||||
kit auth logout anthropic
|
||||
kit auth logout openai`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAuthLogout,
|
||||
}
|
||||
@@ -101,8 +111,10 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
|
||||
switch provider {
|
||||
case "anthropic":
|
||||
return loginAnthropic()
|
||||
case "openai":
|
||||
return loginOpenAI()
|
||||
default:
|
||||
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic", provider)
|
||||
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic, openai", provider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +124,10 @@ func runAuthLogout(cmd *cobra.Command, args []string) error {
|
||||
switch provider {
|
||||
case "anthropic":
|
||||
return logoutAnthropic()
|
||||
case "openai":
|
||||
return logoutOpenAI()
|
||||
default:
|
||||
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic", provider)
|
||||
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic, openai", provider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,8 +171,44 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Check OpenAI credentials
|
||||
fmt.Print("\nOpenAI: ")
|
||||
if hasOpenAICreds, err := cm.HasOpenAICredentials(); err != nil {
|
||||
fmt.Printf("Error checking credentials: %v\n", err)
|
||||
} else if hasOpenAICreds {
|
||||
if creds, err := cm.GetOpenAICredentials(); err != nil {
|
||||
fmt.Printf("Error reading credentials: %v\n", err)
|
||||
} else {
|
||||
authType := "API Key"
|
||||
status := "✓ Authenticated"
|
||||
|
||||
if creds.Type == "oauth" {
|
||||
authType = "OAuth (ChatGPT/Codex)"
|
||||
if creds.IsExpired() {
|
||||
status = "⚠️ Token expired (will refresh automatically)"
|
||||
} else if creds.NeedsRefresh() {
|
||||
status = "⚠️ Token expires soon (will refresh automatically)"
|
||||
}
|
||||
}
|
||||
|
||||
accountInfo := ""
|
||||
if creds.Type == "oauth" && creds.AccountID != "" {
|
||||
accountInfo = fmt.Sprintf(" [%s]", creds.AccountID)
|
||||
}
|
||||
|
||||
fmt.Printf("%s (%s%s, stored %s)\n", status, authType, accountInfo, creds.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
} else {
|
||||
fmt.Println("✗ Not authenticated")
|
||||
// Check if environment variable is set
|
||||
if os.Getenv("OPENAI_API_KEY") != "" {
|
||||
fmt.Println(" (OPENAI_API_KEY environment variable is set)")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\nTo authenticate with a provider:")
|
||||
fmt.Println(" kit auth login anthropic")
|
||||
fmt.Println(" kit auth login openai")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -171,14 +221,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 +255,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 +309,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
|
||||
}
|
||||
@@ -278,3 +332,246 @@ func logoutAnthropic() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loginOpenAI() error {
|
||||
cm, err := kit.NewCredentialManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize credential manager: %w", err)
|
||||
}
|
||||
|
||||
// Check if already authenticated
|
||||
if hasAuth, err := cm.HasOpenAICredentials(); err == nil && hasAuth {
|
||||
var reauth bool
|
||||
err := huh.NewConfirm().
|
||||
Title("You are already authenticated with OpenAI (ChatGPT/Codex)").
|
||||
Description("Do you want to re-authenticate?").
|
||||
Affirmative("Yes").
|
||||
Negative("No").
|
||||
Value(&reauth).
|
||||
Run()
|
||||
if err != nil || !reauth {
|
||||
fmt.Println("Authentication cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create OAuth client
|
||||
client := auth.NewOpenAIOAuthClient()
|
||||
|
||||
// Generate authorization URL
|
||||
fmt.Println("🔐 Starting OAuth authentication with OpenAI (ChatGPT/Codex)...")
|
||||
fmt.Println("This will open your browser to authenticate with your ChatGPT account.")
|
||||
fmt.Println()
|
||||
|
||||
authData, err := client.GetAuthorizationURL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate authorization URL: %w", err)
|
||||
}
|
||||
|
||||
// Start local callback server
|
||||
callbackServer, err := startOpenAICallbackServer(authData.State)
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ Could not start local callback server: %v\n", err)
|
||||
fmt.Println("Falling back to manual code entry.")
|
||||
}
|
||||
if callbackServer != nil {
|
||||
defer callbackServer.Close()
|
||||
}
|
||||
|
||||
// Display URL and try to open browser
|
||||
fmt.Println("📱 Opening your browser for authentication...")
|
||||
fmt.Println("If the browser doesn't open automatically, please visit this URL:")
|
||||
fmt.Printf("\n%s\n\n", authData.URL)
|
||||
|
||||
// Try to open browser
|
||||
auth.TryOpenBrowser(authData.URL)
|
||||
|
||||
// Wait for callback or manual input
|
||||
var code string
|
||||
if callbackServer != nil {
|
||||
fmt.Println("Waiting for browser authentication...")
|
||||
select {
|
||||
case callbackCode := <-callbackServer.CodeChan:
|
||||
if callbackCode != "" {
|
||||
code = callbackCode
|
||||
fmt.Println("✓ Received authorization code from browser callback.")
|
||||
}
|
||||
case <-time.After(2 * time.Minute):
|
||||
fmt.Println("\n⏱️ Timeout waiting for browser callback.")
|
||||
callbackServer.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// If no code from callback, prompt for manual entry
|
||||
if code == "" {
|
||||
fmt.Println("\nAfter authorizing, paste the callback URL or authorization code below.")
|
||||
fmt.Println("(The callback URL will look like: http://localhost:1455/auth/callback?code=...&state=...)")
|
||||
fmt.Println()
|
||||
|
||||
var input string
|
||||
err = huh.NewInput().
|
||||
Title("Callback URL or Code").
|
||||
Description("Paste the full callback URL or just the authorization code").
|
||||
Value(&input).
|
||||
Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
input = strings.TrimSpace(input)
|
||||
|
||||
if input == "" {
|
||||
return fmt.Errorf("authorization code cannot be empty")
|
||||
}
|
||||
|
||||
// Parse the input (could be full URL or just code)
|
||||
parsedCode, parsedState := auth.ParseOpenAIAuthorizationInput(input)
|
||||
if parsedCode == "" {
|
||||
return fmt.Errorf("could not extract authorization code from input")
|
||||
}
|
||||
|
||||
// Validate state if provided
|
||||
if parsedState != "" && parsedState != authData.State {
|
||||
return fmt.Errorf("state mismatch - possible security issue")
|
||||
}
|
||||
code = parsedCode
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
fmt.Println("\n🔄 Exchanging authorization code for access token...")
|
||||
creds, err := client.ExchangeCode(code, authData.Verifier)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to exchange authorization code: %w", err)
|
||||
}
|
||||
|
||||
// Store the credentials
|
||||
if err := cm.SetOpenAIOAuthCredentials(creds); err != nil {
|
||||
return fmt.Errorf("failed to store credentials: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ Successfully authenticated with OpenAI (ChatGPT/Codex)!")
|
||||
fmt.Printf("📁 Credentials stored in: %s\n", cm.GetCredentialsPath())
|
||||
fmt.Printf("👤 Account ID: %s\n", creds.AccountID)
|
||||
fmt.Println("\n🎉 Your OAuth credentials will now be used for OpenAI API calls.")
|
||||
fmt.Println("💡 You can check your authentication status with: kit auth status")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// callbackServer holds the HTTP server and channel for receiving the OAuth callback
|
||||
type callbackServer struct {
|
||||
Server *http.Server
|
||||
CodeChan chan string
|
||||
State string
|
||||
}
|
||||
|
||||
// Close shuts down the callback server
|
||||
func (cs *callbackServer) Close() {
|
||||
if cs.Server != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = cs.Server.Shutdown(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// startOpenAICallbackServer starts a local HTTP server to receive the OAuth callback
|
||||
func startOpenAICallbackServer(expectedState string) (*callbackServer, error) {
|
||||
codeChan := make(chan string, 1)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
server := &http.Server{
|
||||
Addr: "127.0.0.1:1455",
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check state
|
||||
state := r.URL.Query().Get("state")
|
||||
if state != expectedState {
|
||||
http.Error(w, "State mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "Missing authorization code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Send code to channel
|
||||
select {
|
||||
case codeChan <- code:
|
||||
default:
|
||||
}
|
||||
|
||||
// Return success page
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Authentication Successful</title></head>
|
||||
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
|
||||
<h1>✓ Authentication Successful</h1>
|
||||
<p>You can close this window and return to the terminal.</p>
|
||||
</body>
|
||||
</html>`)
|
||||
})
|
||||
|
||||
// Try to start server
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:1455")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("port 1455 not available: %w", err)
|
||||
}
|
||||
_ = listener.Close()
|
||||
|
||||
go func() {
|
||||
_ = server.ListenAndServe()
|
||||
}()
|
||||
|
||||
return &callbackServer{
|
||||
Server: server,
|
||||
CodeChan: codeChan,
|
||||
State: expectedState,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func logoutOpenAI() error {
|
||||
cm, err := kit.NewCredentialManager()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize credential manager: %w", err)
|
||||
}
|
||||
|
||||
// Check if authenticated
|
||||
hasAuth, err := cm.HasOpenAICredentials()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check authentication status: %w", err)
|
||||
}
|
||||
|
||||
if !hasAuth {
|
||||
fmt.Println("You are not currently authenticated with OpenAI.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirm logout
|
||||
var confirm bool
|
||||
err = huh.NewConfirm().
|
||||
Title("Remove OpenAI 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
|
||||
}
|
||||
|
||||
// Remove credentials
|
||||
if err := cm.RemoveOpenAICredentials(); err != nil {
|
||||
return fmt.Errorf("failed to remove credentials: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Successfully logged out from OpenAI!")
|
||||
fmt.Println("You will need to use environment variables or command-line flags for authentication.")
|
||||
|
||||
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
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -47,6 +48,9 @@ func runModels(_ *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func printAllProviders(showAll bool) error {
|
||||
// Reload the registry to pick up any custom models from config
|
||||
models.ReloadGlobalRegistry()
|
||||
|
||||
var providerIDs []string
|
||||
if showAll {
|
||||
providerIDs = kit.GetSupportedProviders()
|
||||
@@ -98,6 +102,9 @@ func printAllProviders(showAll bool) error {
|
||||
}
|
||||
|
||||
func printProvider(provider string) error {
|
||||
// Reload the registry to pick up any custom models from config
|
||||
models.ReloadGlobalRegistry()
|
||||
|
||||
m, err := kit.GetModelsForProvider(provider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unknown provider %q. Run 'kit models' to see all providers", provider)
|
||||
|
||||
+738
-69
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// skillCmd installs Kit skills via the skills.sh CLI (npx skills).
|
||||
var skillCmd = &cobra.Command{
|
||||
Use: "skill",
|
||||
Short: "Install Kit skills via skills.sh",
|
||||
Long: `Install Kit skills that teach AI agents how to build with Kit.
|
||||
Uses the skills.sh CLI (npx skills) to install all skills from the Kit repository.
|
||||
|
||||
Two skills are provided:
|
||||
|
||||
1. Extensions — creating Kit extensions with full knowledge of the extension
|
||||
API, lifecycle events, widgets, tools, commands, editor interceptors,
|
||||
tool renderers, and Yaegi interpreter constraints.
|
||||
|
||||
2. SDK — building AI-powered applications with the Kit Go SDK, including
|
||||
providers, agents, tools, and MCP integration.
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
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,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/pkg/extensions/test"
|
||||
)
|
||||
|
||||
// TestSubagentMonitor_SessionStart verifies OnSessionStart initializes state
|
||||
// without panicking and properly guards nil ctx calls.
|
||||
func TestSubagentMonitor_SessionStart(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
|
||||
|
||||
// Emit SessionStart - should not panic even with nil ctx functions
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
|
||||
if err != nil {
|
||||
t.Fatalf("SessionStart should not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubagentMonitor_SubagentLifecycle verifies the full subagent lifecycle
|
||||
// creates entries and emits widget updates.
|
||||
func TestSubagentMonitor_SubagentLifecycle(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
|
||||
|
||||
// Start session
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
|
||||
if err != nil {
|
||||
t.Fatalf("SessionStart should not error: %v", err)
|
||||
}
|
||||
|
||||
// Emit SubagentStart
|
||||
_, err = harness.Emit(extensions.SubagentStartEvent{
|
||||
ToolCallID: "call-1",
|
||||
Task: "test task",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubagentStart should not error: %v", err)
|
||||
}
|
||||
|
||||
// Emit a few chunks
|
||||
for i := range 3 {
|
||||
_, err = harness.Emit(extensions.SubagentChunkEvent{
|
||||
ToolCallID: "call-1",
|
||||
Task: "test task",
|
||||
ChunkType: "text",
|
||||
Content: fmt.Sprintf("line %d", i),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubagentChunk %d should not error: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Emit tool call chunk
|
||||
_, err = harness.Emit(extensions.SubagentChunkEvent{
|
||||
ToolCallID: "call-1",
|
||||
Task: "test task",
|
||||
ChunkType: "tool_call",
|
||||
ToolName: "bash",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubagentChunk tool_call should not error: %v", err)
|
||||
}
|
||||
|
||||
// Emit SubagentEnd
|
||||
_, err = harness.Emit(extensions.SubagentEndEvent{
|
||||
ToolCallID: "call-1",
|
||||
Task: "test task",
|
||||
Response: "done",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubagentEnd should not error: %v", err)
|
||||
}
|
||||
|
||||
// Give time for cleanup goroutine
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestSubagentMonitor_MultipleSubagents verifies multiple parallel subagents.
|
||||
func TestSubagentMonitor_MultipleSubagents(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
|
||||
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
|
||||
if err != nil {
|
||||
t.Fatalf("SessionStart should not error: %v", err)
|
||||
}
|
||||
|
||||
// Start 3 subagents
|
||||
for i := 1; i <= 3; i++ {
|
||||
_, err := harness.Emit(extensions.SubagentStartEvent{
|
||||
ToolCallID: fmt.Sprintf("call-%d", i),
|
||||
Task: fmt.Sprintf("task %d", i),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubagentStart %d should not error: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Emit chunks for each
|
||||
for i := 1; i <= 3; i++ {
|
||||
_, err := harness.Emit(extensions.SubagentChunkEvent{
|
||||
ToolCallID: fmt.Sprintf("call-%d", i),
|
||||
Task: fmt.Sprintf("task %d", i),
|
||||
ChunkType: "text",
|
||||
Content: fmt.Sprintf("output from agent %d", i),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubagentChunk %d should not error: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// End all subagents
|
||||
for i := 1; i <= 3; i++ {
|
||||
_, err := harness.Emit(extensions.SubagentEndEvent{
|
||||
ToolCallID: fmt.Sprintf("call-%d", i),
|
||||
Task: fmt.Sprintf("task %d", i),
|
||||
Response: "completed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubagentEnd %d should not error: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestSubagentMonitor_SessionShutdown verifies shutdown doesn't panic
|
||||
// even with nil ctx functions.
|
||||
func TestSubagentMonitor_SessionShutdown(t *testing.T) {
|
||||
harness := test.New(t)
|
||||
harness.LoadFile("../../.kit/extensions/subagent-monitor.go")
|
||||
|
||||
// Start then shutdown
|
||||
_, err := harness.Emit(extensions.SessionStartEvent{SessionID: "test-session"})
|
||||
if err != nil {
|
||||
t.Fatalf("SessionStart should not error: %v", err)
|
||||
}
|
||||
|
||||
// Start a subagent
|
||||
_, err = harness.Emit(extensions.SubagentStartEvent{
|
||||
ToolCallID: "call-1",
|
||||
Task: "test task",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubagentStart should not error: %v", err)
|
||||
}
|
||||
|
||||
// Shutdown - should not panic even with active subagent
|
||||
_, err = harness.Emit(extensions.SessionShutdownEvent{})
|
||||
if err != nil {
|
||||
t.Fatalf("SessionShutdown should not error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# SDK Examples
|
||||
|
||||
These examples demonstrate how to use the Kit SDK (`pkg/kit`) to build agents programmatically in Go.
|
||||
|
||||
## Examples
|
||||
|
||||
### [basic](basic/)
|
||||
|
||||
Shows core SDK usage: creating a Kit instance, sending prompts, overriding the model, subscribing to events (tool calls, streaming), and session management.
|
||||
|
||||
```bash
|
||||
go run ./examples/sdk/basic
|
||||
```
|
||||
|
||||
### [scripting](scripting/)
|
||||
|
||||
A minimal script-friendly wrapper that takes a prompt from the command line and prints the response — useful for piping and automation.
|
||||
|
||||
```bash
|
||||
go run ./examples/sdk/scripting "Explain what this repo does"
|
||||
```
|
||||
|
||||
### [crypto-monitor](crypto-monitor/)
|
||||
|
||||
A background agent that checks Bitcoin and Ethereum prices every 30 minutes and sends desktop notifications via `notify-send` (dbus). Demonstrates using the SDK for a long-running autonomous task with a single tool.
|
||||
|
||||
```bash
|
||||
go run ./examples/sdk/crypto-monitor
|
||||
|
||||
# Override the check interval:
|
||||
CRYPTO_INTERVAL=5m go run ./examples/sdk/crypto-monitor
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
```go
|
||||
import kit "github.com/mark3labs/kit/pkg/kit"
|
||||
|
||||
host, err := kit.New(ctx, nil) // uses ~/.kit.yml defaults
|
||||
defer host.Close()
|
||||
|
||||
response, err := host.Prompt(ctx, "Hello!")
|
||||
```
|
||||
|
||||
See the [SDK README](../../pkg/kit/README.md) for the full API reference.
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
kit "github.com/mark3labs/kit/pkg/kit"
|
||||
)
|
||||
|
||||
const systemPrompt = `You are a cryptocurrency price monitor. Your job is to:
|
||||
|
||||
1. Fetch the current prices of Bitcoin and Ethereum using bash with curl
|
||||
2. Send a desktop notification with the results using notify-send
|
||||
|
||||
To fetch prices, use this CoinGecko API endpoint (no API key needed):
|
||||
curl -s 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum&vs_currencies=usd&include_24hr_change=true'
|
||||
|
||||
To send a desktop notification:
|
||||
notify-send -i dialog-information "Crypto Prices" "BTC: $XX,XXX (+X.X%)\nETH: $X,XXX (+X.X%)"
|
||||
|
||||
Include the 24h percentage change in the notification. Use a green arrow (▲) for
|
||||
positive changes and a red arrow (▼) for negative. Format prices with commas.
|
||||
|
||||
If the API call fails, send a notification about the failure instead.
|
||||
|
||||
Always complete both steps: fetch then notify. Be concise — no commentary needed.`
|
||||
|
||||
func main() {
|
||||
interval := 30 * time.Minute
|
||||
if os.Getenv("CRYPTO_INTERVAL") != "" {
|
||||
d, err := time.ParseDuration(os.Getenv("CRYPTO_INTERVAL"))
|
||||
if err == nil {
|
||||
interval = d
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
host, err := kit.New(ctx, &kit.Options{
|
||||
SystemPrompt: systemPrompt,
|
||||
Tools: []kit.Tool{kit.NewBashTool()},
|
||||
NoSession: true,
|
||||
Quiet: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create kit instance: %v", err)
|
||||
}
|
||||
defer func() { _ = host.Close() }()
|
||||
|
||||
fmt.Printf("Crypto price monitor started (every %s)\n", interval)
|
||||
fmt.Println("Press Ctrl+C to stop")
|
||||
|
||||
// Run immediately on startup, then on each tick.
|
||||
check(ctx, host)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
check(ctx, host)
|
||||
case <-ctx.Done():
|
||||
fmt.Println("\nStopping price monitor")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func check(ctx context.Context, host *kit.Kit) {
|
||||
fmt.Printf("[%s] Checking prices...\n", time.Now().Format("15:04:05"))
|
||||
|
||||
// Clear session so each check is independent.
|
||||
host.ClearSession()
|
||||
|
||||
_, err := host.Prompt(ctx, "Fetch current Bitcoin and Ethereum prices and send a desktop notification.")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,99 @@
|
||||
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.17.1
|
||||
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.46.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
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.2 // indirect
|
||||
cloud.google.com/go/auth v0.19.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
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.8 // 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/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-20260323091123-df7b1bcffcca // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca // 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
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // 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.20.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/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/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
@@ -97,46 +102,44 @@ 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.8.2 // 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.273.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,15 +1,17 @@
|
||||
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.17.1 h1:SQzfnyJPDuQWt6e//KKmQmEEXdqHMC0IZz10XwkLcEM=
|
||||
charm.land/fantasy v0.17.1/go.mod h1:FF5ALCCHETacHJPBqU42CtwMInYQ0ul52fdzIHQMbQk=
|
||||
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=
|
||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
||||
cloud.google.com/go/auth v0.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ=
|
||||
cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
@@ -32,74 +34,82 @@ 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.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/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/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-20260323091123-df7b1bcffcca h1:62yAoS1Ynbuzwcn1LkNBxi3IMF5p0E0cHCoaLOOmN9w=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260323091123-df7b1bcffcca/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-20260323091123-df7b1bcffcca h1:QQoyQLgUzojMNWHVHToN6d9qTvT0KWtxUKIRPx/Ox5o=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260323091123-df7b1bcffcca/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 +118,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 +152,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=
|
||||
@@ -151,14 +169,16 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
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.20.0 h1:NIKVuLhDlIV74muWlsMM4CcQZqN6JJ20Qcxd9YMuYcs=
|
||||
github.com/googleapis/gax-go/v2 v2.20.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
|
||||
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=
|
||||
@@ -167,14 +187,12 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
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 +203,17 @@ 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/mark3labs/mcp-go v0.46.0 h1:8KRibF4wcKejbLsHxCA/QBVUr5fQ9nwz/n8lGqmaALo=
|
||||
github.com/mark3labs/mcp-go v0.46.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
|
||||
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,10 +228,8 @@ 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/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
@@ -257,63 +273,65 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
|
||||
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
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.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/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.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ=
|
||||
google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
|
||||
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 v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
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{}
|
||||
}
|
||||
}
|
||||
+255
-22
@@ -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,21 @@ 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)
|
||||
|
||||
// ToolOutputHandler is a function type for handling streaming tool output chunks.
|
||||
// Used by tools like bash to stream output as it arrives rather than waiting
|
||||
// for the command to complete. The isStderr flag indicates if the chunk
|
||||
// contains stderr output.
|
||||
// Note: This is an alias for core.ToolOutputCallback to avoid import cycles.
|
||||
type ToolOutputHandler = core.ToolOutputCallback
|
||||
|
||||
// StepUsageHandler is a function type for handling token usage after each
|
||||
// complete step in a multi-step agent turn. This enables real-time cost
|
||||
// tracking during long-running tool-calling conversations.
|
||||
type StepUsageHandler func(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64)
|
||||
|
||||
// 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 +91,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 +104,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 +176,28 @@ 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 {
|
||||
// Skip max_output_tokens for providers that don't support it (e.g., Codex OAuth)
|
||||
if agentConfig.ModelConfig.MaxTokens > 0 && !providerResult.SkipMaxOutputTokens {
|
||||
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 +221,7 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
streamingEnabled: agentConfig.StreamingEnabled,
|
||||
coreTools: coreTools,
|
||||
extraTools: agentConfig.ExtraTools,
|
||||
toolWrapper: agentConfig.ToolWrapper,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -188,7 +231,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, nil, nil)
|
||||
}
|
||||
|
||||
// GenerateWithLoopAndStreaming processes messages using the fantasy agent with streaming and callbacks.
|
||||
@@ -198,11 +241,21 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
|
||||
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
|
||||
onStreamingResponse StreamingResponseHandler,
|
||||
onReasoningDelta ReasoningDeltaHandler,
|
||||
onToolOutput ToolOutputHandler,
|
||||
onStepUsage StepUsageHandler,
|
||||
) (*GenerateWithLoopResult, error) {
|
||||
|
||||
// Inject tool output handler into context for use by core tools (e.g., bash).
|
||||
if onToolOutput != nil {
|
||||
ctx = core.ContextWithToolOutputCallback(ctx, onToolOutput)
|
||||
}
|
||||
|
||||
// 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 +266,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{
|
||||
streamCall := 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 +313,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 +331,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 +345,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()
|
||||
}
|
||||
@@ -283,10 +358,73 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan
|
||||
if text != "" && len(toolCalls) > 0 && onToolCallContent != nil {
|
||||
onToolCallContent(text)
|
||||
}
|
||||
// Emit step usage for real-time cost tracking
|
||||
if onStepUsage != nil {
|
||||
onStepUsage(step.Usage.InputTokens, step.Usage.OutputTokens,
|
||||
step.Usage.CacheReadTokens, step.Usage.CacheCreationTokens)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// If a steer channel is attached to the context, wire up a
|
||||
// PrepareStep function that drains the channel between steps
|
||||
// and injects pending steer messages as user messages before
|
||||
// the next LLM call. This enables graceful mid-turn steering
|
||||
// without cancelling in-progress tool execution.
|
||||
if steerCh := steerChFromContext(ctx); steerCh != nil {
|
||||
onConsumed := steerConsumedFromContext(ctx)
|
||||
streamCall.PrepareStep = func(
|
||||
stepCtx context.Context,
|
||||
opts fantasy.PrepareStepFunctionOptions,
|
||||
) (context.Context, fantasy.PrepareStepResult, error) {
|
||||
// Drain all pending steer messages (non-blocking).
|
||||
var steered []string
|
||||
for {
|
||||
select {
|
||||
case msg := <-steerCh:
|
||||
steered = append(steered, msg)
|
||||
default:
|
||||
goto done
|
||||
}
|
||||
}
|
||||
done:
|
||||
result := fantasy.PrepareStepResult{
|
||||
Model: opts.Model,
|
||||
Messages: opts.Messages,
|
||||
}
|
||||
if len(steered) > 0 {
|
||||
// Inject each steer message as a user message so the
|
||||
// LLM sees the redirection on the next step.
|
||||
for _, text := range steered {
|
||||
result.Messages = append(result.Messages,
|
||||
fantasy.NewUserMessage(text))
|
||||
}
|
||||
// Notify that steer messages were consumed.
|
||||
if onConsumed != nil {
|
||||
onConsumed(len(steered))
|
||||
}
|
||||
}
|
||||
return stepCtx, result, nil
|
||||
}
|
||||
}
|
||||
|
||||
result, err := a.fantasyAgent.Stream(ctx, streamCall)
|
||||
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 +440,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 +461,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 +494,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 +523,7 @@ func convertAgentResult(result *fantasy.AgentResult, originalMessages []fantasy.
|
||||
ConversationMessages: allFantasyMessages,
|
||||
Messages: allMessages,
|
||||
TotalUsage: result.TotalUsage,
|
||||
StopReason: string(result.Response.FinishReason),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,6 +600,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 +631,89 @@ 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.
|
||||
// Skip max_output_tokens for providers that don't support it (e.g., Codex OAuth)
|
||||
if config.MaxTokens > 0 && !providerResult.SkipMaxOutputTokens {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package agent
|
||||
|
||||
import "context"
|
||||
|
||||
// steerChKey is the context key for the steer channel.
|
||||
type steerChKey struct{}
|
||||
|
||||
// steerConsumedKey is the context key for the steer-consumed callback.
|
||||
type steerConsumedKey struct{}
|
||||
|
||||
// ContextWithSteerCh returns a new context with the steer channel attached.
|
||||
// The agent's PrepareStep function checks this channel between steps and
|
||||
// injects any pending steer messages as user messages before the next LLM call.
|
||||
func ContextWithSteerCh(ctx context.Context, ch <-chan string) context.Context {
|
||||
return context.WithValue(ctx, steerChKey{}, ch)
|
||||
}
|
||||
|
||||
// ContextWithSteerConsumed returns a new context with a callback that fires
|
||||
// when steer messages are consumed by PrepareStep. The count argument is the
|
||||
// number of messages injected in this batch.
|
||||
func ContextWithSteerConsumed(ctx context.Context, fn func(count int)) context.Context {
|
||||
return context.WithValue(ctx, steerConsumedKey{}, fn)
|
||||
}
|
||||
|
||||
// steerChFromContext extracts the steer channel from the context, or nil.
|
||||
func steerChFromContext(ctx context.Context) <-chan string {
|
||||
ch, _ := ctx.Value(steerChKey{}).(<-chan string)
|
||||
return ch
|
||||
}
|
||||
|
||||
// steerConsumedFromContext extracts the steer-consumed callback, or nil.
|
||||
func steerConsumedFromContext(ctx context.Context) func(int) {
|
||||
fn, _ := ctx.Value(steerConsumedKey{}).(func(int))
|
||||
return fn
|
||||
}
|
||||
+429
-62
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
@@ -13,6 +14,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 +54,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 +107,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 +124,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 +136,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 +160,82 @@ func (a *App) QueueLength() int {
|
||||
return len(a.queue)
|
||||
}
|
||||
|
||||
// Steer injects a steering message into the currently running agent turn.
|
||||
// If the agent is in a multi-step tool loop, the message is delivered after
|
||||
// the current tool execution finishes but before the next LLM call (graceful
|
||||
// mid-turn injection via Fantasy's PrepareStep). If the agent is streaming
|
||||
// a text-only response (no pending tool calls), the message waits until the
|
||||
// response completes and then executes as the next turn.
|
||||
//
|
||||
// If the agent is idle, the message starts executing immediately (same as Run).
|
||||
//
|
||||
// Returns the number of pending steer/queue items (0 = started immediately,
|
||||
// >0 = injected/queued). The caller must update UI state based on the return
|
||||
// value — Steer does NOT send events to the program to avoid deadlocking
|
||||
// when called from within Update().
|
||||
//
|
||||
// Satisfies ui.AppController.
|
||||
func (a *App) Steer(prompt string) int {
|
||||
a.mu.Lock()
|
||||
|
||||
if a.closed {
|
||||
a.mu.Unlock()
|
||||
return 0
|
||||
}
|
||||
|
||||
if !a.busy {
|
||||
// Not busy — start immediately, same as Run().
|
||||
item := queueItem{Prompt: prompt}
|
||||
a.busy = true
|
||||
a.wg.Add(1)
|
||||
a.mu.Unlock()
|
||||
go a.drainQueue(item)
|
||||
return 0
|
||||
}
|
||||
|
||||
a.mu.Unlock()
|
||||
|
||||
// Agent is busy — inject via the SDK's steer channel. The message
|
||||
// will be picked up by PrepareStep between agent steps (after tool
|
||||
// execution, before next LLM call). If PrepareStep doesn't fire
|
||||
// (text-only response), drainQueue will pick it up after the turn.
|
||||
if a.opts.Kit != nil {
|
||||
a.opts.Kit.InjectSteer(prompt)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// InterruptAndSend 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 hard-cancel delivery mode used by extensions'
|
||||
// CancelAndSend.
|
||||
func (a *App) InterruptAndSend(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 +264,42 @@ 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
|
||||
// Also update the kit SDK's tree session so messages are persisted correctly.
|
||||
if a.opts.Kit != nil {
|
||||
a.opts.Kit.SetTreeSession(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 +374,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 +385,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 +417,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
|
||||
}
|
||||
@@ -307,47 +452,94 @@ func (a *App) Close() {
|
||||
|
||||
// Wait for background goroutines.
|
||||
a.wg.Wait()
|
||||
|
||||
// Clean up empty session file on shutdown.
|
||||
if ts := a.opts.TreeSession; ts != nil && ts.IsEmpty() {
|
||||
if path := ts.GetFilePath(); path != "" {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 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)
|
||||
|
||||
// Drain any unconsumed steer messages from the SDK channel.
|
||||
// These arrive when the user steered during a text-only response
|
||||
// (no tool calls, so PrepareStep didn't fire for a second step).
|
||||
// They go to the front of the queue so they run next.
|
||||
if a.opts.Kit != nil {
|
||||
if leftover := a.opts.Kit.DrainSteer(); len(leftover) > 0 {
|
||||
a.mu.Lock()
|
||||
steerItems := make([]queueItem, len(leftover))
|
||||
for i, text := range leftover {
|
||||
steerItems[i] = queueItem{Prompt: text}
|
||||
}
|
||||
a.queue = append(steerItems, a.queue...)
|
||||
a.mu.Unlock()
|
||||
// Notify UI about the consumed steer messages.
|
||||
a.sendEvent(SteerConsumedEvent{})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +558,17 @@ 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
|
||||
// preserves the user message and any completed tool
|
||||
// call/result pairs; only the in-progress message or tool
|
||||
// call is discarded. Sync the in-memory store to match.
|
||||
if ts := a.opts.TreeSession; ts != nil {
|
||||
a.store.Replace(ts.GetFantasyMessages())
|
||||
}
|
||||
a.sendEvent(StepCancelledEvent{})
|
||||
return
|
||||
}
|
||||
@@ -387,9 +584,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 +606,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 +626,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 +729,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 +745,17 @@ 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})
|
||||
case kit.ToolOutputEvent:
|
||||
sendFn(ToolOutputEvent{
|
||||
ToolCallID: ev.ToolCallID,
|
||||
ToolName: ev.ToolName,
|
||||
Chunk: ev.Chunk,
|
||||
IsStderr: ev.IsStderr,
|
||||
})
|
||||
case kit.SteerConsumedEvent:
|
||||
sendFn(SteerConsumedEvent{})
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -474,6 +766,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 +799,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 +877,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()
|
||||
@@ -570,28 +926,39 @@ func (a *App) PrintBlockFromExtension(opts extensions.PrintBlockOpts) {
|
||||
}
|
||||
|
||||
// updateUsageFromTurnResult records token usage from an SDK TurnResult into the
|
||||
// configured UsageTracker. This is the SDK-path equivalent of updateUsage.
|
||||
// configured UsageTracker. Called once per turn after the turn completes.
|
||||
//
|
||||
// Cost/token accumulation uses TotalUsage (sum across all tool-calling steps in
|
||||
// the turn). Context-window fill uses FinalUsage.InputTokens only — that is the
|
||||
// number of tokens sent to the model on the last API call, which equals the
|
||||
// actual context window occupation (all accumulated messages + tool results).
|
||||
// OutputTokens are not added here because they are the response length, not
|
||||
// context fill.
|
||||
func (a *App) updateUsageFromTurnResult(result *kit.TurnResult, userPrompt string) {
|
||||
if a.opts.UsageTracker == nil || result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if result.TotalUsage != nil {
|
||||
inputTokens := int(result.TotalUsage.InputTokens)
|
||||
outputTokens := int(result.TotalUsage.OutputTokens)
|
||||
if inputTokens > 0 && outputTokens > 0 {
|
||||
cacheReadTokens := int(result.TotalUsage.CacheReadTokens)
|
||||
cacheWriteTokens := int(result.TotalUsage.CacheCreationTokens)
|
||||
a.opts.UsageTracker.UpdateUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens)
|
||||
} else {
|
||||
a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, result.Response)
|
||||
return
|
||||
}
|
||||
// --- Accumulate cost/token totals for the session ---
|
||||
if result.TotalUsage != nil && result.TotalUsage.InputTokens > 0 {
|
||||
a.opts.UsageTracker.UpdateUsage(
|
||||
int(result.TotalUsage.InputTokens),
|
||||
int(result.TotalUsage.OutputTokens),
|
||||
int(result.TotalUsage.CacheReadTokens),
|
||||
int(result.TotalUsage.CacheCreationTokens),
|
||||
)
|
||||
} else {
|
||||
// Provider didn't report token counts — fall back to character-based
|
||||
// estimates so the footer shows something rather than nothing.
|
||||
a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, result.Response)
|
||||
}
|
||||
|
||||
if result.FinalUsage != nil {
|
||||
if ct := int(result.FinalUsage.InputTokens) + int(result.FinalUsage.OutputTokens); ct > 0 {
|
||||
a.opts.UsageTracker.SetContextTokens(ct)
|
||||
}
|
||||
// --- Context window fill (drives the % bar) ---
|
||||
// Use FinalUsage.InputTokens: the input token count of the last API call
|
||||
// equals the number of tokens currently occupying the context window.
|
||||
// Adding OutputTokens would overstate fill since the response is not part
|
||||
// of the context that was *sent* to the model.
|
||||
if result.FinalUsage != nil && result.FinalUsage.InputTokens > 0 {
|
||||
a.opts.UsageTracker.SetContextTokens(int(result.FinalUsage.InputTokens))
|
||||
}
|
||||
}
|
||||
|
||||
+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.
|
||||
@@ -39,6 +54,19 @@ type ToolResultEvent struct {
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// ToolOutputEvent is sent when a tool produces streaming output chunks (e.g., bash output).
|
||||
// This allows the TUI to display tool output as it arrives, before the tool completes.
|
||||
type ToolOutputEvent struct {
|
||||
// ToolCallID is the stable identifier for the tool call producing output.
|
||||
ToolCallID string
|
||||
// ToolName is the name of the tool producing output.
|
||||
ToolName string
|
||||
// Chunk is a piece of the tool's output text.
|
||||
Chunk string
|
||||
// IsStderr indicates whether this chunk came from stderr.
|
||||
IsStderr bool
|
||||
}
|
||||
|
||||
// ToolCallContentEvent is sent when a step includes text content alongside tool calls.
|
||||
// This allows the TUI to display assistant commentary that accompanies tool usage.
|
||||
type ToolCallContentEvent struct {
|
||||
@@ -113,11 +141,34 @@ type CompactErrorEvent struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// SteerConsumedEvent is sent when one or more steering messages have been
|
||||
// consumed — either injected mid-turn via PrepareStep, or drained into the
|
||||
// queue after a turn completes. The TUI uses this to clear the steering
|
||||
// badge from the display.
|
||||
type SteerConsumedEvent struct{}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
)
|
||||
|
||||
// CredentialStore holds all stored credentials for various providers.
|
||||
// Currently supports Anthropic credentials with both OAuth and API key authentication methods.
|
||||
// Currently supports Anthropic and OpenAI credentials with both OAuth and API key authentication methods.
|
||||
type CredentialStore struct {
|
||||
Anthropic *AnthropicCredentials `json:"anthropic,omitempty"`
|
||||
OpenAI *OpenAICredentials `json:"openai,omitempty"`
|
||||
}
|
||||
|
||||
// AnthropicCredentials holds Anthropic API credentials supporting both OAuth
|
||||
@@ -28,6 +29,20 @@ type AnthropicCredentials struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// OpenAICredentials holds OpenAI API credentials supporting both OAuth
|
||||
// and API key authentication methods. The Type field indicates which authentication
|
||||
// method is being used. For OAuth, tokens are stored with expiration timestamps
|
||||
// for automatic refresh. For API keys, only the key itself is stored.
|
||||
type OpenAICredentials struct {
|
||||
Type string `json:"type"` // "oauth" or "api_key"
|
||||
APIKey string `json:"api_key,omitempty"` // For API key auth
|
||||
AccessToken string `json:"access_token,omitempty"` // For OAuth
|
||||
RefreshToken string `json:"refresh_token,omitempty"` // For OAuth
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"` // For OAuth
|
||||
AccountID string `json:"account_id,omitempty"` // For OAuth (ChatGPT account ID)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// IsExpired checks if the OAuth token is expired based on the ExpiresAt timestamp.
|
||||
// Returns false for API key authentication or if no expiration is set.
|
||||
func (c *AnthropicCredentials) IsExpired() bool {
|
||||
@@ -48,6 +63,26 @@ func (c *AnthropicCredentials) NeedsRefresh() bool {
|
||||
return time.Now().Unix() >= (c.ExpiresAt - 300) // 5 minutes buffer
|
||||
}
|
||||
|
||||
// IsExpired checks if the OAuth token is expired based on the ExpiresAt timestamp.
|
||||
// Returns false for API key authentication or if no expiration is set.
|
||||
func (c *OpenAICredentials) IsExpired() bool {
|
||||
if c.Type != "oauth" || c.ExpiresAt == 0 {
|
||||
return false
|
||||
}
|
||||
return time.Now().Unix() >= c.ExpiresAt
|
||||
}
|
||||
|
||||
// NeedsRefresh checks if the OAuth token needs refresh, returning true if the token
|
||||
// will expire within the next 5 minutes. This allows for proactive token refresh
|
||||
// to avoid authentication failures during operations. Returns false for API key
|
||||
// authentication or if no expiration is set.
|
||||
func (c *OpenAICredentials) NeedsRefresh() bool {
|
||||
if c.Type != "oauth" || c.ExpiresAt == 0 {
|
||||
return false
|
||||
}
|
||||
return time.Now().Unix() >= (c.ExpiresAt - 300) // 5 minutes buffer
|
||||
}
|
||||
|
||||
// CredentialManager handles secure storage and retrieval of authentication credentials.
|
||||
// It manages a JSON file stored in the user's config directory with appropriate
|
||||
// file permissions for security.
|
||||
@@ -212,6 +247,142 @@ func (cm *CredentialManager) HasAnthropicCredentials() (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetOpenAICredentials stores OpenAI API key credentials. It validates the
|
||||
// API key format before storing. The API key must start with "sk-" and be
|
||||
// at least 20 characters long. Returns an error if the API key is invalid or
|
||||
// if storage fails.
|
||||
func (cm *CredentialManager) SetOpenAICredentials(apiKey string) error {
|
||||
if err := validateOpenAIAPIKey(apiKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store, err := cm.LoadCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store.OpenAI = &OpenAICredentials{
|
||||
Type: "api_key",
|
||||
APIKey: apiKey,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
return cm.SaveCredentials(store)
|
||||
}
|
||||
|
||||
// GetOpenAICredentials retrieves stored OpenAI credentials. Returns nil if
|
||||
// no credentials are stored. The returned credentials may be either OAuth or API
|
||||
// key type, check the Type field to determine which.
|
||||
func (cm *CredentialManager) GetOpenAICredentials() (*OpenAICredentials, error) {
|
||||
store, err := cm.LoadCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return store.OpenAI, nil
|
||||
}
|
||||
|
||||
// RemoveOpenAICredentials removes stored OpenAI credentials from storage.
|
||||
// If this was the only credential stored, the entire credentials file is removed.
|
||||
// Returns an error if the removal fails.
|
||||
func (cm *CredentialManager) RemoveOpenAICredentials() error {
|
||||
store, err := cm.LoadCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store.OpenAI = nil
|
||||
|
||||
// If store is empty, remove the file entirely
|
||||
if store.Anthropic == nil && store.OpenAI == nil {
|
||||
if err := os.Remove(cm.credentialsPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove credentials file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return cm.SaveCredentials(store)
|
||||
}
|
||||
|
||||
// HasOpenAICredentials checks if valid OpenAI credentials are stored.
|
||||
// Returns true if either a non-empty OAuth access token or API key is present,
|
||||
// false otherwise. Returns an error if credentials cannot be loaded.
|
||||
func (cm *CredentialManager) HasOpenAICredentials() (bool, error) {
|
||||
creds, err := cm.GetOpenAICredentials()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if creds == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check based on credential type
|
||||
switch creds.Type {
|
||||
case "oauth":
|
||||
return creds.AccessToken != "", nil
|
||||
case "api_key":
|
||||
return creds.APIKey != "", nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetOpenAIOAuthCredentials stores OpenAI OAuth credentials in the credential manager's secure storage.
|
||||
// The credentials should include access token, refresh token, and expiration information.
|
||||
// Returns an error if the credentials cannot be saved.
|
||||
func (cm *CredentialManager) SetOpenAIOAuthCredentials(creds *OpenAICredentials) error {
|
||||
store, err := cm.LoadCredentials()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store.OpenAI = creds
|
||||
return cm.SaveCredentials(store)
|
||||
}
|
||||
|
||||
// GetValidOpenAIAccessToken returns a valid access token for API requests. For OAuth credentials,
|
||||
// it automatically refreshes the token if it's expired or about to expire. For API key
|
||||
// credentials, it simply returns the API key. Returns an error if no credentials are found,
|
||||
// if token refresh fails, or if the credential type is unknown.
|
||||
func (cm *CredentialManager) GetValidOpenAIAccessToken() (string, error) {
|
||||
creds, err := cm.GetOpenAICredentials()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if creds == nil {
|
||||
return "", fmt.Errorf("no credentials found")
|
||||
}
|
||||
|
||||
// For API key auth, return the API key
|
||||
if creds.Type == "api_key" {
|
||||
return creds.APIKey, nil
|
||||
}
|
||||
|
||||
// For OAuth, check if token needs refresh
|
||||
if creds.Type == "oauth" {
|
||||
if creds.NeedsRefresh() {
|
||||
// Refresh the token
|
||||
client := NewOpenAIOAuthClient()
|
||||
newCreds, err := client.RefreshToken(creds.RefreshToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Update stored credentials
|
||||
if err := cm.SetOpenAIOAuthCredentials(newCreds); err != nil {
|
||||
return "", fmt.Errorf("failed to save refreshed token: %w", err)
|
||||
}
|
||||
|
||||
return newCreds.AccessToken, nil
|
||||
}
|
||||
|
||||
return creds.AccessToken, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unknown credential type: %s", creds.Type)
|
||||
}
|
||||
|
||||
// GetCredentialsPath returns the absolute path to the credentials JSON file.
|
||||
// This is useful for debugging or displaying the storage location to users.
|
||||
func (cm *CredentialManager) GetCredentialsPath() string {
|
||||
@@ -238,6 +409,26 @@ func validateAnthropicAPIKey(apiKey string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOpenAIAPIKey validates the format of an OpenAI API key
|
||||
func validateOpenAIAPIKey(apiKey string) error {
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("API key cannot be empty")
|
||||
}
|
||||
|
||||
// OpenAI API keys typically start with "sk-" and are quite long
|
||||
if !strings.HasPrefix(apiKey, "sk-") {
|
||||
return fmt.Errorf("invalid OpenAI API key format (should start with 'sk-')")
|
||||
}
|
||||
|
||||
if len(apiKey) < 20 {
|
||||
return fmt.Errorf("API key appears to be too short")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAnthropicAPIKey retrieves an Anthropic API key from multiple sources in priority order:
|
||||
// 1. Command-line flag value (highest priority)
|
||||
// 2. Stored credentials (OAuth or API key)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+269
-3
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -30,6 +31,7 @@ type OAuthClient struct {
|
||||
type AuthData struct {
|
||||
URL string
|
||||
Verifier string
|
||||
State string // Optional state parameter for CSRF protection
|
||||
}
|
||||
|
||||
// NewOAuthClient creates a new OAuth client configured for Anthropic's OAuth service.
|
||||
@@ -49,12 +51,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 +78,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)
|
||||
}
|
||||
@@ -199,6 +201,270 @@ func (c *OAuthClient) parseCodeAndState(code string) (parsedCode, parsedState st
|
||||
return
|
||||
}
|
||||
|
||||
// OpenAIOAuthClient handles OAuth 2.0 authentication flow with OpenAI Codex (ChatGPT Plus/Pro).
|
||||
// This uses OpenAI's auth0-based OAuth service for ChatGPT account authentication.
|
||||
type OpenAIOAuthClient struct {
|
||||
ClientID string
|
||||
AuthorizeURL string
|
||||
TokenURL string
|
||||
RedirectURI string
|
||||
Scopes string
|
||||
}
|
||||
|
||||
// NewOpenAIOAuthClient creates a new OAuth client configured for OpenAI Codex OAuth.
|
||||
// This uses the public client ID for CLI applications with PKCE for security.
|
||||
func NewOpenAIOAuthClient() *OpenAIOAuthClient {
|
||||
return &OpenAIOAuthClient{
|
||||
// Public client ID for OpenAI Codex CLI OAuth
|
||||
ClientID: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
AuthorizeURL: "https://auth.openai.com/oauth/authorize",
|
||||
TokenURL: "https://auth.openai.com/oauth/token",
|
||||
RedirectURI: "http://localhost:1455/auth/callback",
|
||||
Scopes: "openid profile email offline_access",
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuthorizationURL generates a complete authorization URL for the OAuth flow with
|
||||
// PKCE parameters. Returns an AuthData structure containing the URL for user
|
||||
// authentication and the PKCE verifier for the subsequent code exchange.
|
||||
func (c *OpenAIOAuthClient) GetAuthorizationURL() (*AuthData, error) {
|
||||
verifier, challenge, err := generatePKCE()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate PKCE: %w", err)
|
||||
}
|
||||
|
||||
// Generate random state
|
||||
stateBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(stateBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate state: %w", err)
|
||||
}
|
||||
state := fmt.Sprintf("%x", stateBytes)
|
||||
|
||||
params := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {c.ClientID},
|
||||
"redirect_uri": {c.RedirectURI},
|
||||
"scope": {c.Scopes},
|
||||
"code_challenge": {challenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
"state": {state},
|
||||
"id_token_add_organizations": {"true"},
|
||||
"codex_cli_simplified_flow": {"true"},
|
||||
"originator": {"kit"},
|
||||
}
|
||||
|
||||
authURL := fmt.Sprintf("%s?%s", c.AuthorizeURL, params.Encode())
|
||||
|
||||
return &AuthData{
|
||||
URL: authURL,
|
||||
Verifier: verifier,
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges an authorization code for access and refresh tokens.
|
||||
// The code parameter should be the authorization code received from the OAuth callback.
|
||||
// The verifier parameter must be the same PKCE verifier generated during GetAuthorizationURL.
|
||||
// Returns OpenAICredentials containing the tokens, expiration, and account ID.
|
||||
func (c *OpenAIOAuthClient) ExchangeCode(code, verifier string) (*OpenAICredentials, error) {
|
||||
return c.exchangeAuthorizationCode(code, verifier, c.RedirectURI)
|
||||
}
|
||||
|
||||
// exchangeAuthorizationCode performs the token exchange with the OAuth server
|
||||
func (c *OpenAIOAuthClient) exchangeAuthorizationCode(code, verifier, redirectUri string) (*OpenAICredentials, error) {
|
||||
data := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"client_id": {c.ClientID},
|
||||
"code": {code},
|
||||
"code_verifier": {verifier},
|
||||
"redirect_uri": {redirectUri},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), "POST", c.TokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to make token request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IDToken string `json:"id_token"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" || tokenResp.RefreshToken == "" {
|
||||
return nil, fmt.Errorf("token response missing required fields")
|
||||
}
|
||||
|
||||
// Extract account ID from JWT token
|
||||
accountID := extractOpenAIAccountID(tokenResp.AccessToken)
|
||||
if accountID == "" {
|
||||
return nil, fmt.Errorf("failed to extract account ID from token")
|
||||
}
|
||||
|
||||
return &OpenAICredentials{
|
||||
Type: "oauth",
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
ExpiresAt: time.Now().Unix() + int64(tokenResp.ExpiresIn),
|
||||
CreatedAt: time.Now(),
|
||||
AccountID: accountID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshToken refreshes an expired or expiring access token using a refresh token.
|
||||
// Returns new OpenAICredentials with updated access token, refresh token (may be
|
||||
// rotated), and new expiration timestamp. Returns an error if the refresh fails or
|
||||
// the refresh token is invalid.
|
||||
func (c *OpenAIOAuthClient) RefreshToken(refreshToken string) (*OpenAICredentials, error) {
|
||||
data := url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {refreshToken},
|
||||
"client_id": {c.ClientID},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), "POST", c.TokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to make refresh request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode refresh response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" || tokenResp.RefreshToken == "" {
|
||||
return nil, fmt.Errorf("refresh response missing required fields")
|
||||
}
|
||||
|
||||
// Extract account ID from JWT token
|
||||
accountID := extractOpenAIAccountID(tokenResp.AccessToken)
|
||||
if accountID == "" {
|
||||
return nil, fmt.Errorf("failed to extract account ID from refreshed token")
|
||||
}
|
||||
|
||||
return &OpenAICredentials{
|
||||
Type: "oauth",
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
ExpiresAt: time.Now().Unix() + int64(tokenResp.ExpiresIn),
|
||||
CreatedAt: time.Now(),
|
||||
AccountID: accountID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractOpenAIAccountID extracts the ChatGPT account ID from a JWT access token.
|
||||
// The account ID is stored in the claim path https://api.openai.com/auth.chatgpt_account_id
|
||||
func extractOpenAIAccountID(token string) string {
|
||||
// JWT tokens are base64-encoded JSON payloads
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Decode payload (second part)
|
||||
payload := parts[1]
|
||||
// Add padding if needed
|
||||
if len(payload)%4 != 0 {
|
||||
payload += strings.Repeat("=", 4-len(payload)%4)
|
||||
}
|
||||
|
||||
decoded, err := base64.URLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Navigate to the claim path: https://api.openai.com/auth.chatgpt_account_id
|
||||
authPath, ok := claims["https://api.openai.com/auth"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
accountID, ok := authPath["chatgpt_account_id"].(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return accountID
|
||||
}
|
||||
|
||||
// ParseOpenAIAuthorizationInput parses various forms of authorization input:
|
||||
// - Full callback URL: http://localhost:1455/auth/callback?code=xxx&state=yyy
|
||||
// - Code#State format: abc123#state456
|
||||
// - Query string: code=abc123&state=state456
|
||||
// - Just the code: abc123
|
||||
func ParseOpenAIAuthorizationInput(input string) (code, state string) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Try parsing as URL
|
||||
if strings.HasPrefix(input, "http") {
|
||||
if u, err := url.Parse(input); err == nil {
|
||||
return u.Query().Get("code"), u.Query().Get("state")
|
||||
}
|
||||
}
|
||||
|
||||
// Try code#state format
|
||||
if strings.Contains(input, "#") {
|
||||
parts := strings.SplitN(input, "#", 2)
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
|
||||
// Try query string format
|
||||
if strings.Contains(input, "code=") {
|
||||
if values, err := url.ParseQuery(input); err == nil {
|
||||
return values.Get("code"), values.Get("state")
|
||||
}
|
||||
}
|
||||
|
||||
// Assume it's just the code
|
||||
return input, ""
|
||||
}
|
||||
|
||||
// SetOAuthCredentials stores OAuth credentials in the credential manager's secure storage.
|
||||
// The credentials should include access token, refresh token, and expiration information.
|
||||
// Returns an error if the credentials cannot be saved.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+84
-36
@@ -105,42 +105,82 @@ 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"`
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
// CustomModelConfig defines a custom model that can be used with custom/custom
|
||||
// or other custom/ prefixed models. These models are loaded from the config file
|
||||
// and merged into the custom provider in the model registry.
|
||||
type CustomModelConfig struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Family string `json:"family,omitempty" yaml:"family,omitempty"`
|
||||
Attachment bool `json:"attachment,omitempty" yaml:"attachment,omitempty"`
|
||||
Reasoning bool `json:"reasoning,omitempty" yaml:"reasoning,omitempty"`
|
||||
Temperature bool `json:"temperature,omitempty" yaml:"temperature,omitempty"`
|
||||
Knowledge string `json:"knowledge,omitempty" yaml:"knowledge,omitempty"`
|
||||
Cost CostConfig `json:"cost" yaml:"cost"`
|
||||
Limit LimitConfig `json:"limit" yaml:"limit"`
|
||||
}
|
||||
|
||||
// CostConfig defines the pricing for a custom model.
|
||||
type CostConfig struct {
|
||||
Input float64 `json:"input" yaml:"input"`
|
||||
Output float64 `json:"output" yaml:"output"`
|
||||
}
|
||||
|
||||
// LimitConfig defines context and output limits for a custom model.
|
||||
type LimitConfig struct {
|
||||
Context int `json:"context" yaml:"context"`
|
||||
Output int `json:"output" yaml:"output"`
|
||||
}
|
||||
|
||||
// Config represents the complete application configuration including MCP servers,
|
||||
@@ -157,7 +197,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 +204,18 @@ 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"`
|
||||
|
||||
// Custom model definitions (under custom/ provider)
|
||||
CustomModels map[string]CustomModelConfig `json:"customModels,omitempty" yaml:"customModels,omitempty"`
|
||||
}
|
||||
|
||||
// GetTransportType returns the transport type for the server config, mapping
|
||||
@@ -370,11 +419,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)
|
||||
}
|
||||
}
|
||||
|
||||
+170
-12
@@ -1,16 +1,41 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// ToolOutputCallback is the signature for streaming tool output.
|
||||
// It receives tool call ID, tool name, output chunk, and whether it's stderr.
|
||||
type ToolOutputCallback func(toolCallID, toolName, chunk string, isStderr bool)
|
||||
|
||||
// contextKey is a custom type for context keys to avoid collisions.
|
||||
type contextKey string
|
||||
|
||||
const toolOutputCallbackKey contextKey = "toolOutputCallback"
|
||||
|
||||
// ContextWithToolOutputCallback returns a new context with the tool output callback set.
|
||||
func ContextWithToolOutputCallback(ctx context.Context, callback ToolOutputCallback) context.Context {
|
||||
return context.WithValue(ctx, toolOutputCallbackKey, callback)
|
||||
}
|
||||
|
||||
// toolOutputCallbackFromContext retrieves the tool output callback from context.
|
||||
func toolOutputCallbackFromContext(ctx context.Context) ToolOutputCallback {
|
||||
if cb, ok := ctx.Value(toolOutputCallbackKey).(ToolOutputCallback); ok {
|
||||
return cb
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const defaultBashTimeout = 120 * time.Second
|
||||
const maxBashTimeout = 600 * time.Second
|
||||
|
||||
@@ -90,32 +115,165 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
|
||||
cmd.Dir = workDir
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
// 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)
|
||||
|
||||
err := cmd.Run()
|
||||
// Get the output callback if present (for streaming support)
|
||||
outputCallback := toolOutputCallbackFromContext(ctx)
|
||||
|
||||
if outputCallback != nil {
|
||||
// Streaming mode: use pipes to capture output as it arrives
|
||||
return executeBashStreaming(cmdCtx, call, cmd, outputCallback)
|
||||
}
|
||||
|
||||
// Non-streaming mode: collect all output at once (original behavior)
|
||||
return executeBashBuffered(cmdCtx, call, cmd)
|
||||
}
|
||||
|
||||
// executeBashBuffered collects all output before returning (original behavior).
|
||||
// It uses explicit pipes (not cmd.Stdout) so that cmd.WaitDelay can forcibly
|
||||
// close them when grandchild processes hold pipe handles open after the
|
||||
// direct child exits.
|
||||
func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd) (fantasy.ToolResponse, error) {
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
|
||||
}
|
||||
|
||||
// Read pipes concurrently
|
||||
var wg sync.WaitGroup
|
||||
var stdout, stderr strings.Builder
|
||||
var stdoutErr, stderrErr error
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, stdoutErr = io.Copy(&stdout, stdoutPipe)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, stderrErr = io.Copy(&stderr, stderrPipe)
|
||||
}()
|
||||
|
||||
// Wait for the process to exit first. cmd.WaitDelay ensures that if
|
||||
// pipes remain open (held by grandchild processes), they'll be forcibly
|
||||
// closed after the grace period, which unblocks the io.Copy goroutines.
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
// Wait for pipe readers to finish draining.
|
||||
wg.Wait()
|
||||
|
||||
// Ignore pipe read errors caused by WaitDelay force-closing —
|
||||
// we still have whatever was read before the close.
|
||||
_ = stdoutErr
|
||||
_ = stderrErr
|
||||
|
||||
exitCode := 0
|
||||
if waitErr != nil {
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
return fantasy.NewTextErrorResponse("command timed out"), nil
|
||||
}
|
||||
}
|
||||
|
||||
return buildBashResponse(stdout.String(), stderr.String(), exitCode)
|
||||
}
|
||||
|
||||
// executeBashStreaming streams output as it arrives via the callback.
|
||||
func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, outputCallback ToolOutputCallback) (fantasy.ToolResponse, error) {
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
|
||||
}
|
||||
|
||||
// Start command execution
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
|
||||
}
|
||||
|
||||
// Stream stdout and stderr concurrently
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var stdoutChunks, stderrChunks []string
|
||||
|
||||
streamOutput := func(reader io.Reader, isStderr bool) {
|
||||
defer wg.Done()
|
||||
scanner := bufio.NewScanner(reader)
|
||||
// Use larger buffer for long lines
|
||||
buf := make([]byte, 0, 64*1024)
|
||||
scanner.Buffer(buf, 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
chunk := scanner.Text()
|
||||
// Send chunk to UI
|
||||
outputCallback(call.ID, "bash", chunk, isStderr)
|
||||
// Collect for final result
|
||||
mu.Lock()
|
||||
if isStderr {
|
||||
stderrChunks = append(stderrChunks, chunk)
|
||||
} else {
|
||||
stdoutChunks = append(stdoutChunks, chunk)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(2)
|
||||
go streamOutput(stdoutPipe, false)
|
||||
go streamOutput(stderrPipe, true)
|
||||
|
||||
// Wait for the process to exit. cmd.WaitDelay ensures that if pipes
|
||||
// remain open (held by grandchild processes), they'll be forcibly closed
|
||||
// after the grace period, which unblocks the scanners above.
|
||||
err = cmd.Wait()
|
||||
|
||||
// Wait for the pipe readers to finish draining. This will complete
|
||||
// quickly since cmd.Wait() (with WaitDelay) has already ensured
|
||||
// the pipes are closed.
|
||||
wg.Wait()
|
||||
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("command timed out after %v", timeout)), nil
|
||||
return fantasy.NewTextErrorResponse("command timed out"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Build result
|
||||
return buildBashResponse(strings.Join(stdoutChunks, "\n"), strings.Join(stderrChunks, "\n"), exitCode)
|
||||
}
|
||||
|
||||
// buildBashResponse constructs the final tool response from stdout/stderr.
|
||||
func buildBashResponse(stdout, stderr string, exitCode int) (fantasy.ToolResponse, error) {
|
||||
var result strings.Builder
|
||||
if stdout.Len() > 0 {
|
||||
result.WriteString(stdout.String())
|
||||
if stdout != "" {
|
||||
result.WriteString(stdout)
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
if stderr != "" {
|
||||
if result.Len() > 0 {
|
||||
result.WriteString("\n")
|
||||
}
|
||||
result.WriteString("STDERR:\n")
|
||||
result.WriteString(stderr.String())
|
||||
result.WriteString(stderr)
|
||||
}
|
||||
if exitCode != 0 {
|
||||
if result.Len() > 0 {
|
||||
@@ -130,7 +288,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
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// helper to create a bash tool call with the given command and optional timeout.
|
||||
func bashCall(command string, timeout float64) fantasy.ToolCall {
|
||||
args := map[string]any{"command": command}
|
||||
if timeout > 0 {
|
||||
args["timeout"] = timeout
|
||||
}
|
||||
input, _ := json.Marshal(args)
|
||||
return fantasy.ToolCall{
|
||||
ID: "test-call",
|
||||
Name: "bash",
|
||||
Input: string(input),
|
||||
}
|
||||
}
|
||||
|
||||
func TestBash_SimpleCommand(t *testing.T) {
|
||||
resp, err := executeBash(context.Background(), bashCall("echo hello", 0), "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("expected success, got error: %s", resp.Content)
|
||||
}
|
||||
if resp.Content != "hello\n" {
|
||||
t.Errorf("expected 'hello\\n', got %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBash_TimeoutKillsProcess(t *testing.T) {
|
||||
start := time.Now()
|
||||
resp, err := executeBash(context.Background(), bashCall("sleep 60", 2), "")
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Fatal("expected error response for timed-out command")
|
||||
}
|
||||
if elapsed > 10*time.Second {
|
||||
t.Errorf("command took %v, expected ~2s timeout", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBash_BackgroundProcessDoesNotHang(t *testing.T) {
|
||||
// This command spawns a background sleep that would hold pipes open
|
||||
// forever if we didn't have process group killing + WaitDelay.
|
||||
start := time.Now()
|
||||
resp, err := executeBash(context.Background(), bashCall("echo done; sleep 3600 &", 5), "")
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// The foreground command (echo) should complete quickly
|
||||
if elapsed > 5*time.Second {
|
||||
t.Errorf("command took %v, should complete in <5s (background process should not block)", elapsed)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("expected success, got error: %s", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBash_BackgroundProcessDoesNotHang_Streaming(t *testing.T) {
|
||||
// Same test but in streaming mode (with output callback).
|
||||
ctx := ContextWithToolOutputCallback(context.Background(), func(_, _, _ string, _ bool) {})
|
||||
start := time.Now()
|
||||
resp, err := executeBash(ctx, bashCall("echo streaming; sleep 3600 &", 5), "")
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if elapsed > 5*time.Second {
|
||||
t.Errorf("streaming command took %v, should complete in <5s", elapsed)
|
||||
}
|
||||
if resp.IsError {
|
||||
t.Fatalf("expected success, got error: %s", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBash_ContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, _ = executeBash(ctx, bashCall("sleep 60", 0), "")
|
||||
}()
|
||||
|
||||
// Cancel after a short delay
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
// Should return promptly after cancellation
|
||||
select {
|
||||
case <-done:
|
||||
// success
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("executeBash did not return after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBash_BannedCommand(t *testing.T) {
|
||||
resp, err := executeBash(context.Background(), bashCall("alias foo=bar", 0), "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Fatal("expected error for banned command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBash_EmptyCommand(t *testing.T) {
|
||||
resp, err := executeBash(context.Background(), bashCall("", 0), "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !resp.IsError {
|
||||
t.Fatal("expected error for empty command")
|
||||
}
|
||||
}
|
||||
+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,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
+1069
-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.
|
||||
@@ -19,6 +19,9 @@ const (
|
||||
// ToolExecutionEnd fires when a tool finishes executing.
|
||||
ToolExecutionEnd EventType = "tool_execution_end"
|
||||
|
||||
// ToolOutput fires when a tool produces streaming output chunks.
|
||||
ToolOutput EventType = "tool_output"
|
||||
|
||||
// ToolResult fires after a tool executes. Handlers can modify the result.
|
||||
ToolResult EventType = "tool_result"
|
||||
|
||||
@@ -48,6 +51,38 @@ 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"
|
||||
|
||||
// SubagentStart fires when a spawn_subagent tool call begins executing.
|
||||
// Carries the tool call ID and the task description.
|
||||
SubagentStart EventType = "subagent_start"
|
||||
|
||||
// SubagentChunk fires for each real-time event emitted by a running
|
||||
// subagent: text chunks, tool calls, tool results, etc.
|
||||
SubagentChunk EventType = "subagent_chunk"
|
||||
|
||||
// SubagentEnd fires when a spawn_subagent tool call completes (success
|
||||
// or error). Carries the final response and any error message.
|
||||
SubagentEnd EventType = "subagent_end"
|
||||
)
|
||||
|
||||
// AllEventTypes returns every supported event type.
|
||||
@@ -57,6 +92,9 @@ func AllEventTypes() []EventType {
|
||||
Input, BeforeAgentStart, AgentStart, AgentEnd,
|
||||
MessageStart, MessageUpdate, MessageEnd,
|
||||
SessionStart, SessionShutdown,
|
||||
ModelChange, ContextPrepare,
|
||||
BeforeFork, BeforeSessionSwitch, BeforeCompact,
|
||||
SubagentStart, SubagentChunk, SubagentEnd,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) != 21 {
|
||||
t.Fatalf("expected 21 event types, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,14 @@ 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},
|
||||
{SubagentStartEvent{ToolCallID: "x", Task: "t"}, SubagentStart},
|
||||
{SubagentChunkEvent{ToolCallID: "x", ChunkType: "text"}, SubagentChunk},
|
||||
{SubagentEndEvent{ToolCallID: "x"}, SubagentEnd},
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -214,6 +439,12 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onToolOutput: func(h func(ToolOutputEvent, Context)) {
|
||||
reg(ToolOutput, func(e Event, c Context) Result {
|
||||
h(e.(ToolOutputEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onToolResult: func(h func(ToolResultEvent, Context) *ToolResultResult) {
|
||||
reg(ToolResult, func(e Event, c Context) Result {
|
||||
r := h(e.(ToolResultEvent), c)
|
||||
@@ -283,6 +514,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 +565,39 @@ 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})
|
||||
},
|
||||
onSubagentStart: func(h func(SubagentStartEvent, Context)) {
|
||||
reg(SubagentStart, func(e Event, c Context) Result {
|
||||
h(e.(SubagentStartEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onSubagentChunk: func(h func(SubagentChunkEvent, Context)) {
|
||||
reg(SubagentChunk, func(e Event, c Context) Result {
|
||||
h(e.(SubagentChunkEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onSubagentEnd: func(h func(SubagentEndEvent, Context)) {
|
||||
reg(SubagentEnd, func(e Event, c Context) Result {
|
||||
h(e.(SubagentEndEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// 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,18 +90,50 @@ 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)),
|
||||
|
||||
// Subagent lifecycle events
|
||||
"SubagentStartEvent": reflect.ValueOf((*SubagentStartEvent)(nil)),
|
||||
"SubagentChunkEvent": reflect.ValueOf((*SubagentChunkEvent)(nil)),
|
||||
"SubagentEndEvent": reflect.ValueOf((*SubagentEndEvent)(nil)),
|
||||
|
||||
// Theme types
|
||||
"ThemeColor": reflect.ValueOf((*ThemeColor)(nil)),
|
||||
"ThemeColorConfig": reflect.ValueOf((*ThemeColorConfig)(nil)),
|
||||
|
||||
// Event structs
|
||||
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
|
||||
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
|
||||
"ToolExecutionStartEvent": reflect.ValueOf((*ToolExecutionStartEvent)(nil)),
|
||||
"ToolExecutionEndEvent": reflect.ValueOf((*ToolExecutionEndEvent)(nil)),
|
||||
"ToolOutputEvent": reflect.ValueOf((*ToolOutputEvent)(nil)),
|
||||
"ToolResultEvent": reflect.ValueOf((*ToolResultEvent)(nil)),
|
||||
"ToolResultResult": reflect.ValueOf((*ToolResultResult)(nil)),
|
||||
"InputEvent": reflect.ValueOf((*InputEvent)(nil)),
|
||||
@@ -84,6 +147,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,193 @@
|
||||
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
|
||||
})
|
||||
},
|
||||
onToolOutput: func(h func(ToolOutputEvent, Context)) {
|
||||
reg(ToolOutput, func(e Event, c Context) Result {
|
||||
h(e.(ToolOutputEvent), 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)
|
||||
},
|
||||
onSubagentStart: func(h func(SubagentStartEvent, Context)) {
|
||||
reg(SubagentStart, func(e Event, c Context) Result {
|
||||
h(e.(SubagentStartEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onSubagentChunk: func(h func(SubagentChunkEvent, Context)) {
|
||||
reg(SubagentChunk, func(e Event, c Context) Result {
|
||||
h(e.(SubagentChunkEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
onSubagentEnd: func(h func(SubagentEndEvent, Context)) {
|
||||
reg(SubagentEnd, func(e Event, c Context) Result {
|
||||
h(e.(SubagentEndEvent), c)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
+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,
|
||||
|
||||
@@ -4,11 +4,38 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// sanitizeToolCallID ensures the ID matches Anthropic's required pattern:
|
||||
// ^[a-zA-Z0-9_-]+$ (alphanumeric, underscores, and hyphens only).
|
||||
// Invalid characters are replaced with underscores.
|
||||
func sanitizeToolCallID(id string) string {
|
||||
var sb strings.Builder
|
||||
for _, r := range id {
|
||||
switch {
|
||||
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z'):
|
||||
sb.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
sb.WriteRune(r)
|
||||
case r == '_' || r == '-':
|
||||
sb.WriteRune(r)
|
||||
default:
|
||||
// Replace invalid characters with underscore
|
||||
sb.WriteByte('_')
|
||||
}
|
||||
}
|
||||
result := sb.String()
|
||||
// Ensure non-empty (Anthropic requires at least one character)
|
||||
if result == "" {
|
||||
return "tool_0"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ContentPart is the marker interface for all message content block types.
|
||||
// A message contains a heterogeneous slice of ContentPart values, enabling
|
||||
// rich structured messages that carry text, reasoning, tool calls, tool
|
||||
@@ -58,6 +85,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 +166,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 +218,7 @@ const (
|
||||
toolCallType partType = "tool_call"
|
||||
toolResultType partType = "tool_result"
|
||||
finishType partType = "finish"
|
||||
imageType partType = "image"
|
||||
)
|
||||
|
||||
type partWrapper struct {
|
||||
@@ -194,6 +243,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 +298,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)
|
||||
}
|
||||
@@ -282,7 +339,7 @@ func (m *Message) ToFantasyMessages() []fantasy.Message {
|
||||
// Add tool calls
|
||||
for _, tc := range m.ToolCalls() {
|
||||
parts = append(parts, fantasy.ToolCallPart{
|
||||
ToolCallID: tc.ID,
|
||||
ToolCallID: sanitizeToolCallID(tc.ID),
|
||||
ToolName: tc.Name,
|
||||
Input: tc.Input,
|
||||
})
|
||||
@@ -310,7 +367,7 @@ func (m *Message) ToFantasyMessages() []fantasy.Message {
|
||||
}
|
||||
}
|
||||
parts = append(parts, fantasy.ToolResultPart{
|
||||
ToolCallID: result.ToolCallID,
|
||||
ToolCallID: sanitizeToolCallID(result.ToolCallID),
|
||||
Output: output,
|
||||
})
|
||||
}
|
||||
@@ -323,13 +380,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 +457,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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user