Compare commits

..

12 Commits

Author SHA1 Message Date
Ed Zynda 03d78dfa65 fix: address review findings on SDK godoc and nil guard
- pkg/kit: remove internal package paths from exported godoc on
  ParseTemplate and the ToolKind* constants (SDK doc surface must not
  reference internal packages)
- internal/tools: guard marshalToolResult against a nil CallToolResult
  (json.Marshal(nil) succeeds as 'null', then result.IsError panics if
  a client returns nil result with nil error)

Skipped the TreeNode Children deep-copy suggestion: the slice already
comes from TreeManager.GetChildren which returns a fresh copy per call
into a throwaway intermediate, so no internal state is exposed.
2026-06-11 16:03:50 +03:00
Ed Zynda 086d9334c8 cmd: extract flag validation, preference restore, and provider-URL routing from runNormalMode
runNormalMode opened with ~150 lines of policy logic (flag-combination
validation, persisted model/thinking-level preference restoration, and
two subtle --provider-url model-rewrite rules). These are now standalone
functions (validateModeFlags, restorePersistedPreferences,
applyProviderURLRouting) so the routing policy is independently readable
and testable. Behaviour unchanged; ordering preserved.
2026-06-11 15:02:10 +03:00
Ed Zynda 187bb267a3 internal/ui: extract buildShareFile with defer-based cleanup
handleShareCommand repeated the close/remove/print/return cleanup chain
four times across its temp-file write error paths. File assembly now
lives in buildShareFile with a single deferred cleanup on error.
2026-06-11 15:00:49 +03:00
Ed Zynda e0909494ed internal/tools: extract withOAuthRetry and marshalToolResult helpers
ExecuteTool repeated the OAuth-error/re-auth/retry stanza verbatim twice
(sync and task-augmented paths) and the marshal-and-wrap stanza four
times. Both are now single helpers with identical error strings, so a
fix to OAuth retry or error categorization applies everywhere at once.
2026-06-11 14:59:37 +03:00
Ed Zynda d41608f931 extbridge: extract shared BaseContext for extension wiring
cmd/extension_context.go and internal/acpserver/session.go each built a
giant extensions.Context literal, duplicating ~15 delegation closures
(GetContextStats, GetMessages, AppendEntry, options, SetModel core,
Complete, SpawnSubagent, ...) that had to be kept in sync by hand. New
data-access fields had to be wired in both places or ACP-mode extensions
silently got nil function fields.

extbridge.BaseContext now provides the headless half; both call sites
overlay only their UI-specific closures. As a side effect ACP mode gains
previously-missing APIs (state, tree navigation, skills, template
parsing, model resolution) that were nil before. The interactive TUI
keeps its exact SetModel/ReloadExtensions ordering via overrides.
2026-06-11 14:58:05 +03:00
Ed Zynda cfd252523e internal/ui: extract switchModel helper for model-switch flow
The model-selector handler (ModelSelectedMsg) and /model slash command
duplicated the full switch sequence (thinking-level fallback, setModel,
display-state update, preference persistence, ModelChange emit) and had
already drifted in ordering. Both now call a single switchModel method.
Display state is still updated directly (no prog.Send from Update).
2026-06-11 14:53:52 +03:00
Ed Zynda e2f92b9515 Unify the two {{variable}} template engines
internal/skills and pkg/kit/template_bridge each had their own grammar:
skills rejected '{{ name }}' (whitespace) but allowed digit-first names;
the bridge was the opposite. A template behaved differently depending on
whether it was loaded as a skill prompt or via the extension API.

internal/skills is now the single engine using the superset grammar
(\{\{\s*(\w+)\s*\}\}); pkg/kit ParseTemplate/RenderTemplate are thin
adapters over it. Expand is now regex-based so whitespace placeholders
expand consistently; missing variables are still left as-is.
2026-06-11 14:51:52 +03:00
Ed Zynda dec04954e1 pkg/kit: consolidate model-path helpers and argument tokenizer
- ExtractModelFromPath mis-parsed model IDs containing '/' (e.g.
  'openrouter/meta/llama' -> 'meta'); it now delegates to
  RemoveProviderFromModel and is deprecated alongside
  ExtractProviderFromPath (-> GetCurrentProvider)
- parseFields delegated to prompts.ParseCommandArgs so extension argument
  parsing and builtin prompt-template parsing share one quote/escape
  grammar; ParseCommandArgs now also splits on tabs (superset of both
  previous tokenizers)
2026-06-11 14:50:18 +03:00
Ed Zynda 427675eed8 Consolidate Anthropic OAuth detection and usage-tracker refresh
The 'is the active Anthropic credential a stored OAuth token' check was
copy-pasted at 5 sites, all prefix-matching the magic string
'stored OAuth' produced in internal/auth. Now:

- internal/auth: new CredentialSourceOAuth constant + IsAnthropicOAuth()
- internal/ui: new UpdateUsageTrackerForModel(); CreateUsageTracker and
  SetupCLI share lookupTrackableModel (SetupCLI no longer re-inlines the
  tracker construction)
- cmd/root.go + cmd/extension_context.go: verbatim-duplicated tracker
  refresh blocks replaced with ui.UpdateUsageTrackerForModel
- pkg/kit isAnthropicOAuth delegates to auth.IsAnthropicOAuth
- internal/models compares source against the constant
2026-06-11 14:48:28 +03:00
Ed Zynda 60ed2a4964 Consolidate tool-kind classification into internal/extensions
coreToolKinds + toolKindFor were duplicated verbatim in
internal/extensions/wrapper.go and pkg/kit/events.go, risking silent
divergence between extension events and SDK events. Single source of
truth now lives in internal/extensions/toolkinds.go; pkg/kit re-exports
the constants.
2026-06-11 14:45:02 +03:00
Ed Zynda 31d32655f5 pkg/kit: use TreeManager alias in exported signatures
NewTreeManagerAdapter and InitTreeSession now spell their signatures with
the public kit.TreeManager alias instead of internal/session.TreeManager,
so go doc renders domain types rather than internal paths.
2026-06-11 14:43:12 +03:00
Ed Zynda 43af34fdcf Remove dead code: 5 unused symbols across internal packages
- internal/models: LoadModelSettingsFromConfig (zero refs)
- internal/prompts: PromptTemplate.ExpandWithArgs (zero refs)
- internal/app: NewMessageStore (tests migrated to NewMessageStoreWithMessages)
- internal/config: HasEnvVars (+ its test)
- internal/core: ContextWithSudoPassword (test migrated to context.WithValue)
2026-06-11 14:42:33 +03:00
81 changed files with 825 additions and 5792 deletions
-30
View File
@@ -39,36 +39,6 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Keep floating major/minor tags (e.g. v1, v1.2) pointing at the latest
# release so the composite action can be referenced as `mark3labs/kit@v1`.
action-tags:
runs-on: ubuntu-latest
needs: goreleaser
if: ${{ github.event_name == 'push' && needs.goreleaser.result == 'success' }}
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Update floating major/minor tags
env:
FULL_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# FULL_TAG looks like v1.2.3 — derive v1 and v1.2.
VER="${FULL_TAG#v}"
MAJOR="v${VER%%.*}"
MINOR="v${VER%.*}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
for t in "$MAJOR" "$MINOR"; do
echo "Pointing $t at $FULL_TAG"
git tag -f "$t" "$FULL_TAG"
git push -f origin "refs/tags/$t"
done
npm-publish:
runs-on: ubuntu-latest
needs: goreleaser
+4 -72
View File
@@ -28,7 +28,6 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
- **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
- **GitHub Integration**: Scaffold a GitHub Actions workflow with `kit github install` to run Kit as a collaborator/reviewer on `/kit` comments
- **ACP Server**: Run Kit as an [Agent Client Protocol](https://agentclientprotocol.com) agent over stdio
- **Go SDK**: Embed Kit in your own applications with full agent lifecycle events (30+ event types) and behavior-modifying hooks
@@ -129,14 +128,6 @@ temperature: 0.7
stream: true
thinking-level: off # off, none, minimal, low, medium, high
no-core-tools: false # set to true to disable all built-in core tools
# Skills — all keys are optional
no-skills: false # set to true to disable all skill loading
skill: # explicit skill files/dirs (disables auto-discovery)
- /path/to/skill.md
skills-dir: "" # scan this directory directly for skills (overrides auto-discovery)
skill-disable: # hide skills from the model catalog by name (still usable via /skill:)
- some-skill
```
All of the above keys can also be set programmatically via the SDK
@@ -212,12 +203,6 @@ mcpServers:
--prompt-template Load a specific prompt template by name
--no-prompt-templates Disable prompt template loading
# Skills
--skill Load skill file or directory (repeatable)
--skills-dir Scan this directory directly for skills (overrides auto-discovery)
--skill-disable Hide a skill from the model catalog by name (repeatable); still usable via /skill:
--no-skills Disable skill loading (auto-discovery and explicit)
# Generation parameters
--max-tokens Maximum tokens in response (default: 8192, auto-raised up to 32768 for models with larger known output limits)
--temperature Randomness 0.0-1.0 (default: 0.7)
@@ -264,12 +249,6 @@ kit install --uninstall <pkg> # Remove an installed package
# Skills
kit skill # Install the Kit extensions skill via skills.sh
# GitHub integration
kit github install # Scaffold .github/workflows/kit.yml (run Kit on '/kit' comments)
kit github install --model anthropic/claude-sonnet-4-5-20250929
kit github install --force # Overwrite an existing workflow file
kit github install --no-secret # Skip the offer to set the provider secret via the gh CLI
# ACP server
kit acp # Start as ACP agent (stdio JSON-RPC)
kit acp --debug # With debug logging to stderr
@@ -488,48 +467,6 @@ Placeholders inside fenced code blocks (```) and inline code spans are ignored.
Disable templates with `--no-prompt-templates` or load a specific template with `--prompt-template <name>`.
## GitHub Integration
Kit can run as an automated collaborator/reviewer inside GitHub Actions. The
`kit github install` command scaffolds a workflow that triggers when someone
comments `/kit ...` on an issue or pull request review, runs the agent
non-interactively in the runner, and lets it respond.
```bash
kit github install
```
This writes `.github/workflows/kit.yml`. By default the command prompts for the
model (pre-filled with a sensible default); pass `--model` to skip the prompt.
If the [`gh` CLI](https://cli.github.com/) is detected on your `PATH` and the
provider API key is present in your environment, you'll be offered the option to
store it as a repository secret automatically.
The generated workflow:
- Triggers only on `issue_comment` and `pull_request_review_comment` (`types: [created]`).
- Runs only when the comment begins with the `/kit` command token.
- Restricts triggers to repository owners, members, and collaborators (via `author_association`).
- Uses least-privilege `permissions` and `persist-credentials: false`.
- Authenticates git/PR operations with the built-in `secrets.GITHUB_TOKEN` and
the provider via a repository secret (e.g. `ANTHROPIC_API_KEY`).
After committing the workflow and setting the provider secret, comment
`/kit <your request>` on any issue or pull request to trigger Kit.
The generated workflow uses the bundled [`mark3labs/kit`](action.yml) composite
action, which installs the Kit binary and runs `kit github run`. That command
reads the triggering event, enforces permissions, reacts with an emoji, runs the
agent against the issue thread or pull request, posts the response as a comment,
and — if the agent changed files — pushes a `kit-agent[bot]` branch and opens a
pull request.
| Flag | Description |
| --- | --- |
| `--model` | Provider/model to write into the workflow |
| `--force` | Overwrite an existing workflow file |
| `--no-secret` | Skip the offer to set the provider secret via the `gh` CLI |
## Session Management
Kit uses a tree-based session model that supports branching and forking conversations.
@@ -694,10 +631,10 @@ host, err := kit.NewAgent(ctx,
Available options: `WithModel`, `WithSystemPrompt`, `WithStreaming`,
`WithMaxTokens`, `WithThinkingLevel`, `WithTools`, `WithExtraTools`,
`WithProviderAPIKey`, `WithProviderURL`, `WithConfigFile`, `WithDebug`,
`WithDebugLogger`, and `Ephemeral`. For advanced configuration not covered by
the helpers (custom MCP config, in-process MCP servers, session backends, MCP
task tuning) construct an `Options` value explicitly and call `kit.New`.
`WithProviderAPIKey`, `WithProviderURL`, `WithConfigFile`, `WithDebug`, and
`Ephemeral`. For advanced configuration not covered by the helpers (custom MCP
config, in-process MCP servers, session backends, MCP task tuning) construct an
`Options` value explicitly and call `kit.New`.
### Per-instance config isolation
@@ -893,11 +830,6 @@ host.AddContextFileContent(
host.RemoveSkill("polite-french")
host.RemoveContextFile(fmt.Sprintf("session://%s/AGENTS.md", userID))
// Hide a skill from the model catalog without unloading it (still usable
// via /skill:); EnableSkill reverses it.
host.DisableSkill("refund-policy")
host.EnableSkill("refund-policy")
// Or replace the whole set atomically.
host.SetSkills(activeSkillsForUser)
host.SetContextFiles(activeContextForUser)
-75
View File
@@ -1,75 +0,0 @@
name: "Kit"
description: "Run Kit as an automated collaborator/reviewer on GitHub issues and pull requests."
author: "mark3labs"
branding:
icon: "git-merge"
color: "purple"
inputs:
model:
description: "Provider/model Kit should use (e.g. anthropic/claude-sonnet-4-5-20250929). Defaults to Kit's built-in default."
required: false
default: ""
version:
description: "Kit version to install (e.g. v0.77.0). Defaults to the latest release."
required: false
default: "latest"
runs:
using: "composite"
steps:
- name: Install Kit
shell: bash
env:
KIT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
VERSION="${KIT_VERSION:-latest}"
if [ -z "$VERSION" ] || [ "$VERSION" = "latest" ]; then
VERSION="$(curl -fsSL https://api.github.com/repos/mark3labs/kit/releases/latest \
| grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)"
fi
if [ -z "$VERSION" ]; then
echo "::error::could not determine Kit version to install" >&2
exit 1
fi
VER="${VERSION#v}"
case "$(uname -s)" in
Linux) OS=linux ;;
Darwin) OS=darwin ;;
*) echo "::error::unsupported OS $(uname -s)" >&2; exit 1 ;;
esac
case "$(uname -m)" in
x86_64|amd64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) echo "::error::unsupported arch $(uname -m)" >&2; exit 1 ;;
esac
URL="https://github.com/mark3labs/kit/releases/download/${VERSION}/kit_${VER}_${OS}_${ARCH}.tar.gz"
echo "Installing Kit ${VERSION} from ${URL}"
TMP="$(mktemp -d)"
curl -fsSL "$URL" | tar -xz -C "$TMP"
mkdir -p "$HOME/.kit/bin"
mv "$TMP/kit" "$HOME/.kit/bin/kit"
chmod +x "$HOME/.kit/bin/kit"
echo "$HOME/.kit/bin" >> "$GITHUB_PATH"
rm -rf "$TMP"
- name: Verify Kit
shell: bash
run: kit --version
- name: Run Kit
shell: bash
env:
MODEL: ${{ inputs.model }}
run: |
set -euo pipefail
ARGS=()
if [ -n "${MODEL:-}" ]; then
ARGS+=(--model "$MODEL")
fi
kit github run ${ARGS[@]+"${ARGS[@]}"}
-3
View File
@@ -69,9 +69,6 @@ func buildInteractiveExtensionContext(deps extensionContextDeps) extensions.Cont
}
appInstance.RunWithFiles(text, parts)
}
ec.NewSession = func(prompt string) error {
return appInstance.RequestNewSessionFromExtension(prompt)
}
ec.GetSessionUsage = func() extensions.SessionUsage {
if usageTracker == nil {
return extensions.SessionUsage{}
-255
View File
@@ -1,255 +0,0 @@
package cmd
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"charm.land/huh/v2"
"github.com/charmbracelet/log"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/spf13/cobra"
)
// defaultGitHubModel is the model written into the generated workflow when the
// user does not specify one and runs non-interactively.
const defaultGitHubModel = "anthropic/claude-sonnet-4-5-20250929"
// githubWorkflowPath is the repository-relative location of the generated
// GitHub Actions workflow that wires Kit into a repository as a collaborator.
const githubWorkflowPath = ".github/workflows/kit.yml"
var (
githubInstallModel string
githubInstallForce bool
githubInstallNoSecret bool
)
// githubCmd is the parent command for GitHub integration subcommands. It groups
// the turnkey setup tooling that wires Kit into a repository as an automated
// collaborator/reviewer driven by GitHub Actions.
var githubCmd = &cobra.Command{
Use: "github",
Short: "Set up Kit as a GitHub collaborator/reviewer",
Long: `Set up Kit as an automated collaborator/reviewer in a GitHub repository.
Kit runs inside a GitHub Actions runner, reads the relevant context (an issue
thread or pull request), runs the agent non-interactively, and responds by
posting comments and opening pull requests.
Use 'kit github install' to scaffold the GitHub Actions workflow.`,
}
// githubInstallCmd scaffolds the GitHub Actions workflow that runs Kit on
// '/kit' comment triggers. It writes .github/workflows/kit.yml and, when the
// 'gh' CLI is available, offers to set the provider API key as a repository
// secret.
var githubInstallCmd = &cobra.Command{
Use: "install",
Short: "Scaffold the GitHub Actions workflow that runs Kit",
Long: `Scaffold the GitHub Actions workflow that runs Kit as a collaborator.
This writes .github/workflows/kit.yml configured to trigger when someone
comments '/kit ...' on an issue or pull request review. The workflow runs Kit
inside an ephemeral Actions runner with least-privilege permissions and
'persist-credentials: false', mirroring established security practice.
If the GitHub CLI ('gh') is detected on your PATH, you will be offered the
option to store your provider API key as a repository secret automatically.
Flags:
--model Provider/model to write into the workflow (e.g. anthropic/claude-sonnet-4-5)
--force Overwrite an existing workflow file
--no-secret Skip the offer to set the provider secret via the gh CLI
Examples:
kit github install
kit github install --model anthropic/claude-sonnet-4-5-20250929
kit github install --force --no-secret`,
Args: cobra.NoArgs,
RunE: runGitHubInstall,
}
func init() {
githubInstallCmd.Flags().StringVarP(&githubInstallModel, "model", "m", "", "provider/model to write into the workflow")
githubInstallCmd.Flags().BoolVar(&githubInstallForce, "force", false, "overwrite an existing workflow file")
githubInstallCmd.Flags().BoolVar(&githubInstallNoSecret, "no-secret", false, "skip setting the provider secret via the gh CLI")
githubCmd.AddCommand(githubInstallCmd)
rootCmd.AddCommand(githubCmd)
}
func runGitHubInstall(cmd *cobra.Command, _ []string) error {
model, err := resolveGitHubModel()
if err != nil {
return err
}
provider, _, err := kit.ParseModelString(model)
if err != nil {
return fmt.Errorf("invalid model %q: %w", model, err)
}
secretName := providerSecretEnvVar(provider)
if err := writeGitHubWorkflow(model, secretName, githubInstallForce); err != nil {
return err
}
fmt.Printf("✅ Wrote %s\n", githubWorkflowPath)
maybeSetProviderSecret(cmd.Context(), secretName)
printGitHubInstallNextSteps(secretName)
log.Info("github workflow scaffolded", "model", model, "secret", secretName)
return nil
}
// resolveGitHubModel determines the model to embed in the workflow. The
// --model flag takes precedence; otherwise an interactive prompt is shown
// (pre-filled with the default), and non-interactive runs use the default.
func resolveGitHubModel() (string, error) {
if githubInstallModel != "" {
return strings.TrimSpace(githubInstallModel), nil
}
if !isInteractive() {
return defaultGitHubModel, nil
}
model := defaultGitHubModel
err := huh.NewInput().
Title("Model").
Description("Provider/model Kit should use in CI (e.g. anthropic/claude-sonnet-4-5)").
Value(&model).
Run()
if err != nil {
return "", fmt.Errorf("model selection cancelled: %w", err)
}
model = strings.TrimSpace(model)
if model == "" {
return "", fmt.Errorf("model cannot be empty")
}
return model, nil
}
// providerSecretEnvVar returns the environment variable / repository secret
// name that holds the API key for the given provider. It consults the model
// registry and falls back to "<PROVIDER>_API_KEY" for unknown providers.
func providerSecretEnvVar(provider string) string {
if info := kit.GetProviderInfo(provider); info != nil && len(info.Env) > 0 {
return info.Env[0]
}
sanitized := strings.ToUpper(strings.NewReplacer("-", "_", ".", "_").Replace(provider))
return sanitized + "_API_KEY"
}
// renderGitHubWorkflow builds the workflow YAML for the given model and
// provider secret name.
func renderGitHubWorkflow(model, secretName string) string {
return fmt.Sprintf(`name: kit
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
kit:
if: |
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR') &&
(startsWith(github.event.comment.body, '/kit ') ||
github.event.comment.body == '/kit')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: mark3labs/kit@v0
with:
model: %s
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
%s: ${{ secrets.%s }}
`, model, secretName, secretName)
}
// writeGitHubWorkflow writes the generated workflow to githubWorkflowPath,
// creating parent directories as needed. It refuses to overwrite an existing
// file unless force is true.
func writeGitHubWorkflow(model, secretName string, force bool) error {
if _, err := os.Stat(githubWorkflowPath); err == nil && !force {
return fmt.Errorf("%s already exists; re-run with --force to overwrite", githubWorkflowPath)
} else if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("checking %s: %w", githubWorkflowPath, err)
}
if err := os.MkdirAll(filepath.Dir(githubWorkflowPath), 0o755); err != nil {
return fmt.Errorf("creating %s: %w", filepath.Dir(githubWorkflowPath), err)
}
content := renderGitHubWorkflow(model, secretName)
if err := os.WriteFile(githubWorkflowPath, []byte(content), 0o644); err != nil {
return fmt.Errorf("writing %s: %w", githubWorkflowPath, err)
}
return nil
}
// maybeSetProviderSecret offers to set the provider API key as a repository
// secret via the gh CLI when it is available, interactive, the secret value is
// present in the environment, and the user did not pass --no-secret.
func maybeSetProviderSecret(ctx context.Context, secretName string) {
if githubInstallNoSecret || !isInteractive() {
return
}
if _, err := exec.LookPath("gh"); err != nil {
return
}
value := os.Getenv(secretName)
if value == "" {
fmt.Printf("️ %s is not set in your environment; set the repository secret manually with:\n", secretName)
fmt.Printf(" gh secret set %s\n", secretName)
return
}
var confirm bool
if err := huh.NewConfirm().
Title(fmt.Sprintf("Set the %s repository secret via gh?", secretName)).
Description("Uses the value from your current environment.").
Value(&confirm).
Run(); err != nil || !confirm {
return
}
// Feed the secret value via stdin rather than a command-line argument so
// the API key never appears in the process argument list.
cmd := exec.CommandContext(ctx, "gh", "secret", "set", secretName)
cmd.Stdin = strings.NewReader(value)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("⚠️ Failed to set secret via gh: %v\n", err)
fmt.Printf(" Set it manually with: gh secret set %s\n", secretName)
return
}
fmt.Printf("✅ Set repository secret %s\n", secretName)
}
// printGitHubInstallNextSteps prints the manual follow-up actions a user must
// take after the workflow is scaffolded.
func printGitHubInstallNextSteps(secretName string) {
fmt.Println("\nNext steps:")
fmt.Printf(" 1. Commit the workflow: git add %s && git commit -m \"ci: add kit workflow\"\n", githubWorkflowPath)
fmt.Printf(" 2. Set the %s repository secret (Settings → Secrets → Actions), if not already set.\n", secretName)
fmt.Println(" 3. Comment '/kit <your request>' on an issue or pull request to trigger Kit.")
}
-498
View File
@@ -1,498 +0,0 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/charmbracelet/log"
"github.com/spf13/cobra"
)
// commandToken is the mention that triggers Kit from a comment, mirroring the
// `if:` guard in the generated workflow (.github/workflows/kit.yml).
const commandToken = "/kit"
// subprocessTimeout bounds each git/gh invocation so a stalled network call or
// an unexpected auth prompt cannot hang the Actions job indefinitely.
const subprocessTimeout = 30 * time.Second
// agentTimeout bounds the headless agent run so a runaway turn cannot block the
// job forever. GitHub Actions jobs have their own ceiling, but a tighter bound
// keeps feedback fast and costs predictable.
const agentTimeout = 20 * time.Minute
// botName / botEmail are the dedicated identity commits are attributed to, so
// Kit's changes are clearly distinguishable from human authors in history.
const (
botName = "kit-agent[bot]"
botEmail = "kit-agent[bot]@users.noreply.github.com"
)
// writeAssociations are the GitHub author_association values that imply
// write/admin access. Only these may trigger the handler.
var writeAssociations = map[string]bool{
"OWNER": true,
"MEMBER": true,
"COLLABORATOR": true,
}
var (
githubRunModel string
githubRunDryRun bool
)
// githubRunCmd is the runtime half of the GitHub integration. It is invoked by
// the bundled composite action (action.yml) inside a GitHub Actions runner once
// a collaborator comments '/kit <request>' on an issue or pull request. It reads
// the triggering event, enforces permissions, runs the agent headlessly against
// the comment/PR context, and responds by posting a comment and — when the agent
// leaves changes — opening a pull request.
var githubRunCmd = &cobra.Command{
Use: "run",
Short: "Run Kit against the current GitHub Actions event (used by the kit action)",
Long: `Run Kit against the current GitHub Actions event.
This command is normally invoked by the bundled composite action inside a
GitHub Actions runner; you rarely run it by hand. It reads the triggering
event from GITHUB_EVENT_PATH, verifies the commenter has write/admin access,
reacts with an emoji while it works, runs the agent non-interactively against
the issue thread or pull request, posts the response as a comment, and — if the
agent modified files — pushes a kit-agent[bot] branch and opens a pull request.
Set --dry-run (or KIT_GITHUB_DRY_RUN=1) to log every git/gh side effect and
skip the agent run instead of executing them.`,
Args: cobra.NoArgs,
RunE: runGitHubRun,
}
func init() {
githubRunCmd.Flags().StringVarP(&githubRunModel, "model", "m", "", "provider/model the agent should use (falls back to $MODEL, then a default)")
githubRunCmd.Flags().BoolVar(&githubRunDryRun, "dry-run", false, "log git/gh side effects and skip the agent run instead of executing them")
githubCmd.AddCommand(githubRunCmd)
}
// --- GitHub event types ------------------------------------------------------
type ghUser struct {
Login string `json:"login"`
}
type ghComment struct {
ID int64 `json:"id"`
Body string `json:"body"`
AuthorAssociation string `json:"author_association"`
User ghUser `json:"user"`
}
type ghIssue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
PullRequest json.RawMessage `json:"pull_request"`
}
type ghPull struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
}
type ghRepo struct {
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
}
type ghEvent struct {
Action string `json:"action"`
Comment *ghComment `json:"comment"`
Issue *ghIssue `json:"issue"`
PullRequest *ghPull `json:"pull_request"`
Repository ghRepo `json:"repository"`
}
// trigger normalises a single invocation across issue_comment and
// pull_request_review_comment events.
type trigger struct {
repo string
defaultBranch string
number int // issue or PR number
isPR bool // true when the target is a pull request
commentID int64 // triggering comment id (for reactions)
commentKind string // "issues" or "pulls" — reaction API path segment
author string
association string
request string // the user's instruction (comment body minus the token)
title string
body string
}
// runGitHubRun is the entry point wired to `kit github run`.
func runGitHubRun(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
if !inGitHubActions() && !githubDryRun() {
return fmt.Errorf("kit github run is meant to run inside GitHub Actions (set GITHUB_ACTIONS=true or pass --dry-run)")
}
event, err := loadGitHubEvent()
if err != nil {
return err
}
tr, err := buildTrigger(event)
if err != nil {
// Not an actionable trigger (the workflow `if:` normally prevents this).
log.Info("github run: nothing to do", "reason", err)
return nil
}
if !writeAssociations[strings.ToUpper(tr.association)] {
log.Warn("github run: ignoring /kit from unauthorized author",
"author", tr.author, "association", tr.association)
return nil
}
model := resolveRunModel()
log.Info("github run: handling trigger",
"repo", tr.repo, "number", tr.number, "pr", tr.isPR, "author", tr.author, "model", model)
// React with 👀 so the human sees Kit picked up the request.
addReaction(ctx, tr, "eyes")
gathered := gatherContext(ctx, tr)
prompt := buildPrompt(tr, gathered)
response, runErr := runAgent(ctx, model, prompt)
if runErr != nil {
postComment(ctx, tr, "⚠️ Kit hit an error while processing this request:\n\n```\n"+runErr.Error()+"\n```")
addReaction(ctx, tr, "confused")
return runErr
}
response = strings.TrimSpace(response)
if response == "" {
response = "Kit finished without a textual response."
}
prURL := ""
if hasUncommittedChanges(ctx) {
prURL = openPullRequest(ctx, tr, response)
}
comment := response
if prURL != "" {
comment += "\n\n---\nOpened a pull request with the changes: " + prURL
}
postComment(ctx, tr, comment)
addReaction(ctx, tr, "rocket")
return nil
}
// resolveRunModel picks the model: --model flag, then $MODEL, then the default.
func resolveRunModel() string {
if m := strings.TrimSpace(githubRunModel); m != "" {
return m
}
if m := strings.TrimSpace(os.Getenv("MODEL")); m != "" {
return m
}
return defaultGitHubModel
}
func inGitHubActions() bool {
return os.Getenv("GITHUB_ACTIONS") == "true"
}
// githubDryRun reports whether side effects should be logged instead of run.
func githubDryRun() bool {
return githubRunDryRun || os.Getenv("KIT_GITHUB_DRY_RUN") != ""
}
// loadGitHubEvent reads and decodes the GitHub Actions event payload.
func loadGitHubEvent() (*ghEvent, error) {
path := os.Getenv("GITHUB_EVENT_PATH")
if path == "" {
return nil, fmt.Errorf("GITHUB_EVENT_PATH is not set")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading event payload: %w", err)
}
var event ghEvent
if err := json.Unmarshal(data, &event); err != nil {
return nil, fmt.Errorf("parsing event payload: %w", err)
}
return &event, nil
}
// buildTrigger normalises an event into a trigger, or returns an error when the
// event is not an actionable `/kit` comment.
func buildTrigger(event *ghEvent) (*trigger, error) {
if event.Comment == nil {
return nil, fmt.Errorf("event has no comment; nothing to do")
}
request, ok := extractRequest(event.Comment.Body)
if !ok {
return nil, fmt.Errorf("comment does not contain the %q command", commandToken)
}
tr := &trigger{
repo: event.Repository.FullName,
defaultBranch: event.Repository.DefaultBranch,
commentID: event.Comment.ID,
author: event.Comment.User.Login,
association: event.Comment.AuthorAssociation,
request: request,
}
if tr.defaultBranch == "" {
tr.defaultBranch = "main"
}
switch {
case event.Issue != nil:
tr.number = event.Issue.Number
tr.title = event.Issue.Title
tr.body = event.Issue.Body
tr.isPR = len(event.Issue.PullRequest) > 0
tr.commentKind = "issues"
case event.PullRequest != nil:
tr.number = event.PullRequest.Number
tr.title = event.PullRequest.Title
tr.body = event.PullRequest.Body
tr.isPR = true
tr.commentKind = "pulls"
default:
return nil, fmt.Errorf("event has no issue or pull_request target")
}
if tr.repo == "" {
return nil, fmt.Errorf("event is missing repository.full_name")
}
return tr, nil
}
// extractRequest pulls the instruction text out of a comment body that mentions
// the command token. It only recognizes the token at the start of a line
// (mirroring the workflow guard) or at the very end, so incidental mid-sentence
// mentions like "please review /kit behavior" do not trigger the handler. It
// returns the remainder of the matching line as the request.
func extractRequest(body string) (string, bool) {
for line := range strings.SplitSeq(body, "\n") {
trimmed := strings.TrimSpace(line)
var rest string
switch {
case trimmed == commandToken:
return "", true
case strings.HasPrefix(trimmed, commandToken+" "):
rest = trimmed[len(commandToken):]
case strings.HasSuffix(trimmed, " "+commandToken):
return "", true
default:
continue
}
return strings.TrimSpace(rest), true
}
return "", false
}
// gatherContext assembles the issue thread or PR diff to give the agent. It
// always includes the title/body from the event payload, and — outside dry-run,
// when `gh` is available — enriches with the comment thread and PR diff.
func gatherContext(ctx context.Context, tr *trigger) string {
var b strings.Builder
target := "Issue"
if tr.isPR {
target = "Pull request"
}
fmt.Fprintf(&b, "%s #%d: %s\n", target, tr.number, tr.title)
if strings.TrimSpace(tr.body) != "" {
fmt.Fprintf(&b, "\n%s\n", strings.TrimSpace(tr.body))
}
if githubDryRun() || !commandExists("gh") {
return b.String()
}
num := fmt.Sprint(tr.number)
if tr.isPR {
if diff := ghOutput(ctx, "pr", "diff", num, "--repo", tr.repo); diff != "" {
fmt.Fprintf(&b, "\n## Diff\n```diff\n%s\n```\n", strings.TrimSpace(diff))
}
if comments := ghOutput(ctx, "pr", "view", num, "--repo", tr.repo, "--json", "comments", "--jq", ".comments[] | \"@\\(.author.login): \\(.body)\""); comments != "" {
fmt.Fprintf(&b, "\n## Comments\n%s\n", strings.TrimSpace(comments))
}
} else {
if comments := ghOutput(ctx, "issue", "view", num, "--repo", tr.repo, "--json", "comments", "--jq", ".comments[] | \"@\\(.author.login): \\(.body)\""); comments != "" {
fmt.Fprintf(&b, "\n## Comments\n%s\n", strings.TrimSpace(comments))
}
}
return b.String()
}
// buildPrompt constructs the instruction sent to the agent.
func buildPrompt(tr *trigger, gathered string) string {
target := "issue"
if tr.isPR {
target = "pull request"
}
request := tr.request
if request == "" {
request = "(no explicit instruction — review the " + target + " and respond helpfully)"
}
var b strings.Builder
fmt.Fprintf(&b, "You are Kit, operating as an automated collaborator on the GitHub repository %s.\n\n", tr.repo)
fmt.Fprintf(&b, "@%s (access: %s) triggered you on %s #%d with this request:\n\n", tr.author, tr.association, target, tr.number)
fmt.Fprintf(&b, "%s\n\n", request)
fmt.Fprintf(&b, "## Context\n%s\n\n", strings.TrimSpace(gathered))
b.WriteString("Carry out the request. If you modify files, they will be committed to a new ")
b.WriteString("branch and a pull request will be opened automatically, so you do not need to ")
b.WriteString("commit or push yourself. Finish with a concise summary of what you did.")
return b.String()
}
// runAgent drives the agent headlessly by invoking this same binary in quiet,
// ephemeral mode against the constructed prompt, and returns its response. In
// dry-run it returns a canned response without spawning anything.
func runAgent(ctx context.Context, model, prompt string) (string, error) {
if githubDryRun() {
log.Info("github run: [dry-run] would run agent", "model", model, "promptChars", len(prompt))
return "[dry-run] agent response", nil
}
exe, err := os.Executable()
if err != nil || exe == "" {
exe = "kit"
}
runCtx, cancel := context.WithTimeout(ctx, agentTimeout)
defer cancel()
args := []string{"--quiet", "--no-session", "--no-extensions"}
if model != "" {
args = append(args, "--model", model)
}
args = append(args, prompt)
cmd := exec.CommandContext(runCtx, exe, args...)
cmd.Stderr = os.Stderr // surface agent progress/errors in the Actions log
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("agent run failed: %w", err)
}
return string(out), nil
}
// hasUncommittedChanges reports whether the agent produced working-tree changes.
func hasUncommittedChanges(ctx context.Context) bool {
if githubDryRun() {
return os.Getenv("KIT_GITHUB_FAKE_DIRTY") != ""
}
return strings.TrimSpace(gitOutput(ctx, "status", "--porcelain")) != ""
}
// openPullRequest commits the working tree as kit-agent[bot], pushes a branch,
// and opens a PR. It returns the PR URL, or "" on failure / dry-run.
func openPullRequest(ctx context.Context, tr *trigger, summary string) string {
branch := fmt.Sprintf("kit/issue-%d-%d", tr.number, time.Now().Unix())
runGit(ctx, "checkout", "-b", branch)
runGit(ctx, "add", "-A")
runGit(ctx, "-c", "user.name="+botName, "-c", "user.email="+botEmail,
"commit", "-m", fmt.Sprintf("kit: address #%d", tr.number))
// `persist-credentials: false` in the workflow means the checkout left no
// push credentials behind. Re-establish them from GITHUB_TOKEN via gh's git
// credential helper, then push over the existing origin remote.
if !githubDryRun() {
runCmd(ctx, "gh", "auth", "setup-git")
}
runGit(ctx, "push", "origin", "HEAD:"+branch)
title := fmt.Sprintf("kit: changes for #%d", tr.number)
body := fmt.Sprintf("Automated changes from Kit in response to #%d.\n\n%s", tr.number, summary)
if githubDryRun() {
log.Info("github run: [dry-run] would open PR", "branch", branch, "base", tr.defaultBranch)
return ""
}
return strings.TrimSpace(ghOutput(ctx, "pr", "create", "--repo", tr.repo,
"--head", branch, "--base", tr.defaultBranch, "--title", title, "--body", body))
}
// addReaction adds an emoji reaction to the trigger comment.
func addReaction(ctx context.Context, tr *trigger, content string) {
path := fmt.Sprintf("/repos/%s/%s/comments/%d/reactions", tr.repo, tr.commentKind, tr.commentID)
if githubDryRun() || !commandExists("gh") {
log.Info("github run: [dry-run] react", "content", content, "path", path)
return
}
runCmd(ctx, "gh", "api", "-X", "POST", path, "-f", "content="+content)
}
// postComment posts a comment back on the triggering issue or pull request.
func postComment(ctx context.Context, tr *trigger, body string) {
sub := "issue"
if tr.isPR {
sub = "pr"
}
if githubDryRun() || !commandExists("gh") {
log.Info("github run: [dry-run] comment", "sub", sub, "number", tr.number, "chars", len(body))
return
}
runCmd(ctx, "gh", sub, "comment", fmt.Sprint(tr.number), "--repo", tr.repo, "--body", body)
}
// --- thin subprocess helpers -------------------------------------------------
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
// runGit runs a mutating git command, logging instead of executing in dry-run.
func runGit(ctx context.Context, args ...string) {
if githubDryRun() {
log.Info("github run: [dry-run] git", "args", strings.Join(args, " "))
return
}
runCmd(ctx, "git", args...)
}
// gitOutput runs a read-only git command and returns its stdout.
func gitOutput(ctx context.Context, args ...string) string {
cmdCtx, cancel := context.WithTimeout(ctx, subprocessTimeout)
defer cancel()
out, err := exec.CommandContext(cmdCtx, "git", args...).Output()
if err != nil {
log.Error("github run: git failed", "args", strings.Join(args, " "), "err", err)
return ""
}
return string(out)
}
// ghOutput runs a gh command and returns its stdout.
func ghOutput(ctx context.Context, args ...string) string {
cmdCtx, cancel := context.WithTimeout(ctx, subprocessTimeout)
defer cancel()
out, err := exec.CommandContext(cmdCtx, "gh", args...).Output()
if err != nil {
log.Error("github run: gh failed", "args", strings.Join(args, " "), "err", err)
return ""
}
return string(out)
}
// runCmd runs a command for its side effects, surfacing failures in the log.
func runCmd(ctx context.Context, name string, args ...string) {
cmdCtx, cancel := context.WithTimeout(ctx, subprocessTimeout)
defer cancel()
if out, err := exec.CommandContext(cmdCtx, name, args...).CombinedOutput(); err != nil {
log.Error("github run: command failed", "cmd", name, "err", err, "output", strings.TrimSpace(string(out)))
}
}
-190
View File
@@ -1,190 +0,0 @@
package cmd
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// setupEvent writes a GitHub event payload to a temp file, points
// GITHUB_EVENT_PATH at it, and forces dry-run + Actions mode. It also resets
// the run command's package-level flag state so tests are independent.
func setupEvent(t *testing.T, payload string) {
t.Helper()
path := filepath.Join(t.TempDir(), "event.json")
if err := os.WriteFile(path, []byte(payload), 0o644); err != nil {
t.Fatalf("write event: %v", err)
}
t.Setenv("GITHUB_ACTIONS", "true")
t.Setenv("KIT_GITHUB_DRY_RUN", "1")
t.Setenv("GITHUB_EVENT_PATH", path)
t.Cleanup(func() {
githubRunModel = ""
githubRunDryRun = false
})
}
const issueCommentEvent = `{
"action": "created",
"comment": {
"id": 555,
"body": "/kit fix the broken parser",
"author_association": "OWNER",
"user": {"login": "alice"}
},
"issue": {"number": 42, "title": "Parser crashes on empty input", "body": "It panics."},
"repository": {"full_name": "acme/widgets", "default_branch": "main"}
}`
func TestExtractRequest(t *testing.T) {
tests := []struct {
name string
body string
want string
wantHit bool
}{
{"start with request", "/kit fix the bug", "fix the bug", true},
{"bare token", "/kit", "", true},
{"trailing token", "hey /kit", "", true},
{"mid-sentence ignored", "please review /kit behavior in the docs", "", false},
{"no token", "just a normal comment", "", false},
{"token in second line", "thanks!\n/kit add tests", "add tests", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, hit := extractRequest(tt.body)
if hit != tt.wantHit || got != tt.want {
t.Errorf("extractRequest(%q) = (%q, %v), want (%q, %v)", tt.body, got, hit, tt.want, tt.wantHit)
}
})
}
}
func TestBuildTrigger_IssueComment(t *testing.T) {
event, err := func() (*ghEvent, error) {
setupEvent(t, issueCommentEvent)
return loadGitHubEvent()
}()
if err != nil {
t.Fatalf("loadGitHubEvent: %v", err)
}
tr, err := buildTrigger(event)
if err != nil {
t.Fatalf("buildTrigger: %v", err)
}
if tr.repo != "acme/widgets" || tr.number != 42 || tr.isPR || tr.request != "fix the broken parser" {
t.Errorf("unexpected trigger: %+v", tr)
}
if tr.commentKind != "issues" {
t.Errorf("commentKind = %q, want issues", tr.commentKind)
}
}
func TestBuildPrompt_ContainsContext(t *testing.T) {
setupEvent(t, issueCommentEvent)
event, _ := loadGitHubEvent()
tr, _ := buildTrigger(event)
prompt := buildPrompt(tr, gatherContext(context.Background(), tr))
for _, want := range []string{
"fix the broken parser", // the request
"acme/widgets", // the repo
"issue #42", // the target
"@alice", // the author
"Parser crashes on empty input", // context: title
"It panics.", // context: body
} {
if !strings.Contains(prompt, want) {
t.Errorf("prompt missing %q\n---\n%s", want, prompt)
}
}
}
func TestRunGitHub_AuthorizedIssueComment(t *testing.T) {
setupEvent(t, issueCommentEvent)
if err := runGitHubRun(githubRunCmd, nil); err != nil {
t.Fatalf("runGitHubRun: %v", err)
}
}
func TestRunGitHub_UnauthorizedAssociation(t *testing.T) {
setupEvent(t, strings.Replace(issueCommentEvent, `"OWNER"`, `"NONE"`, 1))
// Should return nil (no-op) without attempting the agent run.
if err := runGitHubRun(githubRunCmd, nil); err != nil {
t.Fatalf("runGitHubRun should be a no-op for unauthorized authors, got: %v", err)
}
}
func TestRunGitHub_CommentWithoutToken(t *testing.T) {
setupEvent(t, strings.Replace(issueCommentEvent,
`"/kit fix the broken parser"`, `"just a normal comment"`, 1))
if err := runGitHubRun(githubRunCmd, nil); err != nil {
t.Fatalf("runGitHubRun should be a no-op without /kit, got: %v", err)
}
}
func TestRunGitHub_MidSentenceMentionIgnored(t *testing.T) {
setupEvent(t, strings.Replace(issueCommentEvent,
`"/kit fix the broken parser"`, `"please review /kit behavior in the docs"`, 1))
if err := runGitHubRun(githubRunCmd, nil); err != nil {
t.Fatalf("runGitHubRun should ignore mid-sentence mentions, got: %v", err)
}
}
func TestRunGitHub_PullRequestReviewComment(t *testing.T) {
setupEvent(t, `{
"action": "created",
"comment": {
"id": 999,
"body": "/kit review this change",
"author_association": "COLLABORATOR",
"user": {"login": "bob"}
},
"pull_request": {"number": 7, "title": "Add caching", "body": "Speeds things up."},
"repository": {"full_name": "acme/widgets", "default_branch": "main"}
}`)
event, _ := loadGitHubEvent()
tr, err := buildTrigger(event)
if err != nil {
t.Fatalf("buildTrigger: %v", err)
}
if !tr.isPR || tr.number != 7 || tr.commentKind != "pulls" {
t.Errorf("unexpected PR trigger: %+v", tr)
}
if err := runGitHubRun(githubRunCmd, nil); err != nil {
t.Fatalf("runGitHubRun (PR): %v", err)
}
}
func TestRunGitHub_RequiresActionsOrDryRun(t *testing.T) {
// Neither GITHUB_ACTIONS nor dry-run set → must error rather than act.
t.Setenv("GITHUB_ACTIONS", "")
t.Setenv("KIT_GITHUB_DRY_RUN", "")
githubRunDryRun = false
t.Cleanup(func() { githubRunDryRun = false })
if err := runGitHubRun(githubRunCmd, nil); err == nil {
t.Fatal("expected an error when run outside Actions without --dry-run")
}
}
func TestResolveRunModel(t *testing.T) {
t.Cleanup(func() { githubRunModel = "" })
t.Setenv("MODEL", "")
githubRunModel = ""
if got := resolveRunModel(); got != defaultGitHubModel {
t.Errorf("default model = %q, want %q", got, defaultGitHubModel)
}
t.Setenv("MODEL", "openai/gpt-5")
if got := resolveRunModel(); got != "openai/gpt-5" {
t.Errorf("MODEL env model = %q, want openai/gpt-5", got)
}
githubRunModel = "anthropic/claude-sonnet-4-5"
if got := resolveRunModel(); got != "anthropic/claude-sonnet-4-5" {
t.Errorf("flag model = %q, want anthropic/claude-sonnet-4-5", got)
}
}
-102
View File
@@ -1,102 +0,0 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestProviderSecretEnvVar(t *testing.T) {
tests := []struct {
provider string
want string
}{
{"anthropic", "ANTHROPIC_API_KEY"},
{"openai", "OPENAI_API_KEY"},
// Unknown provider falls back to "<PROVIDER>_API_KEY" with sanitization.
{"my-custom.provider", "MY_CUSTOM_PROVIDER_API_KEY"},
}
for _, tt := range tests {
t.Run(tt.provider, func(t *testing.T) {
got := providerSecretEnvVar(tt.provider)
if got != tt.want {
t.Errorf("providerSecretEnvVar(%q) = %q, want %q", tt.provider, got, tt.want)
}
})
}
}
func TestRenderGitHubWorkflow(t *testing.T) {
out := renderGitHubWorkflow("anthropic/claude-sonnet-4-5-20250929", "ANTHROPIC_API_KEY")
wantSubstrings := []string{
"name: kit",
"issue_comment:",
"pull_request_review_comment:",
"startsWith(github.event.comment.body, '/kit ')",
"github.event.comment.body == '/kit'",
"github.event.comment.author_association == 'OWNER'",
"github.event.comment.author_association == 'COLLABORATOR'",
"persist-credentials: false",
"uses: mark3labs/kit@v0",
"model: anthropic/claude-sonnet-4-5-20250929",
"GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}",
"ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}",
"contents: write",
"pull-requests: write",
"issues: write",
}
for _, want := range wantSubstrings {
if !strings.Contains(out, want) {
t.Errorf("rendered workflow missing %q\n---\n%s", want, out)
}
}
}
func TestWriteGitHubWorkflow(t *testing.T) {
dir := t.TempDir()
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
// First write succeeds and creates nested directories.
if err := writeGitHubWorkflow("anthropic/claude-sonnet-4-5", "ANTHROPIC_API_KEY", false); err != nil {
t.Fatalf("writeGitHubWorkflow: %v", err)
}
data, err := os.ReadFile(githubWorkflowPath)
if err != nil {
t.Fatalf("reading workflow: %v", err)
}
if !strings.Contains(string(data), "model: anthropic/claude-sonnet-4-5") {
t.Errorf("workflow missing model line:\n%s", data)
}
// Second write without force must refuse to clobber.
if err := writeGitHubWorkflow("anthropic/claude-sonnet-4-5", "ANTHROPIC_API_KEY", false); err == nil {
t.Error("expected error when overwriting without --force, got nil")
}
// With force it overwrites.
if err := writeGitHubWorkflow("openai/gpt-5", "OPENAI_API_KEY", true); err != nil {
t.Fatalf("writeGitHubWorkflow with force: %v", err)
}
data, err = os.ReadFile(githubWorkflowPath)
if err != nil {
t.Fatalf("reading workflow: %v", err)
}
if !strings.Contains(string(data), "OPENAI_API_KEY") {
t.Errorf("forced overwrite did not update content:\n%s", data)
}
// Sanity: the file lives at the expected nested path.
if _, err := os.Stat(filepath.Join(dir, githubWorkflowPath)); err != nil {
t.Errorf("workflow not at expected path: %v", err)
}
}
+5 -33
View File
@@ -73,12 +73,6 @@ var (
noCoreToolsFlag bool
extensionPaths []string
// Skills control
noSkillsFlag bool
skillsPaths []string
skillsDir string
skillsDisable []string
// TLS configuration
tlsSkipVerify bool
@@ -289,16 +283,6 @@ func init() {
rootCmd.PersistentFlags().
StringSliceVarP(&extensionPaths, "extension", "e", nil, "load additional extension file(s)")
// Skills flags
rootCmd.PersistentFlags().
BoolVar(&noSkillsFlag, "no-skills", false, "disable skill loading (auto-discovery and explicit)")
rootCmd.PersistentFlags().
StringSliceVar(&skillsPaths, "skill", nil, "load skill file or directory (repeatable)")
rootCmd.PersistentFlags().
StringVar(&skillsDir, "skills-dir", "", "scan this directory directly for skills (overrides auto-discovery)")
rootCmd.PersistentFlags().
StringSliceVar(&skillsDisable, "skill-disable", nil, "hide a skill from the model catalog by name (repeatable); still usable via /skill:")
flags := rootCmd.PersistentFlags()
flags.StringVar(&providerURL, "provider-url", "", "base URL for the provider API (applies to OpenAI, Anthropic, Ollama, and Google)")
flags.StringVar(&providerAPIKey, "provider-api-key", "", "API key for the provider (applies to OpenAI, Anthropic, and Google)")
@@ -349,10 +333,6 @@ func init() {
_ = viper.BindPFlag("extension", rootCmd.PersistentFlags().Lookup("extension"))
_ = viper.BindPFlag("prompt-template", rootCmd.PersistentFlags().Lookup("prompt-template"))
_ = viper.BindPFlag("no-prompt-templates", rootCmd.PersistentFlags().Lookup("no-prompt-templates"))
_ = viper.BindPFlag("no-skills", rootCmd.PersistentFlags().Lookup("no-skills"))
_ = viper.BindPFlag("skill", rootCmd.PersistentFlags().Lookup("skill"))
_ = viper.BindPFlag("skills-dir", rootCmd.PersistentFlags().Lookup("skills-dir"))
_ = viper.BindPFlag("skill-disable", rootCmd.PersistentFlags().Lookup("skill-disable"))
// Defaults are already set in flag definitions, no need to duplicate in viper
@@ -674,16 +654,13 @@ func beforeForkProviderForUI(k *kit.Kit) func(string, bool, string) (bool, strin
// beforeSessionSwitchProviderForUI returns a callback that emits a
// BeforeSessionSwitch event and returns (cancelled, reason). Returns nil
// if extensions are disabled — the UI treats nil as "no hook". The
// initialPrompt argument is forwarded to the event so extensions can
// inspect the prompt that will be submitted as the first turn of the
// new session.
func beforeSessionSwitchProviderForUI(k *kit.Kit) func(switchReason, initialPrompt string) (bool, string) {
// if extensions are disabled — the UI treats nil as "no hook".
func beforeSessionSwitchProviderForUI(k *kit.Kit) func(string) (bool, string) {
if !k.Extensions().HasExtensions() {
return nil
}
return func(switchReason, initialPrompt string) (bool, string) {
return k.Extensions().EmitBeforeSessionSwitchWithPrompt(switchReason, initialPrompt)
return func(switchReason string) (bool, string) {
return k.Extensions().EmitBeforeSessionSwitch(switchReason)
}
}
@@ -843,11 +820,6 @@ func runNormalMode(ctx context.Context) error {
AutoCompact: autoCompactFlag,
MCPAuthHandler: authHandler,
DisableCoreTools: viper.GetBool("no-core-tools"),
NoSkills: noSkillsFlag,
Skills: skillsPaths,
SkillsDir: skillsDir,
SkillsDisable: skillsDisable,
SkillTrustPrompt: skillTrustPrompt(),
// This callback is called when each MCP server finishes loading.
// We use a closure that captures appInstancePtr which is set after
// app.New() is called below.
@@ -1496,7 +1468,7 @@ type runModeDeps struct {
getUIVisibility func() *ui.UIVisibility
getStatusBarEntries func() []ui.StatusBarEntryData
emitBeforeFork func(string, bool, string) (bool, string)
emitBeforeSessionSwitch func(string, string) (bool, string)
emitBeforeSessionSwitch func(string) (bool, string)
getGlobalShortcuts func() map[string]func()
getExtensionCommands func() []commands.ExtensionCommand
setModel func(string) error
-52
View File
@@ -1,52 +0,0 @@
package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"golang.org/x/term"
"github.com/mark3labs/kit/pkg/kit"
)
// skillTrustPrompt returns a callback that gates project-local skill loading
// on an interactive trust decision (issue #65, gap #8). Project-local skills
// are injected into the system prompt, so a freshly cloned untrusted repo
// could smuggle instructions into the agent. The prompt asks the user whether
// to trust the directory before any project skill is loaded.
//
// It returns nil — meaning "load without prompting" — when Kit is not running
// interactively (a non-TTY stdin, --quiet, or a non-interactive one-shot
// prompt), so scripted and piped invocations keep their existing behaviour.
func skillTrustPrompt() func(projectDir string, skillCount int) kit.TrustDecision {
// Only prompt for interactive terminal sessions.
if quietFlag || positionalPrompt != "" {
return nil
}
if !term.IsTerminal(int(os.Stdin.Fd())) {
return nil
}
return func(projectDir string, skillCount int) kit.TrustDecision {
noun := "skills"
if skillCount == 1 {
noun = "skill"
}
fmt.Printf("\nThis project provides %d %s under .agents/skills or .kit/skills:\n %s\n",
skillCount, noun, projectDir)
fmt.Print("Load them into the agent? [t]rust always / [o]nce / [s]kip (default skip): ")
reader := bufio.NewReader(os.Stdin)
line, _ := reader.ReadString('\n')
switch strings.ToLower(strings.TrimSpace(line)) {
case "t", "trust", "a", "always":
return kit.TrustProject
case "o", "once", "y", "yes":
return kit.TrustProjectOnce
default:
return kit.SkipProjectSkills
}
}
}
-110
View File
@@ -1,110 +0,0 @@
//go:build ignore
// phase-handoff.go demonstrates ctx.NewSession by automating the multi-phase
// workflow pattern: the agent works through a spec, writes a HANDOFF.md at
// the end of each phase, then a fresh session picks up where the last one
// left off.
//
// Two trigger modes are provided:
//
// 1. Automatic — when an assistant message ends with the sentinel
// "<HANDOFF_READY>", the extension starts a new session and pre-loads
// HANDOFF.md as the first prompt. Use this when you want the agent to
// hand off control to itself with no user intervention.
//
// 2. Manual — the /handoff slash command starts a new session immediately
// with the same handoff prompt. Useful when you finish a phase by hand
// and want to clear the context window before the next one starts.
//
// Usage:
//
// kit -e examples/extensions/phase-handoff.go
//
// Have your spec-driving agent write a HANDOFF.md at the end of each phase
// and finish its message with the literal string `<HANDOFF_READY>`. The
// next session boots automatically and reads HANDOFF.md as @file context.
package main
import (
"strings"
"kit/ext"
)
// HANDOFFSentinel is the marker the agent appends to its last message to
// request an automatic session switch. Change this to whatever fits your
// workflow.
const HANDOFFSentinel = "<HANDOFF_READY>"
// HANDOFFPrompt is the first prompt the new session receives. The leading
// "@HANDOFF.md" triggers Kit's @file expansion, inlining the handoff file's
// contents as XML-wrapped context.
const HANDOFFPrompt = "Read @HANDOFF.md and continue with the next phase."
func Init(api ext.API) {
// Automatic trigger: detect the sentinel at the end of an agent turn.
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
msgs := ctx.GetMessages()
if len(msgs) == 0 {
return
}
last := msgs[len(msgs)-1]
if last.Role != "assistant" || !strings.Contains(last.Content, HANDOFFSentinel) {
return
}
// NewSession blocks while the agent finishes settling and then while
// the TUI completes the switch; run it in a goroutine so the agent's
// turn-end pipeline isn't stalled. The internal wait-for-idle (added
// in response to issue #63) makes this reliable even when post-turn
// tooling (formatters, on-save hooks, hidden tool calls) extends the
// busy window past AgentEnd.
go func() {
if err := ctx.NewSession(HANDOFFPrompt); err != nil {
ctx.PrintError("phase-handoff: " + err.Error())
return
}
ctx.PrintInfo("phase-handoff: started a fresh session from HANDOFF.md")
}()
})
// Manual trigger: /handoff [optional override prompt]
api.RegisterCommand(ext.CommandDef{
Name: "handoff",
Description: "Start a new session, optionally with a custom prompt",
Execute: func(args string, ctx ext.Context) (string, error) {
prompt := strings.TrimSpace(args)
if prompt == "" {
prompt = HANDOFFPrompt
}
if err := ctx.NewSession(prompt); err != nil {
return "", err
}
return "", nil
},
})
// Optional safeguard: surface the next prompt so the user can confirm
// before the auto-handoff proceeds. Set kit option "handoff.confirm=1"
// to enable.
api.OnBeforeSessionSwitch(func(e ext.BeforeSessionSwitchEvent, ctx ext.Context) *ext.BeforeSessionSwitchResult {
if ctx.GetOption("handoff.confirm") != "1" {
return nil
}
if e.InitialPrompt == "" {
return nil
}
resp := ctx.PromptConfirm(ext.PromptConfirmConfig{
Message: "Start a new session with prompt:\n " + e.InitialPrompt + "\n\nProceed?",
DefaultValue: true,
})
if resp.Cancelled || !resp.Value {
return &ext.BeforeSessionSwitchResult{
Cancel: true,
Reason: "handoff cancelled by user",
}
}
return nil
})
}
+44 -42
View File
@@ -1,21 +1,21 @@
module github.com/mark3labs/kit
go 1.26.4
go 1.26.3
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.7
charm.land/fantasy v0.32.0
charm.land/fantasy v0.25.0
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.4
github.com/alecthomas/chroma/v2 v2.27.0
charm.land/lipgloss/v2 v2.0.3
github.com/alecthomas/chroma/v2 v2.26.1
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/fang v1.0.0
github.com/charmbracelet/log v1.0.0
github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f
github.com/charmbracelet/x/editor v0.2.0
github.com/clipperhouse/displaywidth v0.11.0
github.com/clipperhouse/uax29/v2 v2.7.0
@@ -23,12 +23,12 @@ require (
github.com/fsnotify/fsnotify v1.10.1
github.com/indaco/herald v0.13.0
github.com/indaco/herald-md v0.3.0
github.com/mark3labs/mcp-go v0.55.0
github.com/mark3labs/mcp-go v0.54.1
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/traefik/yaegi v0.16.1
golang.org/x/image v0.42.0
golang.org/x/term v0.44.0
golang.org/x/image v0.41.0
golang.org/x/term v0.43.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -37,41 +37,41 @@ require (
cloud.google.com/go/auth v0.20.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.22.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.25 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
github.com/aws/smithy-go v1.27.2 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.8 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.19 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.18 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.1.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.18 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.2 // indirect
github.com/aws/smithy-go v1.26.0 // 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/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260615092313-b57e5e6d29bb // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260615092313-b57e5e6d29bb // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260527151214-009e6338d40d // 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/dlclark/regexp2 v1.12.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
github.com/dlclark/regexp2/v2 v2.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 // 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
@@ -84,14 +84,16 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/kaptinlin/jsonpointer v0.4.26 // indirect
github.com/kaptinlin/jsonschema v0.8.1 // indirect
github.com/kaptinlin/go-i18n v0.4.5 // indirect
github.com/kaptinlin/jsonpointer v0.4.25 // indirect
github.com/kaptinlin/jsonschema v0.7.13 // indirect
github.com/kaptinlin/messageformat-go v0.6.0 // 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/roff v0.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.4.0 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/spf13/afero v1.15.0 // indirect
@@ -111,14 +113,14 @@ require (
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.285.0 // indirect
google.golang.org/genai v1.61.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect
google.golang.org/api v0.282.0 // indirect
google.golang.org/genai v1.58.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
@@ -136,7 +138,7 @@ require (
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0
)
+163 -86
View File
@@ -1,13 +1,15 @@
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
charm.land/fantasy v0.32.0 h1:tlC1qlOdXi2CkF6KB0x8YAAm3hiarI2/69u6pZmOZk8=
charm.land/fantasy v0.32.0/go.mod h1:CWAFEOB21guhmt4qWN9sOnAHkZzVWjKbhxbPHG+oRs8=
charm.land/fantasy v0.25.0 h1:oXOWY1ivmTSnhYGzAolscF8zKtavWZyBWv0LHRSwN5Q=
charm.land/fantasy v0.25.0/go.mod h1:8QrWUzIcKwZQP+aAnC9vLu3iID6hu9/Jt+rPMiieBkc=
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.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
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.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
@@ -16,60 +18,81 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
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=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM=
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
cloud.google.com/go/longrunning v0.5.6/go.mod h1:vUaDrWYOMKRuhiv6JBnn49YxCPz2Ayn9GqyjaBT8/mA=
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
cloud.google.com/go/translate v1.10.3/go.mod h1:GW0vC1qvPtd3pgtypCv4k4U8B7EdgK9/QEF2aJEUovs=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Rhymond/go-money v1.0.15/go.mod h1:iHvCuIvitxu2JIlAlhF0g9jHqjRSr+rpdOs7Omqlupg=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y=
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/ardanlabs/jinja v1.2.0/go.mod h1:aXXzlJfjA+T3XNKA/YT5ZtDq2VJxt5a5siZ8cl9B35Q=
github.com/ardanlabs/kronk v1.25.2/go.mod h1:b5Gg4jDqvHDklkeHNB8+7treZRxUiCFsV65zphrTloY=
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.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
github.com/aws/smithy-go v1.27.2 h1:y9NPmSE6am6LjEFPfqHqG/jJk7AauQvhCJONKh7kpzk=
github.com/aws/smithy-go v1.27.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.41.8 h1:sRs7nG6/RiEBZ/K5UO2sNw0w40U02Nmz1VtARloTZXk=
github.com/aws/aws-sdk-go-v2 v1.41.8/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/config v1.32.19 h1:qRhIJMbevHUvIE7X4TK8N8zye5+5AhapcslPrvB+qKE=
github.com/aws/aws-sdk-go-v2/config v1.32.19/go.mod h1:RbJ24nfoya63+Mf5VI+CGCGk9vEdv28xPeii+gojRYs=
github.com/aws/aws-sdk-go-v2/credentials v1.19.18 h1:GcXQz2M/0ZvMo0v5DakUqbDBeBM1ZNaivkolEF4Esgw=
github.com/aws/aws-sdk-go-v2/credentials v1.19.18/go.mod h1:sHJ06tMGcD3ZpmMyJqV+VBsGilhSIZPIN+ZFy5Dg0C4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24 h1:FQm5ApnyzkuJdXLGskPce83CK1CQKC4RUnIHKVe4BU4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.24/go.mod h1:JsC7dqQc55MlZ5mvNsDMMge71u8pVcSzU3RNz2h/5yQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24 h1:u6kJU2i0va1AgtJsH3RdWKWqHULlTh7zHwb35Womf74=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24/go.mod h1:7GY+xLcXOFUpCkNwDReft9qOAVg54A4/AnjHIU7sSAY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24 h1:Xhbcf3KugX6vX7SDyUK205Oicyfg7EGuvoVNyP5L6DM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24/go.mod h1:rwDgb2HNOGZsnTHylOUedM7Vnl+bCfnXDqUNPsFWYfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 h1:54CTMmlJ71Rk2dYvM9qZOob+39wjlVja2zDLxCu69Ew=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25/go.mod h1:BZaHqxsS9vN1fvV5EfEl0OBLOk5+AajWsMu6MjqnZB4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 h1:CQW2FTrflfoslYWLf3fv7vG28Q219+v8YJS5QTQb2+Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24/go.mod h1:Xfx13T+u3nH6EEzgl9fBSO6nDRmze1FvnZNYkctQ2zw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.0 h1:yQo3eZ5qFaL1sJWqs1nL6j3yPHA2/R7c6tQ4T+0IO10=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.0/go.mod h1:3Zzou41Qt/ueXfIzHvTEjDNuR5IjCUBVF01SNhrt1e8=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.18 h1:ApLTFdAZfDhZSiY5uskwECKHkSNNF83y2Ru2r7SezWA=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.18/go.mod h1:A9K9qx2l6nK89hp+a350FdGfRkrkH5HdiEjHbiy/Q/c=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1 h1:4VD7TIZOGzehrgQ8vDE+1c6BQW4ErZPGY8ohZT5LXEE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.1/go.mod h1:er0SFJfdV89Rit5hIJu/EXtv+qC2XMnxoksLmcUFkqM=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.2 h1:XKnxlM4KZH1gktcsh3zSWc7GW4KivEv/OkifmHOhCUY=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.2/go.mod h1:KJYmkQaFB3SUW2j3aBkPsxNmAb4ZsSOvbvCpuxzHJA0=
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
github.com/aws/smithy-go v1.26.0/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.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
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/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
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=
@@ -84,10 +107,10 @@ github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0r
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
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-20260617131321-5e4b9c18c4be h1:pg+OWlIkk9HOe/8P5J95aKe2wGDzFUiiyFOUpwR30B4=
github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk=
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
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-20260601155805-6cf7526a1b3f h1:vKsPSlO4g4jKfJ9enESgNZ45BkbHngTIq3UxNOzic74=
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
@@ -98,14 +121,14 @@ github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIR
github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY=
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-20260615092313-b57e5e6d29bb h1:hoqNT54vrpXamSaQe5GxupakGgvvqFmVgmLJjotpHco=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a h1:aVvnksCVgxB2igk7jERL9ARIkbDXccp1gXCFqhGlamQ=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a/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/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-20260615092313-b57e5e6d29bb h1:fr6DwrfJB2XQ3zM2fCwumXPE5G+hegnkEpl1KUuPsQI=
github.com/charmbracelet/x/exp/slice v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/slice v0.0.0-20260527151214-009e6338d40d h1:RxcAR+vJCoD8QqT1cqLtkQKw+1cqvjqnu5IpPqYzPco=
github.com/charmbracelet/x/exp/slice v0.0.0-20260527151214-009e6338d40d/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=
@@ -120,6 +143,7 @@ github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcO
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/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
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=
@@ -133,25 +157,31 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dlclark/regexp2/v2 v2.1.1 h1:LCUGyd9Wf+r+VVOl8Ny38JTpWJcAsdVnCIuhhtthmKw=
github.com/dlclark/regexp2/v2 v2.1.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
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/dromara/carbon/v2 v2.6.16/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo=
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/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/eliben/go-sentencepiece v0.6.0/go.mod h1:nNYk4aMzgBoI6QFp4LUG8Eu1uO9fHD9L5ZEre93o9+c=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
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=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 h1:nxP4pPoyqOAgX8lYDFCfl3DyKeXErCvSvhcyzwGV9CE=
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA=
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
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=
@@ -165,12 +195,17 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
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/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
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=
@@ -181,18 +216,32 @@ github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72/go.mod h1:Vn+BBgKQHVQYdVQ4NZDICE1Brb+JfaONyDHr3q07oQc=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-getter v1.8.6/go.mod h1:nVH12eOV2P58dIiL3rsU6Fh3wLeJEKBOJzhMmzlSWoo=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hybridgroup/yzma v1.13.0/go.mod h1:zrzMgv/KVQz23+s6l16b+vJ+9uJVBdWtGcGkwRTMeiQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/indaco/herald v0.13.0 h1:+xVG9Fx5NpuWhwku/9IlRL6I009NnX4VUGKvlZHTRxU=
github.com/indaco/herald v0.13.0/go.mod h1:T5g1+XLYvpjouhzAGHnAHDCKizhESkoV6+QPZ3DhgWA=
github.com/indaco/herald-md v0.3.0 h1:hN1cKyrexPPM9PeHBsKuaWvIizSi/iYvM9yzRgtdb8M=
github.com/indaco/herald-md v0.3.0/go.mod h1:RUHVaDSG45ymJjKyxpDwBocLXrZo93FB4OeYMsw9B9s=
github.com/kaptinlin/jsonpointer v0.4.26 h1:tw616yszHek+B3/GtDSia+uzBa3sLXGpmo4tYeMhBZw=
github.com/kaptinlin/jsonpointer v0.4.26/go.mod h1:wVOBaXGGnP42YsMb6zev/3W5POTvspdNfh8DXzf8XS8=
github.com/kaptinlin/jsonschema v0.8.1 h1:Krhuq1HpE+olHoPfcxkohqKKCnXfixUPv+aUYRegBBQ=
github.com/kaptinlin/jsonschema v0.8.1/go.mod h1:mCH2W5lXd29tdDjvoFfY32nedPORnlk7pCVrrcs/NkQ=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jupiterrider/ffi v0.7.0/go.mod h1:9dauhpOfNqrqk28fxuu0kkdeFtT9Qr4vbfigiuIXN7c=
github.com/kaptinlin/go-i18n v0.4.5 h1:9tIlo5A0RXth+yZJO2MG7Bhpu/X9PlzQnGz/qyYWNoY=
github.com/kaptinlin/go-i18n v0.4.5/go.mod h1:mU/7BH4molY5lGZYBwBRKAaiJ70dWRHuqmQ0/pFLGno=
github.com/kaptinlin/jsonpointer v0.4.25 h1:iJ197e8n+WwqaqBsa53FqG3rPJCg5oijyFXEXNWWC3E=
github.com/kaptinlin/jsonpointer v0.4.25/go.mod h1:wVOBaXGGnP42YsMb6zev/3W5POTvspdNfh8DXzf8XS8=
github.com/kaptinlin/jsonschema v0.7.13 h1:kahVXTy/rURL0XJjyQ9WELm59wEmXi6IY0TWswQEFvU=
github.com/kaptinlin/jsonschema v0.7.13/go.mod h1:Uh0aUBusnhXDCEXJ2oimL/hx7YTo7F+sKniE+tM0ERc=
github.com/kaptinlin/messageformat-go v0.6.0 h1:D6jiXFsKW4/JG2CMddv/F6Rev9KVbCRKEzzV5QOAcpc=
github.com/kaptinlin/messageformat-go v0.6.0/go.mod h1:NKjwS6e9u7DRhAK+vydjDDwJ7UbdHhYjk/yk2WPuZPs=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -201,12 +250,14 @@ 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.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mark3labs/mcp-go v0.55.0 h1:lJfz2aoctiwK+sI991+uIYwmKNIBciI+O7zsyDsa4U8=
github.com/mark3labs/mcp-go v0.55.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0=
github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
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=
@@ -221,14 +272,19 @@ 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/pelletier/go-toml/v2 v2.4.0 h1:Mwu0mAkUKbittDs3/ADDWXqMmq3EOK2VHiuCkV00Row=
github.com/pelletier/go-toml/v2 v2.4.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/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=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -236,8 +292,10 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -249,6 +307,7 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -266,20 +325,27 @@ 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/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
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.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
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/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
@@ -288,40 +354,49 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
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.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.285.0 h1:B7eHHoKGAX/LrPkQvhQqnGwjgWxofbdGwCTQvpm8FkM=
google.golang.org/api v0.285.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ=
google.golang.org/genai v1.61.0 h1:wCyNGiaC9q5A59B80zuEtNBhq3ypEvICFkZYOfK7IO0=
google.golang.org/genai v1.61.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
google.golang.org/genproto v0.0.0-20260610212136-7ab31c22f7ad h1:cYL1DPJAQr4JMvhfGao0PDXoaf03ifMljAuDyrbMBd0=
google.golang.org/genproto v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:cVHIikDNAdx8ISZeW+2rYkEMf3xn0GSaBYmVnWXQBUo=
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad h1:3iLyITS/sySRwbUKoC7ogfj2Yr1Cjs0pfaRKj5U5HEw=
google.golang.org/genproto/googleapis/api v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I=
google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genai v1.58.0 h1:MNA3ZkRyr7MnRwZ9RNZ60p4+UMKV3yYRw6pyHq4pp0U=
google.golang.org/genai v1.58.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 h1:JjVGDZYWkJWZcxveJGzfkXC5myDVWAd4dZdgbzrDUv8=
google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348/go.mod h1:95PqD4xM+AdOcBGsmgfaofXsiA37uXDtDufVbntT3TU=
google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:U8orV30l6KpDsi9dxU0CoJZGbjS8EEpw+6ba+XwGPQA=
google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:6TABGosqSqU2l1+fJ3jdvOYPPVryeKybxYF0cCZkTBE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
@@ -329,6 +404,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY=
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-3
View File
@@ -96,9 +96,6 @@ func (r *sessionRegistry) create(ctx context.Context, cwd string) (*acpSession,
// Message injection — no-ops for now; ACP clients drive prompts.
ec.SendMessage = func(string) {}
ec.CancelAndSend = func(string) {}
ec.NewSession = func(string) error {
return fmt.Errorf("new session not available in ACP mode")
}
ec.Exit = func() {}
// TUI widgets/chrome — silent no-ops (no TUI in ACP).
-12
View File
@@ -1105,18 +1105,6 @@ func (a *Agent) GetExtensionToolCount() int {
return len(a.extraTools)
}
// GetExtraTools returns the agent's current extra tools (e.g.
// extension-registered tools). The returned slice is a copy so callers can
// snapshot and later restore it via SetExtraTools.
func (a *Agent) GetExtraTools() []fantasy.AgentTool {
if len(a.extraTools) == 0 {
return nil
}
out := make([]fantasy.AgentTool, len(a.extraTools))
copy(out, a.extraTools)
return out
}
// SetExtraTools replaces the agent's extra tools (e.g. extension-registered
// tools) and rebuilds the internal agent with the updated tool list. The
// model, system prompt, and all other configuration are preserved.
+9 -168
View File
@@ -2,7 +2,6 @@ package app
import (
"context"
"errors"
"fmt"
"log"
"os"
@@ -25,26 +24,6 @@ type queueItem struct {
Files []kit.LLMFilePart
}
// ErrAgentBusy is returned when an operation cannot proceed because the agent
// is still processing a turn (including any post-turn extension hooks) and did
// not become idle before the operation's deadline.
//
// This is an alias for extensions.ErrAgentBusy so the extension API and the
// app layer share a single sentinel value — callers can detect the condition
// with errors.Is(err, app.ErrAgentBusy) without substring-matching the error
// message.
var ErrAgentBusy = extensions.ErrAgentBusy
// DefaultNewSessionIdleWait bounds how long RequestNewSessionFromExtension
// will block waiting for the agent to settle. It needs to be generous enough
// to cover real-world post-turn tooling (project formatters, on-save linters,
// hidden tool calls) which routinely hold the busy flag for seconds and
// occasionally minutes — yet still short enough to surface a wedged agent.
//
// Issue #63 reported workloads where the busy window regularly exceeded
// 6 seconds; ten minutes is the same bound the workaround in that issue used.
const DefaultNewSessionIdleWait = 10 * time.Minute
// 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.
@@ -76,25 +55,11 @@ type App struct {
// each new step and called by CancelCurrentStep().
cancelStep context.CancelFunc
// mu protects busy, queue, cancelStep, and idleCh.
// mu protects busy, queue, and cancelStep.
mu sync.Mutex
busy bool
queue []queueItem
// idleCh is closed when the agent transitions from busy back to idle.
// While the agent is idle the channel is already closed (recv returns
// immediately). When busy transitions to true a fresh open channel is
// allocated so callers blocked on the previous one are released. All
// transitions are funnelled through setBusyLocked to keep the channel
// pointer in sync with the busy flag.
//
// This is the underlying primitive WaitForIdle and
// RequestNewSessionFromExtension wait on to fix the AgentEnd→NewSession
// race described in issue #63: AgentEnd is emitted from inside the agent
// loop, before drainQueue clears busy, so any extension hook that calls
// ctx.NewSession synchronously would otherwise observe busy==true.
idleCh chan struct{}
// wg tracks in-flight goroutines; Close() waits on it.
wg sync.WaitGroup
@@ -130,10 +95,6 @@ type App struct {
// initialMessages may be nil or empty for a fresh session.
func New(opts Options, initialMessages []kit.LLMMessage) *App {
rootCtx, rootCancel := context.WithCancel(context.Background())
// idleCh starts already closed: the freshly constructed App is idle, so
// any caller blocking on it via WaitForIdle should be released immediately.
idleCh := make(chan struct{})
close(idleCh)
return &App{
opts: opts,
store: NewMessageStoreWithMessages(initialMessages),
@@ -141,90 +102,6 @@ func New(opts Options, initialMessages []kit.LLMMessage) *App {
rootCancel: rootCancel,
// cancelStep starts as a no-op so CancelCurrentStep() is always safe.
cancelStep: func() {},
idleCh: idleCh,
}
}
// setBusyLocked is the single chokepoint for mutating a.busy. It keeps the
// idleCh signalling channel in sync with the busy flag:
//
// - false → true: allocate a fresh open channel so future WaitForIdle
// callers block until the next idle transition.
// - true → false: close the current channel so any waiters wake up.
//
// No-op when the requested state already matches. The caller must hold a.mu.
func (a *App) setBusyLocked(busy bool) {
if a.busy == busy {
return
}
a.busy = busy
if busy {
a.idleCh = make(chan struct{})
} else {
close(a.idleCh)
}
}
// idleSnapshot returns the current busy state and the channel that will be
// closed on the next idle transition. The snapshot is taken under a.mu so the
// pair is consistent (busy==true ⇒ ch is the open channel for *this* busy
// cycle, not a stale one).
func (a *App) idleSnapshot() (busy bool, ch chan struct{}) {
a.mu.Lock()
defer a.mu.Unlock()
return a.busy, a.idleCh
}
// WaitForIdle blocks until the agent is idle, the given timeout elapses, or
// the app shuts down. Returns nil on idle, ErrAgentBusy on timeout, or the
// rootCtx error if the app is closing.
//
// A non-positive timeout disables the deadline and waits indefinitely (until
// idle or app shutdown). Safe to call from any goroutine, but never from
// inside the Bubble Tea Update() loop — it blocks.
//
// Idiomatic use from extensions:
//
// if err := app.WaitForIdle(0); err != nil { /* shutdown */ }
//
// The loop guards against the agent re-arming itself between wakeups: if
// another prompt is queued (or a steer message lands) while we're waiting,
// setBusyLocked allocates a fresh idleCh and we wait again.
func (a *App) WaitForIdle(timeout time.Duration) error {
var deadline time.Time
if timeout > 0 {
deadline = time.Now().Add(timeout)
}
for {
busy, ch := a.idleSnapshot()
if !busy {
return nil
}
var timer *time.Timer
var timerCh <-chan time.Time
if timeout > 0 {
remaining := time.Until(deadline)
if remaining <= 0 {
return ErrAgentBusy
}
timer = time.NewTimer(remaining)
timerCh = timer.C
}
select {
case <-ch:
// Idle transition observed — loop and re-check under the
// mutex in case a new busy cycle started immediately after.
case <-timerCh:
return ErrAgentBusy
case <-a.rootCtx.Done():
if timer != nil {
timer.Stop()
}
return a.rootCtx.Err()
}
if timer != nil {
timer.Stop()
}
}
}
@@ -278,7 +155,7 @@ func (a *App) RunWithFiles(prompt string, files []kit.LLMFilePart) int {
return qLen
}
a.setBusyLocked(true)
a.busy = true
a.wg.Add(1)
a.mu.Unlock()
go a.drainQueue(item)
@@ -358,7 +235,7 @@ func (a *App) SteerWithFiles(prompt string, files []kit.LLMFilePart) int {
if !a.busy {
// Not busy — start immediately, same as RunWithFiles().
item := queueItem{Prompt: prompt, Files: files}
a.setBusyLocked(true)
a.busy = true
a.wg.Add(1)
a.mu.Unlock()
go a.drainQueue(item)
@@ -394,7 +271,7 @@ func (a *App) InterruptAndSend(prompt string) {
if !a.busy {
// Not busy — start immediately, same as Run().
a.setBusyLocked(true)
a.busy = true
a.wg.Add(1)
a.mu.Unlock()
go a.drainQueue(item)
@@ -593,7 +470,7 @@ func (a *App) CompactConversation(customInstructions string) error {
a.mu.Unlock()
return fmt.Errorf("SDK instance not available")
}
a.setBusyLocked(true)
a.busy = true
a.wg.Add(1)
a.mu.Unlock()
@@ -655,7 +532,7 @@ func (a *App) CompactAsync(customInstructions string, onComplete func(), onError
a.mu.Unlock()
return fmt.Errorf("SDK instance not available")
}
a.setBusyLocked(true)
a.busy = true
a.wg.Add(1)
a.mu.Unlock()
@@ -744,7 +621,7 @@ func (a *App) releaseBusyAfterCompact() {
// in just before closed was set.
if a.closed {
a.queue = a.queue[:0]
a.setBusyLocked(false)
a.busy = false
a.mu.Unlock()
return
}
@@ -756,7 +633,7 @@ func (a *App) releaseBusyAfterCompact() {
a.queue = a.queue[:0]
if len(pending) == 0 {
a.setBusyLocked(false)
a.busy = false
a.mu.Unlock()
return
}
@@ -973,7 +850,7 @@ func (a *App) drainQueue(first queueItem) {
// Mark as no longer busy
a.mu.Lock()
a.setBusyLocked(false)
a.busy = false
a.mu.Unlock()
}
@@ -1353,42 +1230,6 @@ func (a *App) SetEditorTextFromExtension(text string) {
}
}
// RequestNewSessionFromExtension sends a NewSessionRequestEvent to the TUI
// to end the current session and start a fresh one. If initialPrompt is
// non-empty it is submitted as the first user turn of the new session.
//
// If the agent is currently busy (e.g. the caller is an OnAgentEnd hook that
// fires before drainQueue clears the busy flag, or there are queued prompts
// still being processed) the call blocks until the agent becomes idle, up to
// DefaultNewSessionIdleWait. If that deadline elapses, ErrAgentBusy is
// returned and callers can detect it with errors.Is. This wait-then-send
// behavior fixes the v0.79.0 phase-handoff race documented in issue #63.
//
// Returns an error when running headless (no TUI attached), when the wait
// for idle times out (ErrAgentBusy), when the app is shutting down, or when
// a BeforeSessionSwitch extension hook cancels the switch.
//
// This is the implementation behind ctx.NewSession(prompt) for the
// interactive TUI. It blocks the caller until the TUI processes the
// switch, so it must be invoked from a goroutine outside Update().
func (a *App) RequestNewSessionFromExtension(initialPrompt string) error {
a.mu.Lock()
prog := a.program
a.mu.Unlock()
if prog == nil {
return fmt.Errorf("new session unavailable: no interactive TUI attached")
}
if err := a.WaitForIdle(DefaultNewSessionIdleWait); err != nil {
if errors.Is(err, ErrAgentBusy) {
return fmt.Errorf("cannot start new session: %w", err)
}
return err
}
ch := make(chan error, 1)
prog.Send(NewSessionRequestEvent{InitialPrompt: initialPrompt, ResponseCh: ch})
return <-ch
}
// 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) {
+5 -283
View File
@@ -794,7 +794,7 @@ func TestReleaseBusyAfterCompact_flushesQueuedMessages(t *testing.T) {
// summarising. (Run() would have appended them and returned a queue
// length > 0 to the caller.)
app.mu.Lock()
app.setBusyLocked(true)
app.busy = true
app.queue = append(app.queue,
queueItem{Prompt: "queued during compact #1"},
queueItem{Prompt: "queued during compact #2"},
@@ -834,7 +834,7 @@ func TestReleaseBusyAfterCompact_idleWhenQueueEmpty(t *testing.T) {
defer app.Close()
app.mu.Lock()
app.setBusyLocked(true)
app.busy = true
app.mu.Unlock()
app.releaseBusyAfterCompact()
@@ -901,7 +901,7 @@ func TestReleaseBusyAfterCompact_splicesSteerAheadOfQueue(t *testing.T) {
// Simulate the state at the end of compaction: busy is set and a couple
// of regular Run() prompts have piled up after the steer messages.
app.mu.Lock()
app.setBusyLocked(true)
app.busy = true
app.queue = append(app.queue,
queueItem{Prompt: "queued-1"},
queueItem{Prompt: "queued-2"},
@@ -950,7 +950,7 @@ func TestReleaseBusyAfterCompact_dropsQueueWhenClosed(t *testing.T) {
app := newTestApp(stub)
app.mu.Lock()
app.setBusyLocked(true)
app.busy = true
app.queue = append(app.queue, queueItem{Prompt: "would have run"})
app.closed = true
app.mu.Unlock()
@@ -999,7 +999,7 @@ func TestPopLastUserMessage_WhileBusy(t *testing.T) {
defer app.Close()
app.mu.Lock()
app.setBusyLocked(true)
app.busy = true
app.mu.Unlock()
_, _, err := app.PopLastUserMessage()
@@ -1115,281 +1115,3 @@ func TestPopLastUserMessage_NoUserOnBranch(t *testing.T) {
t.Fatalf("expected error mentioning missing user message, got %q", err.Error())
}
}
// --------------------------------------------------------------------------
// WaitForIdle / RequestNewSessionFromExtension (issue #63)
// --------------------------------------------------------------------------
// TestWaitForIdle_AlreadyIdle verifies the fast path: a freshly constructed
// App is idle and WaitForIdle returns immediately without consulting the
// timeout.
func TestWaitForIdle_AlreadyIdle(t *testing.T) {
app := newTestApp(newStub())
defer app.Close()
start := time.Now()
if err := app.WaitForIdle(2 * time.Second); err != nil {
t.Fatalf("WaitForIdle on idle app: %v", err)
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("WaitForIdle blocked for %s on already-idle app", elapsed)
}
}
// TestWaitForIdle_BlocksUntilDrain reproduces the issue #63 race: while
// drainQueue holds busy==true the call should block, then return nil as soon
// as the drain completes.
func TestWaitForIdle_BlocksUntilDrain(t *testing.T) {
gate := make(chan struct{})
var gateOnce sync.Once
closeGate := func() { gateOnce.Do(func() { close(gate) }) }
stub := newStubWithFuncs(
func(ctx context.Context) (*kit.TurnResult, error) {
select {
case <-gate:
case <-ctx.Done():
return nil, ctx.Err()
}
return turnResult("done"), nil
},
)
app := newTestApp(stub)
t.Cleanup(func() {
closeGate()
app.Close()
})
app.Run("hello")
// Confirm the agent is busy before we start waiting.
if !waitForCondition(2*time.Second, func() bool { return app.IsBusy() }) {
t.Fatal("app never became busy after Run()")
}
errCh := make(chan error, 1)
go func() {
errCh <- app.WaitForIdle(5 * time.Second)
}()
// Should not return while the stub is blocked.
select {
case err := <-errCh:
t.Fatalf("WaitForIdle returned early (err=%v) while agent still busy", err)
case <-time.After(150 * time.Millisecond):
}
closeGate()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("WaitForIdle: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("WaitForIdle did not return after drain completed")
}
if app.IsBusy() {
t.Fatal("app still reports busy after WaitForIdle returned")
}
}
// TestWaitForIdle_TimeoutReturnsErrAgentBusy verifies that a slow turn yields
// ErrAgentBusy (detectable via errors.Is) when the deadline elapses.
func TestWaitForIdle_TimeoutReturnsErrAgentBusy(t *testing.T) {
gate := make(chan struct{})
stub := newStubWithFuncs(
func(ctx context.Context) (*kit.TurnResult, error) {
select {
case <-gate:
case <-ctx.Done():
return nil, ctx.Err()
}
return turnResult("done"), nil
},
)
app := newTestApp(stub)
// Release the stub before Close so wg.Wait() can return.
t.Cleanup(func() {
close(gate)
app.Close()
})
app.Run("hello")
if !waitForCondition(2*time.Second, func() bool { return app.IsBusy() }) {
t.Fatal("app never became busy after Run()")
}
err := app.WaitForIdle(50 * time.Millisecond)
if !errors.Is(err, ErrAgentBusy) {
t.Fatalf("expected ErrAgentBusy on timeout, got %v", err)
}
}
// TestWaitForIdle_ZeroTimeoutWaitsIndefinitely verifies that a non-positive
// timeout still blocks until idle (or shutdown) — not an instant ErrAgentBusy.
func TestWaitForIdle_ZeroTimeoutWaitsIndefinitely(t *testing.T) {
gate := make(chan struct{})
var gateOnce sync.Once
closeGate := func() { gateOnce.Do(func() { close(gate) }) }
stub := newStubWithFuncs(
func(ctx context.Context) (*kit.TurnResult, error) {
select {
case <-gate:
case <-ctx.Done():
return nil, ctx.Err()
}
return turnResult("done"), nil
},
)
app := newTestApp(stub)
t.Cleanup(func() {
closeGate()
app.Close()
})
app.Run("hello")
if !waitForCondition(2*time.Second, func() bool { return app.IsBusy() }) {
t.Fatal("app never became busy after Run()")
}
errCh := make(chan error, 1)
go func() { errCh <- app.WaitForIdle(0) }()
select {
case err := <-errCh:
t.Fatalf("WaitForIdle(0) returned early with %v while agent was busy", err)
case <-time.After(150 * time.Millisecond):
}
closeGate()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("WaitForIdle(0) returned %v after idle", err)
}
case <-time.After(3 * time.Second):
t.Fatal("WaitForIdle(0) did not return after drain completed")
}
}
// TestWaitForIdle_AppClose verifies that shutting down the app while a
// caller is blocked in WaitForIdle releases the wait.
func TestWaitForIdle_AppClose(t *testing.T) {
gate := make(chan struct{})
stub := newStubWithFuncs(
func(ctx context.Context) (*kit.TurnResult, error) {
select {
case <-gate:
case <-ctx.Done():
return nil, ctx.Err()
}
return turnResult("done"), nil
},
)
app := newTestApp(stub)
app.Run("hello")
if !waitForCondition(2*time.Second, func() bool { return app.IsBusy() }) {
t.Fatal("app never became busy after Run()")
}
errCh := make(chan error, 1)
go func() { errCh <- app.WaitForIdle(5 * time.Second) }()
// Give the goroutine a moment to enter the wait.
time.Sleep(50 * time.Millisecond)
// rootCancel is called by Close, which should release the waiter
// before drainQueue itself observes the cancellation and clears busy.
go func() {
// Unblock the stub so Close() can proceed past wg.Wait().
close(gate)
}()
app.Close()
select {
case err := <-errCh:
// Either rootCtx cancellation propagated first (err = context.Canceled)
// or the drain finished cleanly first (err == nil); both are
// acceptable terminations. The key invariant is that WaitForIdle
// does not hang past Close.
if err != nil && !errors.Is(err, context.Canceled) {
t.Fatalf("WaitForIdle returned unexpected error: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("WaitForIdle did not return after Close()")
}
}
// TestRequestNewSessionFromExtension_NoTUI verifies the headless guard: with
// no Bubble Tea program registered the call fails fast (no busy-wait).
func TestRequestNewSessionFromExtension_NoTUI(t *testing.T) {
app := newTestApp(newStub())
defer app.Close()
err := app.RequestNewSessionFromExtension("hello")
if err == nil {
t.Fatal("expected error in headless mode")
}
if !strings.Contains(err.Error(), "no interactive TUI") {
t.Fatalf("expected 'no interactive TUI' error, got %q", err.Error())
}
}
// TestBusyTransitionsSignalIdleCh exercises the setBusyLocked invariants
// directly: a fresh App is idle (closed channel); Run() opens a new channel
// that is then closed when drainQueue exits.
func TestBusyTransitionsSignalIdleCh(t *testing.T) {
app := newTestApp(newStub("ok"))
defer app.Close()
// Initial state: closed channel, busy==false.
busy, ch := app.idleSnapshot()
if busy {
t.Fatal("freshly constructed App should not be busy")
}
select {
case <-ch:
default:
t.Fatal("initial idleCh should already be closed")
}
gate := make(chan struct{})
var gateOnce sync.Once
closeGate := func() { gateOnce.Do(func() { close(gate) }) }
stub := newStubWithFuncs(func(ctx context.Context) (*kit.TurnResult, error) {
select {
case <-gate:
case <-ctx.Done():
return nil, ctx.Err()
}
return turnResult("ok"), nil
})
app2 := newTestApp(stub)
t.Cleanup(func() {
closeGate()
app2.Close()
})
app2.Run("hello")
if !waitForCondition(2*time.Second, func() bool { return app2.IsBusy() }) {
t.Fatal("app2 never became busy")
}
_, ch2 := app2.idleSnapshot()
select {
case <-ch2:
t.Fatal("idleCh should be open while busy")
default:
}
closeGate()
select {
case <-ch2:
case <-time.After(3 * time.Second):
t.Fatal("idleCh was never closed after drain completed")
}
}
-15
View File
@@ -247,21 +247,6 @@ type EditorTextSetEvent struct {
Text string
}
// NewSessionRequestEvent is sent when an extension calls ctx.NewSession to
// end the current session and start a fresh one. The TUI routes this into
// the same /new code path (including the BeforeSessionSwitch hook and any
// @file expansion in InitialPrompt). ResponseCh, when non-nil, receives a
// single result so the extension goroutine can observe success or failure.
type NewSessionRequestEvent struct {
// InitialPrompt, when non-empty, is the first user turn to submit
// after the session switch. @file references are expanded.
InitialPrompt string
// ResponseCh receives the outcome (nil error on success). Must be
// buffered (cap >= 1) so the TUI never blocks. May be nil if the
// caller does not need the result.
ResponseCh chan<- error
}
// 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
-32
View File
@@ -389,30 +389,6 @@ func roleLabel(role fantasy.MessageRole) string {
}
}
// skillContentMarkers are substrings that identify a message carrying
// explicitly-activated skill content. Such messages are exempt from
// compaction pruning per the agentskills.io spec (issue #65, gap #7): an
// activated skill must remain in context verbatim instead of being folded
// into a lossy summary.
var skillContentMarkers = []string{"<skill ", "<skill>", "<skill_content"}
// isProtectedMessage reports whether msg carries explicitly-activated skill
// content that must survive compaction unchanged.
func isProtectedMessage(msg fantasy.Message) bool {
for _, part := range msg.Content {
tp, ok := part.(fantasy.TextPart)
if !ok {
continue
}
for _, marker := range skillContentMarkers {
if strings.Contains(tp.Text, marker) {
return true
}
}
}
return false
}
// serializeMessages converts a slice of fantasy messages into a plain-text
// representation suitable for sending to the summarisation LLM. Tool result
// text is truncated to maxToolResultChars to keep the summarisation request
@@ -542,14 +518,6 @@ func Compact(
newMessages := make([]fantasy.Message, 0, 1+len(recentMessages))
newMessages = append(newMessages, summaryMessage)
// Carry forward any explicitly-activated skill content from the
// summarised range verbatim — skill instructions must not be lost to
// compaction (issue #65, gap #7).
for _, msg := range oldMessages {
if isProtectedMessage(msg) {
newMessages = append(newMessages, msg)
}
}
newMessages = append(newMessages, recentMessages...)
compactedTokens := EstimateMessageTokens(newMessages)
-22
View File
@@ -439,25 +439,3 @@ func TestSortedKeys_Empty(t *testing.T) {
t.Errorf("sortedKeys(nil) = %v, want nil", got)
}
}
// ---------------------------------------------------------------------------
// Skill-content protection (issue #65, gap #7)
// ---------------------------------------------------------------------------
func TestIsProtectedMessage(t *testing.T) {
cases := []struct {
text string
want bool
}{
{`<skill name="foo" location="/x">body</skill>`, true},
{`<skill_content name="foo">body</skill_content>`, true},
{"just a normal message", false},
{"talking about skills in general", false},
}
for _, c := range cases {
msg := makeTextMessage(fantasy.MessageRoleUser, c.text)
if got := isProtectedMessage(msg); got != c.want {
t.Errorf("isProtectedMessage(%q) = %v, want %v", c.text, got, c.want)
}
}
}
+13 -49
View File
@@ -6,7 +6,6 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
@@ -228,17 +227,16 @@ type GenerationParams struct {
// 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"`
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
APIModelName string `json:"apiModelName,omitempty" yaml:"apiModelName,omitempty"`
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"`
Name string `json:"name" yaml:"name"`
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
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"`
// Generation parameter defaults for this model.
// These are applied when the user hasn't explicitly set the corresponding
@@ -495,27 +493,6 @@ mcpServers:
# maxTokens: 16384
# systemPrompt: "You are a deep reasoning assistant." # or a file path
# Skills configuration (all optional)
# no-skills: false # Set to true to disable all skill loading
# skill: # Explicit skill files/dirs (disables auto-discovery)
# - "/path/to/skill.md"
# skills-dir: "/path/to/skills" # Scan this directory directly for skills (overrides auto-discovery)
# skill-disable: # Hide skills from the model catalog by name (still usable via /skill:)
# - "some-skill"
#
# Skill files follow the agentskills.io spec. A SKILL.md frontmatter block
# supports these fields:
# name: my-skill # required
# description: Use when ... # required (basis for model discovery)
# license: MIT # optional SPDX identifier
# compatibility: claude-code, cursor # optional targeted-environment note
# allowed-tools: read, bash # optional (experimental) tool restriction
# disable-model-invocation: false # optional; true hides from the catalog
# metadata: # optional arbitrary key/value pairs
# author: you
# tags: [example] # Kit extension
# when: on-demand # Kit extension
# API Configuration (can also use environment variables)
# provider-api-key: "your-api-key" # API key for OpenAI, Anthropic, or Google
# provider-url: "https://api.openai.com/v1" # Base URL for OpenAI, Anthropic, or Ollama
@@ -570,7 +547,7 @@ func FilepathOr[T any](key string, value *T) error {
absPath = filepath.Join(home, absPath[2:])
}
if !filepath.IsAbs(absPath) {
base := GetConfigPath()
base := configPath
if base == "" {
fmt.Fprintf(os.Stderr, "unable to build relative path to config.")
os.Exit(1)
@@ -597,24 +574,11 @@ func FilepathOr[T any](key string, value *T) error {
return nil
}
var (
configPathMu sync.RWMutex
configPath string
)
var configPath string
// SetConfigPath sets the configuration file path for resolving relative paths
// in configuration values. This should be called when the configuration file
// location is known. It is safe for concurrent use.
// location is known.
func SetConfigPath(path string) {
configPathMu.Lock()
defer configPathMu.Unlock()
configPath = path
}
// GetConfigPath returns the configuration file path previously set via
// SetConfigPath. It is safe for concurrent use.
func GetConfigPath() string {
configPathMu.RLock()
defer configPathMu.RUnlock()
return configPath
}
-3
View File
@@ -205,9 +205,6 @@ func TestEnsureConfigExists(t *testing.T) {
"type: \"local\"",
"type: \"remote\"",
"Core tools",
"# Skills configuration",
"no-skills:",
"skills-dir:",
}
for _, expected := range expectedSections {
-33
View File
@@ -1,33 +0,0 @@
package config
import (
"sync"
"testing"
)
// TestConfigPathConcurrentAccess exercises the mutex guarding the package-level
// configPath global. Run with -race to detect the data race that motivated the
// guard (concurrent kit.New() calls discovering a .kit.yml).
func TestConfigPathConcurrentAccess(t *testing.T) {
t.Cleanup(func() { SetConfigPath("") })
const goroutines = 32
var wg sync.WaitGroup
wg.Add(goroutines * 2)
for range goroutines {
go func() {
defer wg.Done()
SetConfigPath("/tmp/kit.yml")
}()
go func() {
defer wg.Done()
_ = GetConfigPath()
}()
}
wg.Wait()
SetConfigPath("/tmp/final.yml")
if got := GetConfigPath(); got != "/tmp/final.yml" {
t.Fatalf("GetConfigPath() = %q, want /tmp/final.yml", got)
}
}
+2 -85
View File
@@ -1,24 +1,5 @@
package extensions
import (
"errors"
)
// ErrAgentBusy is returned (wrapped) when an extension API call that requires
// the agent to be idle cannot proceed because the agent is still processing a
// turn or post-turn hooks. Most notably, ctx.NewSession waits for idle
// internally; if its wait deadline elapses it returns an error that wraps
// this sentinel.
//
// Extensions can detect the condition with errors.Is:
//
// if err := ctx.NewSession(prompt); err != nil {
// if errors.Is(err, ext.ErrAgentBusy) {
// // agent never settled — fall back to a queued message instead
// }
// }
var ErrAgentBusy = errors.New("agent is busy")
// ---------------------------------------------------------------------------
// Internal types (used by runner, NOT exposed to Yaegi)
// ---------------------------------------------------------------------------
@@ -143,48 +124,6 @@ type Context struct {
// })
SendMultimodalMessage func(text string, files []FilePart)
// NewSession ends the current session and starts a fresh one (matching
// the /new slash command). When prompt is non-empty it is submitted as
// the first user turn of the new session, with @file references
// expanded the same way they are for normal user input. Pass an empty
// string to start an empty session.
//
// If the agent is currently busy when NewSession is called (for example,
// from an OnAgentEnd hook that fires before the agent fully settles, or
// while post-turn formatters/linters are still running), the call blocks
// until the agent transitions to idle. This avoids the v0.79.0
// phase-handoff race where NewSession from OnAgentEnd would fail with
// "agent is busy" because TurnEnd fires before the busy flag clears.
// The wait has a generous internal timeout; if it elapses the returned
// error wraps ErrAgentBusy (detectable with errors.Is).
//
// Returns an error if the agent does not become idle within the wait
// window, if a registered BeforeSessionSwitch handler cancels the
// switch, or if the new session file cannot be created. In
// non-interactive (ACP / headless) mode this is a no-op that returns
// an error.
//
// Because NewSession may block, call it from a goroutine — not
// directly from inside an event handler that the agent loop is waiting
// on.
//
// Typical pattern — start a fresh session at the end of a phase by
// reading a handoff file:
//
// api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
// msgs := ctx.GetMessages()
// if len(msgs) == 0 {
// return
// }
// last := msgs[len(msgs)-1].Content
// if strings.Contains(last, "<HANDOFF_READY>") {
// go func() {
// _ = ctx.NewSession("Read @HANDOFF.md and continue the next phase.")
// }()
// }
// })
NewSession func(prompt string) error
// GetSessionUsage returns aggregated token usage and cost statistics
// for the current session. This includes total input/output tokens,
// cache read/write tokens, total cost, and request count.
@@ -777,8 +716,7 @@ type Context struct {
LoadSkillsFromDir func(dir string) SkillLoadResult
// DiscoverSkills finds skills in standard locations.
// Checks ~/.agents/skills/, ~/.config/kit/skills/, <project>/.agents/skills/,
// and <project>/.kit/skills/.
// Checks ~/.config/kit/skills/, .kit/skills/, .agents/skills/
DiscoverSkills func() SkillLoadResult
// InjectSkillAsContext sends a skill's content as a system message.
@@ -910,24 +848,9 @@ type Skill struct {
Content string
// Path is the absolute filesystem path.
Path string
// License is an optional SPDX license identifier (agentskills.io field).
License string
// Compatibility is an optional note describing targeted environments
// (agentskills.io field).
Compatibility string
// Metadata is an optional bag of arbitrary string key/value pairs
// (agentskills.io field).
Metadata map[string]string
// AllowedTools optionally restricts which tools the skill may use
// (experimental agentskills.io field).
AllowedTools string
// DisableModelInvocation hides the skill from the model-facing catalog
// while keeping it available via explicit activation (agentskills.io field).
DisableModelInvocation bool
// Tags are optional labels for categorization. Kit extension.
// Tags are optional labels for categorization.
Tags []string
// When controls automatic inclusion: "always", "on-demand", or file-glob.
// Kit extension.
When string
}
@@ -2373,12 +2296,6 @@ type BeforeSessionSwitchEvent struct {
// Reason describes why the switch is happening: "new" for /new command,
// "clear" for /clear command.
Reason string
// InitialPrompt, when non-empty, is the prompt that will be submitted
// as the first user turn of the new session. Set when /new is invoked
// with an argument (e.g. "/new continue from HANDOFF.md") or when an
// extension calls ctx.NewSession(prompt). Extensions may inspect this
// to decide whether to allow the switch.
InitialPrompt string
}
func (e BeforeSessionSwitchEvent) Type() EventType { return BeforeSessionSwitch }
+2 -6
View File
@@ -372,12 +372,8 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
Handlers: make(map[EventType][]HandlerFunc),
}
// Create a fresh interpreter. Yaegi runs extensions in restricted mode,
// where os.Getenv/os.LookupEnv/os.Environ read from a virtualized
// environment rather than the real one. Seed it with the process
// environment so extensions can read variables (e.g. CI-provided ones
// like GITHUB_EVENT_PATH) without being able to mutate the host's env.
i := interp.New(interp.Options{Env: os.Environ()})
// Create a fresh interpreter.
i := interp.New(interp.Options{})
// Expose the Go stdlib. The base set covers most packages; the
// unrestricted set adds os/exec so extensions can spawn processes.
-3
View File
@@ -192,9 +192,6 @@ func normalizeContext(ctx Context) Context {
if ctx.SendMultimodalMessage == nil {
ctx.SendMultimodalMessage = func(string, []FilePart) {}
}
if ctx.NewSession == nil {
ctx.NewSession = func(string) error { return fmt.Errorf("new session not available") }
}
if ctx.GetSessionUsage == nil {
ctx.GetSessionUsage = func() SessionUsage { return SessionUsage{} }
}
-5
View File
@@ -28,11 +28,6 @@ func Symbols() interp.Exports {
"CommandDef": reflect.ValueOf((*CommandDef)(nil)),
"PrintBlockOpts": reflect.ValueOf((*PrintBlockOpts)(nil)),
// Sentinel errors. Extensions detect them with errors.Is:
//
// if errors.Is(err, ext.ErrAgentBusy) { ... }
"ErrAgentBusy": reflect.ValueOf(&ErrAgentBusy).Elem(),
// Session types
"SessionMessage": reflect.ValueOf((*SessionMessage)(nil)),
"ExtensionEntry": reflect.ValueOf((*ExtensionEntry)(nil)),
+1 -11
View File
@@ -53,11 +53,6 @@ type AgentSetupOptions struct {
// Debug enables debug logging. When zero-value, viper is consulted.
// Only meaningful when ProviderConfig is also set.
Debug bool
// DebugLogger, if non-nil, is used directly as the engine/MCP debug
// logger — overriding the built-in SimpleDebugLogger / BufferedDebugLogger
// selected by Debug + UseBufferedLogger. Callers supply this when they
// want to route debug output into their own logging system.
DebugLogger tools.DebugLogger
// NoExtensions skips extension loading. When false, viper is consulted.
// Only meaningful when ProviderConfig is also set.
NoExtensions bool
@@ -197,12 +192,7 @@ func SetupAgent(ctx context.Context, opts AgentSetupOptions) (*AgentSetupResult,
// Create the appropriate debug logger.
var debugLogger tools.DebugLogger
var bufferedLogger *tools.BufferedDebugLogger
switch {
case opts.DebugLogger != nil:
// Caller-supplied logger wins unconditionally. Its IsDebugEnabled()
// is the source of truth for whether downstream code emits messages.
debugLogger = opts.DebugLogger
case debugEnabled:
if debugEnabled {
if opts.UseBufferedLogger {
bufferedLogger = tools.NewBufferedDebugLogger(true)
debugLogger = bufferedLogger
+18 -20
View File
@@ -44,14 +44,13 @@ func loadCustomModelsFrom(v *viper.Viper) map[string]ModelInfo {
// modelConfigToModelInfo converts a CustomModelConfig to a ModelInfo.
func modelConfigToModelInfo(modelID string, cfg CustomModelConfig) ModelInfo {
info := ModelInfo{
ID: modelID,
Name: cfg.Name,
Attachment: cfg.Attachment,
Reasoning: cfg.Reasoning,
Temperature: cfg.Temperature,
BaseURL: cfg.BaseURL,
APIKey: cfg.APIKey,
APIModelName: cfg.APIModelName,
ID: modelID,
Name: cfg.Name,
Attachment: cfg.Attachment,
Reasoning: cfg.Reasoning,
Temperature: cfg.Temperature,
BaseURL: cfg.BaseURL,
APIKey: cfg.APIKey,
Cost: Cost{
Input: cfg.Cost.Input,
Output: cfg.Cost.Output,
@@ -288,18 +287,17 @@ type GenerationParams struct {
// CustomModelConfig defines a custom model configuration loaded from the config file.
// This is a duplicate here to avoid circular dependencies with internal/config.
type CustomModelConfig struct {
Name string `json:"name" yaml:"name"`
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
APIModelName string `json:"apiModelName,omitempty" yaml:"apiModelName,omitempty"`
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"`
Params GenerationParamsConfig `json:"params,omitzero" yaml:"params,omitempty"`
Name string `json:"name" yaml:"name"`
BaseURL string `json:"baseUrl,omitempty" yaml:"baseUrl,omitempty"`
APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"`
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"`
Params GenerationParamsConfig `json:"params,omitzero" yaml:"params,omitempty"`
}
// GenerationParamsConfig is the JSON/YAML-serializable form of generation
File diff suppressed because one or more lines are too long
+1 -6
View File
@@ -1533,12 +1533,7 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName
return nil, wrapProviderErr("custom", "provider", err)
}
apiModelName := modelName
if modelInfo != nil && modelInfo.APIModelName != "" {
apiModelName = modelInfo.APIModelName
}
model, err := p.LanguageModel(ctx, apiModelName)
model, err := p.LanguageModel(ctx, modelName)
if err != nil {
return nil, wrapProviderErr("custom", "model", err)
}
+11 -12
View File
@@ -16,18 +16,17 @@ var embeddedModelsJSON []byte
// ModelInfo represents information about a specific model.
type ModelInfo struct {
ID string
Name string
Family string // Model family (e.g., "claude", "gpt", "gemini")
Attachment bool
Reasoning bool
Temperature bool
Cost Cost
Limit Limit
ProviderNPM string // Model-specific provider npm override (e.g. "@ai-sdk/anthropic")
BaseURL string // Per-model base URL override (custom models only)
APIKey string // Per-model API key override (custom models only)
APIModelName string // Per-model API model name override (custom models only)
ID string
Name string
Family string // Model family (e.g., "claude", "gpt", "gemini")
Attachment bool
Reasoning bool
Temperature bool
Cost Cost
Limit Limit
ProviderNPM string // Model-specific provider npm override (e.g. "@ai-sdk/anthropic")
BaseURL string // Per-model base URL override (custom models only)
APIKey string // Per-model API key override (custom models only)
// Params holds per-model generation parameter defaults. These are applied
// when the user hasn't explicitly set the corresponding CLI flag or global
+1 -1
View File
@@ -50,7 +50,7 @@ func TestPromptBuilder_WithSkills(t *testing.T) {
if !strings.Contains(result, "<description>Write code</description>") {
t.Error("missing skill description in XML")
}
if !strings.Contains(result, "<location>/tmp/coding/SKILL.md</location>") {
if !strings.Contains(result, "<location>file:///tmp/coding/SKILL.md</location>") {
t.Error("missing skill location")
}
}
+54 -436
View File
@@ -2,173 +2,40 @@
//
// Skills are markdown instruction files with optional YAML frontmatter that
// provide domain-specific context, instructions, and workflows to the agent.
// They follow the cross-client agentskills.io discovery convention plus a
// Kit-native location:
// They follow a hierarchical discovery pattern similar to extensions:
//
// ~/.agents/skills/ user-level cross-client skills
// ~/.config/kit/skills/ user-level Kit skills ($XDG_CONFIG_HOME aware)
// <project>/.agents/skills/ project-local cross-client skills
// <project>/.kit/skills/ project-local Kit skills
// ~/.config/kit/skills/ global skills directory
// .kit/skills/ project-local skills directory
//
// Skills can be single .md/.txt files or subdirectories containing a SKILL.md
// file. Project-level skills take precedence over user-level skills when two
// skills share the same name.
// Skills can be single .md/.txt files or subdirectories containing a SKILL.md file.
package skills
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/charmbracelet/log"
"gopkg.in/yaml.v3"
)
// Skill represents a markdown-based instruction file that provides
// domain-specific context and workflows to the agent.
//
// The Name and Description fields are required by the agentskills.io
// specification. License, Compatibility, Metadata, and AllowedTools are
// optional spec fields. Tags and When are Kit-specific extensions that other
// clients ignore.
type Skill struct {
// Name is the human-readable identifier for this skill. Required.
// Name is the human-readable identifier for this skill.
Name string `yaml:"name" json:"name"`
// Description summarises what this skill provides and when to use it.
// Required by the spec — it is the sole basis on which the model decides
// whether a skill is relevant, so a skill without one is omitted from the
// catalog.
// Description summarises what this skill provides.
Description string `yaml:"description" json:"description"`
// Content is the full markdown body (after frontmatter).
Content string `yaml:"-" json:"content"`
// Path is the absolute filesystem path the skill was loaded from.
Path string `yaml:"-" json:"path"`
// License is an optional SPDX license identifier (spec field).
License string `yaml:"license,omitempty" json:"license,omitempty"`
// Compatibility is an optional free-form note describing the environments
// or clients the skill targets (spec field). The model can use it to adapt
// execution.
Compatibility string `yaml:"compatibility,omitempty" json:"compatibility,omitempty"`
// Metadata is an optional bag of arbitrary string key/value pairs (spec
// field) for client-specific annotations.
Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
// AllowedTools optionally restricts which tools the skill may use. This is
// an experimental spec field carried for portability; Kit does not yet
// enforce it.
AllowedTools string `yaml:"allowed-tools,omitempty" json:"allowed_tools,omitempty"`
// DisableModelInvocation, when true, hides the skill from the
// model-facing catalog (spec field). The skill can still be activated
// explicitly via the /skill: slash command.
DisableModelInvocation bool `yaml:"disable-model-invocation,omitempty" json:"disable_model_invocation,omitempty"`
// Tags are optional labels for categorisation. Kit extension.
// Tags are optional labels for categorisation.
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
// When controls automatic inclusion: "always", "on-demand", or a
// file-glob like "file:*.go". Empty defaults to "on-demand". Kit extension.
// file-glob like "file:*.go". Empty defaults to "on-demand".
When string `yaml:"when,omitempty" json:"when,omitempty"`
// project records whether the skill was discovered in a project-local
// scope. Used internally for name-collision precedence (project > user).
project bool `yaml:"-" json:"-"`
}
// Diagnostic describes a validation problem with a skill. Severity is either
// "error" (the skill cannot be used) or "warning" (the skill is usable but
// non-compliant).
type Diagnostic struct {
// Severity is "error" or "warning".
Severity string `json:"severity"`
// Field names the frontmatter field the diagnostic relates to, if any.
Field string `json:"field,omitempty"`
// Message is a human-readable description of the problem.
Message string `json:"message"`
}
// Validate checks the skill against the agentskills.io specification and
// returns a list of diagnostics. An empty slice means the skill is fully
// compliant. A missing description is reported as an error because the spec
// makes it required for discovery.
func (s *Skill) Validate() []Diagnostic {
var diags []Diagnostic
if strings.TrimSpace(s.Name) == "" {
diags = append(diags, Diagnostic{Severity: "error", Field: "name", Message: "name is required"})
}
if strings.TrimSpace(s.Description) == "" {
diags = append(diags, Diagnostic{
Severity: "error",
Field: "description",
Message: "description is required for skill discovery",
})
}
return diags
}
// hasError reports whether diags contains a diagnostic with "error" severity.
func hasError(diags []Diagnostic) bool {
for _, d := range diags {
if d.Severity == "error" {
return true
}
}
return false
}
// BaseDir returns the directory the skill was loaded from. Relative resources
// referenced by a skill (scripts/, references/, assets/) resolve against this
// directory.
func (s *Skill) BaseDir() string {
if s.Path == "" {
return ""
}
return filepath.Dir(s.Path)
}
// resourceDirs are the conventional subdirectories a skill may bundle.
var resourceDirs = []string{"scripts", "references", "assets"}
// maxResources caps how many bundled resources are enumerated to avoid
// flooding the prompt for skills with large asset trees.
const maxResources = 50
// Resources walks one level into the skill's scripts/, references/, and
// assets/ subdirectories and returns the relative paths of any files found
// (slash-separated, relative to BaseDir). The result is capped at 50 entries.
// It returns nil when the skill has no bundled resources or its Path is not a
// real on-disk file.
func (s *Skill) Resources() []string {
base := s.BaseDir()
if base == "" {
return nil
}
var out []string
for _, sub := range resourceDirs {
dir := filepath.Join(base, sub)
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
continue
}
out = append(out, sub+"/"+e.Name())
if len(out) >= maxResources {
sort.Strings(out)
return out
}
}
}
sort.Strings(out)
return out
}
// frontmatterSep is the YAML frontmatter delimiter.
@@ -188,14 +55,7 @@ func LoadSkill(path string) (*Skill, error) {
abs = path
}
return parseSkill(data, path, abs)
}
// parseSkill parses skill bytes that originated from srcPath (used for error
// messages and name derivation) and records storePath as the skill's Path.
// It is shared by the os-backed and fs.FS-backed loaders.
func parseSkill(data []byte, srcPath, storePath string) (*Skill, error) {
skill := &Skill{Path: storePath}
skill := &Skill{Path: abs}
content := string(data)
@@ -209,8 +69,8 @@ func parseSkill(data []byte, srcPath, storePath string) (*Skill, error) {
// Strip an optional trailing newline right after the closing ---.
body = strings.TrimPrefix(body, "\n")
if err := unmarshalFrontmatter([]byte(frontmatter), skill); err != nil {
return nil, fmt.Errorf("parsing frontmatter in %s: %w", srcPath, err)
if err := yaml.Unmarshal([]byte(frontmatter), skill); err != nil {
return nil, fmt.Errorf("parsing frontmatter in %s: %w", path, err)
}
skill.Content = strings.TrimSpace(body)
} else {
@@ -223,69 +83,18 @@ func parseSkill(data []byte, srcPath, storePath string) (*Skill, error) {
// Fallback: derive name from filename if frontmatter didn't set one.
if skill.Name == "" {
base := filepath.Base(srcPath)
base := filepath.Base(path)
ext := filepath.Ext(base)
skill.Name = strings.TrimSuffix(base, ext)
// Convert SKILL → directory name for SKILL.md files.
if strings.EqualFold(skill.Name, "SKILL") || strings.EqualFold(skill.Name, "skill") {
skill.Name = filepath.Base(filepath.Dir(srcPath))
skill.Name = filepath.Base(filepath.Dir(path))
}
}
return skill, nil
}
// unquotedColonRe matches a YAML scalar line whose value contains an unquoted
// colon, e.g. `description: Use when: extracting tables`. This is the most
// common frontmatter authoring mistake in cross-client skills and breaks
// strict YAML parsing.
var unquotedColonRe = regexp.MustCompile(`^(\s*[A-Za-z0-9_-]+):[ \t]+([^'"\n].*:.*)$`)
// unmarshalFrontmatter unmarshals YAML frontmatter into skill, tolerating the
// common "unquoted colon in a scalar value" mistake (e.g.
// `description: Use when: …`). On a parse failure it quotes offending scalar
// values and retries once before giving up.
func unmarshalFrontmatter(frontmatter []byte, skill *Skill) error {
err := yaml.Unmarshal(frontmatter, skill)
if err == nil {
return nil
}
// Attempt a single recovery pass: quote scalar values that contain an
// unquoted colon, which is the dominant cross-client failure mode.
repaired, changed := repairUnquotedColons(string(frontmatter))
if !changed {
return err
}
if retryErr := yaml.Unmarshal([]byte(repaired), skill); retryErr != nil {
// The original error is more useful to the author.
return err
}
return nil
}
// repairUnquotedColons quotes scalar values containing an unquoted colon and
// reports whether any line was changed.
func repairUnquotedColons(frontmatter string) (string, bool) {
lines := strings.Split(frontmatter, "\n")
changed := false
for i, line := range lines {
m := unquotedColonRe.FindStringSubmatch(line)
if m == nil {
continue
}
key, value := m[1], strings.TrimRight(m[2], " \t")
// Escape embedded double quotes before wrapping.
value = strings.ReplaceAll(value, `"`, `\"`)
lines[i] = fmt.Sprintf(`%s: "%s"`, key, value)
changed = true
}
if !changed {
return frontmatter, false
}
return strings.Join(lines, "\n"), true
}
// LoadSkillsFromDir loads all skills from a single directory. It looks for:
// - *.md and *.txt files directly in dir
// - SKILL.md (case-insensitive) in immediate subdirectories
@@ -304,7 +113,7 @@ func LoadSkillsFromDir(dir string) ([]*Skill, error) {
}
var skills []*Skill
var errs []error
var errs []string
for _, entry := range entries {
full := filepath.Join(dir, entry.Name())
@@ -314,7 +123,7 @@ func LoadSkillsFromDir(dir string) ([]*Skill, error) {
if ext == ".md" || ext == ".txt" {
s, err := LoadSkill(full)
if err != nil {
errs = append(errs, err)
errs = append(errs, err.Error())
continue
}
skills = append(skills, s)
@@ -331,7 +140,7 @@ func LoadSkillsFromDir(dir string) ([]*Skill, error) {
if !se.IsDir() && strings.EqualFold(se.Name(), "SKILL.md") {
s, err := LoadSkill(filepath.Join(full, se.Name()))
if err != nil {
errs = append(errs, err)
errs = append(errs, err.Error())
continue
}
skills = append(skills, s)
@@ -341,204 +150,59 @@ func LoadSkillsFromDir(dir string) ([]*Skill, error) {
}
if len(errs) > 0 {
return skills, fmt.Errorf("some skills failed to load: %w", errors.Join(errs...))
return skills, fmt.Errorf("some skills failed to load: %s", strings.Join(errs, "; "))
}
return skills, nil
}
// LoadSkillsFromFS is the fs.FS-typed counterpart of LoadSkillsFromDir. It
// walks fsys starting at root (which may be "." or a subdirectory), finds
// *.md and *.txt files plus SKILL.md files in subdirectories, parses YAML
// frontmatter + markdown body, and returns the loaded skills.
// LoadSkills auto-discovers skills from standard directories:
// 1. Global: $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
// 2. Project-local: <cwd>/.kit/skills/
//
// Because fs.FS has no notion of an absolute on-disk path, each loaded skill's
// Path is set to its slash-separated path within fsys. Files that fail to
// parse are skipped and reported via the returned error.
func LoadSkillsFromFS(fsys fs.FS, root string) ([]*Skill, error) {
if fsys == nil {
return nil, nil
}
if root == "" {
root = "."
}
var skills []*Skill
var errs []error
walkErr := fs.WalkDir(fsys, root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable entries rather than aborting the walk
}
if d.IsDir() {
return nil
}
name := d.Name()
ext := strings.ToLower(path.Ext(name))
if ext != ".md" && ext != ".txt" {
return nil
}
// Top-level .md/.txt files, or SKILL.md anywhere.
isTopLevel := path.Dir(p) == root
if !isTopLevel && !strings.EqualFold(name, "SKILL.md") {
return nil
}
data, readErr := fs.ReadFile(fsys, p)
if readErr != nil {
errs = append(errs, fmt.Errorf("reading skill %s: %w", p, readErr))
return nil
}
s, parseErr := parseSkill(data, p, p)
if parseErr != nil {
errs = append(errs, parseErr)
return nil
}
skills = append(skills, s)
return nil
})
if walkErr != nil {
return skills, fmt.Errorf("walking skills fs at %s: %w", root, walkErr)
}
if len(errs) > 0 {
return skills, fmt.Errorf("some skills failed to load: %w", errors.Join(errs...))
}
return skills, nil
}
// LoadUserSkills discovers skills from the user-level scopes only:
//
// 1. ~/.agents/skills/ (cross-client convention)
// 2. $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
//
// The returned skills are not yet validated or deduplicated; pass them through
// Combine together with project skills to produce the final catalog set.
func LoadUserSkills() []*Skill {
var loaded []*Skill
if home, err := os.UserHomeDir(); err == nil && home != "" {
dir := filepath.Join(home, ".agents", "skills")
ss, loadErr := LoadSkillsFromDir(dir)
if loadErr != nil {
// Missing directories are already swallowed by LoadSkillsFromDir,
// so a non-nil error here is genuine (permission denied, read
// failure, or a malformed skill file) and would otherwise yield a
// silently partial catalog.
log.Warn("failed to load some user skills", "dir", dir, "err", loadErr)
}
loaded = append(loaded, ss...)
}
if g := globalSkillsDir(); g != "" {
ss, loadErr := LoadSkillsFromDir(g)
if loadErr != nil {
log.Warn("failed to load some user skills", "dir", g, "err", loadErr)
}
loaded = append(loaded, ss...)
}
for _, s := range loaded {
s.project = false
}
return loaded
}
// LoadProjectSkills discovers skills from the project-local scopes only:
//
// 1. <cwd>/.agents/skills/ (cross-client convention)
// 2. <cwd>/.kit/skills/ (Kit-specific)
//
// Because project-local skills are injected into the system prompt, callers
// may wish to gate this on a trust check before including the result. The
// returned skills are not yet validated or deduplicated; pass them through
// Combine.
func LoadProjectSkills(cwd string) []*Skill {
// Skills from project-local directories take precedence (appended last).
// cwd is the working directory for project-local discovery; if empty the
// current working directory is used.
func LoadSkills(cwd string) ([]*Skill, error) {
if cwd == "" {
cwd, _ = os.Getwd()
}
var loaded []*Skill
for _, dir := range []string{
filepath.Join(cwd, ".agents", "skills"),
filepath.Join(cwd, ".kit", "skills"),
} {
ss, loadErr := LoadSkillsFromDir(dir)
if loadErr != nil {
log.Warn("failed to load some project skills", "dir", dir, "err", loadErr)
}
loaded = append(loaded, ss...)
}
for _, s := range loaded {
s.project = true
}
return loaded
}
// Combine validates and deduplicates the union of user-level and project-level
// skills. Skills missing a required description are skipped with a logged
// warning; when two skills share a Name the project-level one wins (also
// logged). User skills are considered before project skills so first-seen
// ordering is stable.
func Combine(user, project []*Skill) []*Skill {
combined := make([]*Skill, 0, len(user)+len(project))
combined = append(combined, user...)
combined = append(combined, project...)
return finalizeSkills(combined)
}
seen := make(map[string]bool)
var all []*Skill
// LoadSkills auto-discovers skills from the standard agentskills.io scopes:
//
// 1. User-level: ~/.agents/skills/ (cross-client convention)
// 2. User-level: $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
// 3. Project-local: <cwd>/.agents/skills/ (cross-client convention)
// 4. Project-local: <cwd>/.kit/skills/ (Kit-specific)
//
// When two skills share the same Name, the project-level skill takes
// precedence over a user-level one and a warning is logged. cwd is the working
// directory for project-local discovery; if empty the current working
// directory is used.
func LoadSkills(cwd string) ([]*Skill, error) {
return Combine(LoadUserSkills(), LoadProjectSkills(cwd)), nil
}
// finalizeSkills applies validation (skipping skills missing a required
// description) and name-collision precedence (project overrides user). It
// preserves first-seen ordering for stable catalog output.
func finalizeSkills(loaded []*Skill) []*Skill {
byName := make(map[string]int) // name → index in result
var result []*Skill
for _, s := range loaded {
if diags := s.Validate(); hasError(diags) {
for _, d := range diags {
if d.Severity == "error" {
log.Warn("skipping skill: validation failed", "path", s.Path, "field", d.Field, "reason", d.Message)
}
addUnique := func(skills []*Skill) {
for _, s := range skills {
if !seen[s.Path] {
seen[s.Path] = true
all = append(all, s)
}
continue
}
if idx, ok := byName[s.Name]; ok {
existing := result[idx]
// Project-level skills override user-level skills.
if s.project && !existing.project {
log.Warn("skill name collision: project skill overrides user skill",
"name", s.Name, "project", s.Path, "user", existing.Path)
result[idx] = s
} else {
log.Warn("skill name collision: keeping earlier skill, ignoring duplicate",
"name", s.Name, "kept", existing.Path, "ignored", s.Path)
}
continue
}
byName[s.Name] = len(result)
result = append(result, s)
}
return result
// Global skills.
globalDir := globalSkillsDir()
if globalDir != "" {
global, _ := LoadSkillsFromDir(globalDir)
addUnique(global)
}
// Project-local skills: .agents/skills/ (standardized cross-tool convention).
agentsDir := filepath.Join(cwd, ".agents", "skills")
agentsSkills, _ := LoadSkillsFromDir(agentsDir)
addUnique(agentsSkills)
// Project-local skills: .kit/skills/ (kit-specific).
localDir := filepath.Join(cwd, ".kit", "skills")
local, _ := LoadSkillsFromDir(localDir)
addUnique(local)
return all, nil
}
// FormatForPrompt formats skills as metadata-only XML for inclusion in a
// system prompt. Only the name, description, and file location are included;
// the agent reads the full skill file on demand using the read tool. Skill
// fields are XML-escaped so that descriptions containing <, >, & or quotes
// produce valid markup. Skills with DisableModelInvocation set are omitted
// from the catalog (they remain available via the /skill: slash command).
// the agent reads the full skill file on demand using the read tool. This
func FormatForPrompt(skills []*Skill) string {
if len(skills) == 0 {
return ""
@@ -550,63 +214,17 @@ func FormatForPrompt(skills []*Skill) string {
buf.WriteString("When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md) and use that absolute path in tool commands.\n")
buf.WriteString("\n<available_skills>\n")
emitted := 0
for _, s := range skills {
if s.DisableModelInvocation {
continue
}
buf.WriteString(" <skill>\n")
fmt.Fprintf(&buf, " <name>%s</name>\n", escapeXML(s.Name))
fmt.Fprintf(&buf, " <name>%s</name>\n", s.Name)
if s.Description != "" {
fmt.Fprintf(&buf, " <description>%s</description>\n", escapeXML(s.Description))
fmt.Fprintf(&buf, " <description>%s</description>\n", s.Description)
}
if s.Compatibility != "" {
fmt.Fprintf(&buf, " <compatibility>%s</compatibility>\n", escapeXML(s.Compatibility))
}
fmt.Fprintf(&buf, " <location>%s</location>\n", escapeXML(s.Path))
fmt.Fprintf(&buf, " <location>file://%s</location>\n", s.Path)
buf.WriteString(" </skill>\n")
emitted++
}
buf.WriteString("</available_skills>")
if emitted == 0 {
return ""
}
return buf.String()
}
// escapeXML escapes a string for safe inclusion as XML text content.
func escapeXML(s string) string {
var buf bytes.Buffer
if err := xml.EscapeText(&buf, []byte(s)); err != nil {
return s
}
return buf.String()
}
// FormatResources renders a skill's bundled resources as a <skill_resources>
// block, or returns the empty string when the skill bundles no resources. It
// is used when a skill is explicitly activated so the model knows which files
// it can read without enumerating them itself.
func FormatResources(resources []string) string {
if len(resources) == 0 {
return ""
}
var buf bytes.Buffer
buf.WriteString("<skill_resources>\n")
limit := len(resources)
truncated := false
if limit > maxResources {
limit = maxResources
truncated = true
}
for _, r := range resources[:limit] {
fmt.Fprintf(&buf, " <file>%s</file>\n", escapeXML(r))
}
if truncated {
buf.WriteString(" <!-- (truncated) -->\n")
}
buf.WriteString("</skill_resources>")
return buf.String()
}
-70
View File
@@ -1,70 +0,0 @@
package skills
import (
"testing"
"testing/fstest"
)
func TestLoadSkillsFromFS(t *testing.T) {
fsys := fstest.MapFS{
"top.md": {Data: []byte("---\nname: top-skill\ndescription: a top level skill\n---\nbody here")},
"notes.txt": {Data: []byte("plain text skill")},
"deep/SKILL.md": {Data: []byte("---\nname: deep-skill\n---\ndeep body")},
"deep/other.md": {Data: []byte("ignored non-SKILL nested md")},
"ignore.json": {Data: []byte("{}")},
}
got, err := LoadSkillsFromFS(fsys, ".")
if err != nil {
t.Fatalf("LoadSkillsFromFS error: %v", err)
}
byName := map[string]*Skill{}
for _, s := range got {
byName[s.Name] = s
}
if _, ok := byName["top-skill"]; !ok {
t.Errorf("top-skill not loaded; got %v", names(got))
}
if _, ok := byName["notes"]; !ok {
t.Errorf("notes (txt) not loaded; got %v", names(got))
}
if _, ok := byName["deep-skill"]; !ok {
t.Errorf("deep SKILL.md not loaded; got %v", names(got))
}
if _, ok := byName["other"]; ok {
t.Errorf("nested non-SKILL .md should be ignored; got %v", names(got))
}
if len(got) != 3 {
t.Errorf("expected 3 skills, got %d: %v", len(got), names(got))
}
// Content/description parsed from frontmatter.
if s := byName["top-skill"]; s != nil {
if s.Description != "a top level skill" {
t.Errorf("description = %q", s.Description)
}
if s.Content != "body here" {
t.Errorf("content = %q", s.Content)
}
if s.Path != "top.md" {
t.Errorf("path = %q, want top.md", s.Path)
}
}
}
func TestLoadSkillsFromFSNil(t *testing.T) {
got, err := LoadSkillsFromFS(nil, ".")
if err != nil || got != nil {
t.Fatalf("nil fs should yield (nil, nil), got (%v, %v)", got, err)
}
}
func names(skills []*Skill) []string {
out := make([]string, 0, len(skills))
for _, s := range skills {
out = append(out, s.Name)
}
return out
}
+22 -204
View File
@@ -243,24 +243,15 @@ func TestLoadSkillsFromDir_CaseInsensitiveSKILLmd(t *testing.T) {
// LoadSkills (auto-discovery)
// ---------------------------------------------------------------------------
func writeSkill(t *testing.T, path, name, desc, body string) {
t.Helper()
content := "---\nname: " + name + "\ndescription: " + desc + "\n---\n" + body
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoadSkills_ProjectLocal(t *testing.T) {
dir := t.TempDir()
// Isolate user-level scopes so the host machine's skills don't leak in.
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "xdg"))
t.Setenv("HOME", filepath.Join(dir, "home"))
skillsDir := filepath.Join(dir, ".kit", "skills")
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
t.Fatal(err)
}
writeSkill(t, filepath.Join(skillsDir, "local.md"), "local", "A local skill", "Local skill")
if err := os.WriteFile(filepath.Join(skillsDir, "local.md"), []byte("Local skill"), 0o644); err != nil {
t.Fatal(err)
}
skills, err := LoadSkills(dir)
if err != nil {
@@ -274,64 +265,37 @@ func TestLoadSkills_ProjectLocal(t *testing.T) {
}
}
// TestLoadSkills_SkipsMissingDescription verifies that a skill without a
// required description is skipped during auto-discovery (gap #2).
func TestLoadSkills_SkipsMissingDescription(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "xdg"))
t.Setenv("HOME", filepath.Join(dir, "home"))
skillsDir := filepath.Join(dir, ".kit", "skills")
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
t.Fatal(err)
}
// No description — should be skipped.
if err := os.WriteFile(filepath.Join(skillsDir, "nodesc.md"), []byte("Just a body"), 0o644); err != nil {
t.Fatal(err)
}
writeSkill(t, filepath.Join(skillsDir, "good.md"), "good", "Has a description", "Body")
skills, err := LoadSkills(dir)
if err != nil {
t.Fatal(err)
}
if len(skills) != 1 {
t.Fatalf("expected 1 skill (missing-description skipped), got %d", len(skills))
}
if skills[0].Name != "good" {
t.Errorf("Name = %q, want %q", skills[0].Name, "good")
}
}
// TestLoadSkills_NameCollisionPrecedence verifies project-level skills override
// user-level skills with the same name (gap #5).
func TestLoadSkills_NameCollisionPrecedence(t *testing.T) {
func TestLoadSkills_Deduplication(t *testing.T) {
dir := t.TempDir()
// Set XDG_CONFIG_HOME so the user-level skill lives under our temp dir.
// Set XDG_CONFIG_HOME to our temp dir so global and local overlap.
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("HOME", filepath.Join(dir, "home"))
userDir := filepath.Join(dir, "kit", "skills")
projectDir := filepath.Join(dir, ".kit", "skills")
if err := os.MkdirAll(userDir, 0o755); err != nil {
globalDir := filepath.Join(dir, "kit", "skills")
localDir := filepath.Join(dir, ".kit", "skills")
if err := os.MkdirAll(globalDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(projectDir, 0o755); err != nil {
if err := os.MkdirAll(localDir, 0o755); err != nil {
t.Fatal(err)
}
writeSkill(t, filepath.Join(userDir, "shared.md"), "shared", "User version", "USER")
writeSkill(t, filepath.Join(projectDir, "shared.md"), "shared", "Project version", "PROJECT")
// Same content in both directories but different paths — both should load.
if err := os.WriteFile(filepath.Join(globalDir, "shared.md"), []byte("Global version"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(localDir, "shared.md"), []byte("Local version"), 0o644); err != nil {
t.Fatal(err)
}
skills, err := LoadSkills(dir)
if err != nil {
t.Fatal(err)
}
if len(skills) != 1 {
t.Fatalf("expected 1 skill (deduped by name), got %d", len(skills))
}
if !strings.Contains(skills[0].Content, "PROJECT") {
t.Errorf("expected project version to win, got content %q", skills[0].Content)
// Different absolute paths = both loaded.
if len(skills) != 2 {
t.Fatalf("expected 2 skills (different paths), got %d", len(skills))
}
}
@@ -357,8 +321,8 @@ func TestFormatForPrompt_SingleSkill(t *testing.T) {
if !strings.Contains(result, "<description>A test</description>") {
t.Errorf("result should contain description in XML")
}
if !strings.Contains(result, "<location>/tmp/test-skill/SKILL.md</location>") {
t.Errorf("result should contain bare file location (no file:// prefix)")
if !strings.Contains(result, "<location>file:///tmp/test-skill/SKILL.md</location>") {
t.Errorf("result should contain file location")
}
if !strings.Contains(result, "<available_skills>") {
t.Errorf("result should contain available_skills root element")
@@ -388,149 +352,3 @@ func TestFormatForPrompt_MultipleSkills(t *testing.T) {
t.Error("missing preamble instructions")
}
}
// ---------------------------------------------------------------------------
// agentskills.io spec compliance (issue #65)
// ---------------------------------------------------------------------------
// TestFormatForPrompt_XMLEscaping verifies special characters in name and
// description are escaped so the catalog is valid XML (gap #1).
func TestFormatForPrompt_XMLEscaping(t *testing.T) {
skills := []*Skill{
{Name: "a&b", Description: "use when <tag> & \"quoted\"", Path: "/tmp/x"},
}
result := FormatForPrompt(skills)
if strings.Contains(result, "<tag>") {
t.Errorf("raw < should have been escaped, got: %q", result)
}
if !strings.Contains(result, "&lt;tag&gt;") {
t.Errorf("expected escaped <tag>, got: %q", result)
}
if !strings.Contains(result, "a&amp;b") {
t.Errorf("expected escaped ampersand in name, got: %q", result)
}
}
// TestFormatForPrompt_DisableModelInvocation verifies that a skill flagged
// disable-model-invocation is omitted from the catalog (gap #10).
func TestFormatForPrompt_DisableModelInvocation(t *testing.T) {
skills := []*Skill{
{Name: "visible", Description: "shown", Path: "/tmp/a"},
{Name: "hidden", Description: "not shown", Path: "/tmp/b", DisableModelInvocation: true},
}
result := FormatForPrompt(skills)
if !strings.Contains(result, "<name>visible</name>") {
t.Error("visible skill should be in catalog")
}
if strings.Contains(result, "<name>hidden</name>") {
t.Error("disable-model-invocation skill should be omitted from catalog")
}
}
// TestLoadSkill_NewSpecFields verifies the new frontmatter fields parse (gap #6).
func TestLoadSkill_NewSpecFields(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "spec.md")
content := `---
name: spec-skill
description: A spec-compliant skill
license: MIT
compatibility: claude-code, cursor
allowed-tools: read, bash
disable-model-invocation: true
metadata:
author: jane
version: "1.2"
---
Body.`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s, err := LoadSkill(path)
if err != nil {
t.Fatal(err)
}
if s.License != "MIT" {
t.Errorf("License = %q, want MIT", s.License)
}
if s.Compatibility != "claude-code, cursor" {
t.Errorf("Compatibility = %q", s.Compatibility)
}
if s.AllowedTools != "read, bash" {
t.Errorf("AllowedTools = %q", s.AllowedTools)
}
if !s.DisableModelInvocation {
t.Error("DisableModelInvocation should be true")
}
if s.Metadata["author"] != "jane" || s.Metadata["version"] != "1.2" {
t.Errorf("Metadata = %v", s.Metadata)
}
}
// TestLoadSkill_UnquotedColonFallback verifies the YAML repair fallback for
// the common `description: Use when: ...` mistake (gap #9).
func TestLoadSkill_UnquotedColonFallback(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "colon.md")
content := "---\nname: colon-skill\ndescription: Use when: extracting tables from PDFs\n---\nBody."
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
s, err := LoadSkill(path)
if err != nil {
t.Fatalf("expected unquoted-colon fallback to succeed, got error: %v", err)
}
if s.Name != "colon-skill" {
t.Errorf("Name = %q", s.Name)
}
if !strings.Contains(s.Description, "extracting tables") {
t.Errorf("Description = %q", s.Description)
}
}
// TestValidate verifies the Validate diagnostics (gaps #2, #15).
func TestValidate(t *testing.T) {
missing := &Skill{Name: "x"}
diags := missing.Validate()
if !hasError(diags) {
t.Error("expected an error diagnostic for missing description")
}
ok := &Skill{Name: "x", Description: "y"}
if len(ok.Validate()) != 0 {
t.Error("expected no diagnostics for a complete skill")
}
}
// TestSkillResources verifies bundled-resource enumeration (gaps #11, #15).
func TestSkillResources(t *testing.T) {
dir := t.TempDir()
skillDir := filepath.Join(dir, "my-skill")
if err := os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(skillDir, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "scripts", "run.py"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "references", "REF.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
s := &Skill{Name: "my-skill", Path: filepath.Join(skillDir, "SKILL.md")}
res := s.Resources()
if len(res) != 2 {
t.Fatalf("expected 2 resources, got %d: %v", len(res), res)
}
if s.BaseDir() != skillDir {
t.Errorf("BaseDir = %q, want %q", s.BaseDir(), skillDir)
}
formatted := FormatResources(res)
if !strings.Contains(formatted, "<file>references/REF.md</file>") {
t.Errorf("FormatResources output missing reference: %q", formatted)
}
if !strings.Contains(formatted, "<file>scripts/run.py</file>") {
t.Errorf("FormatResources output missing script: %q", formatted)
}
}
-142
View File
@@ -1,142 +0,0 @@
// Package skilltool provides the built-in activate_skill tool, a dedicated
// activation entry point for agentskills.io skills (issue #65, gaps #13/#14).
//
// While a skill can always be activated by reading its SKILL.md with the
// generic read tool, a dedicated tool offers an enum-constrained skill name
// (preventing hallucinated names), bundled-resource enumeration, and
// per-session deduplication so the same skill is not re-injected repeatedly.
package skilltool
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/skills"
)
// SkillProvider returns the skills currently available for activation. It is
// queried on every call so runtime skill mutations are reflected.
type SkillProvider func() []*skills.Skill
type activateArgs struct {
Name string `json:"name"`
}
// activateSkillTool implements fantasy.AgentTool.
type activateSkillTool struct {
info fantasy.ToolInfo
provider SkillProvider
providerOptions fantasy.ProviderOptions
mu sync.Mutex
activated map[string]bool // session-level dedup tracking
}
func (t *activateSkillTool) Info() fantasy.ToolInfo { return t.info }
func (t *activateSkillTool) ProviderOptions() fantasy.ProviderOptions { return t.providerOptions }
func (t *activateSkillTool) SetProviderOptions(opts fantasy.ProviderOptions) {
t.providerOptions = opts
}
// New builds the activate_skill tool. names is the initial set of skill names
// used to populate the enum constraint on the name parameter; provider is
// queried at call time to resolve the skill by name (so runtime additions
// resolve even if absent from the enum). Returns nil when no skill names are
// available.
func New(names []string, provider SkillProvider) fantasy.AgentTool {
if len(names) == 0 || provider == nil {
return nil
}
sorted := append([]string(nil), names...)
sort.Strings(sorted)
enum := make([]any, len(sorted))
for i, n := range sorted {
enum[i] = n
}
return &activateSkillTool{
info: fantasy.ToolInfo{
Name: "activate_skill",
Description: "Activate a skill by name to load its full instructions into context. " +
"Use this when a task matches a skill listed in <available_skills>. " +
"The skill body and a list of its bundled resources are returned.",
Parameters: map[string]any{
"name": map[string]any{
"type": "string",
"description": "The exact name of the skill to activate.",
"enum": enum,
},
},
Required: []string{"name"},
Parallel: false,
},
provider: provider,
activated: map[string]bool{},
}
}
func (t *activateSkillTool) Run(_ context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
var args activateArgs
if call.Input != "" && call.Input != "{}" {
if err := json.Unmarshal([]byte(call.Input), &args); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid arguments: %v", err)), nil
}
}
name := strings.TrimSpace(args.Name)
if name == "" {
return fantasy.NewTextErrorResponse("name is required"), nil
}
// Hold the lock across the whole activation so the dedup check and the
// subsequent mark are atomic — two concurrent calls cannot both pass the
// check and double-activate the same skill (gap #14). The skill is only
// marked activated on success, so a failed load can be retried.
t.mu.Lock()
defer t.mu.Unlock()
if t.activated[name] {
return fantasy.NewTextResponse(
fmt.Sprintf("Skill %q was already loaded earlier in this session.", name)), nil
}
// Resolve the skill path from the current provider snapshot. Skills with
// disable-model-invocation set are not activatable by the model (they
// remain available via the /skill: command), mirroring their exclusion
// from the catalog and the tool's name enum.
var path string
for _, s := range t.provider() {
if s.Name == name && !s.DisableModelInvocation {
path = s.Path
break
}
}
if path == "" {
return fantasy.NewTextErrorResponse(fmt.Sprintf("unknown skill %q", name)), nil
}
// Re-read the file for freshness, stripping frontmatter.
loaded, err := skills.LoadSkill(path)
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to load skill %q: %v", name, err)), nil
}
var buf strings.Builder
fmt.Fprintf(&buf, "<skill_content name=%q location=%q>\n", loaded.Name, loaded.Path)
fmt.Fprintf(&buf, "References are relative to %s.\n\n", loaded.BaseDir())
buf.WriteString(loaded.Content)
if res := skills.FormatResources(loaded.Resources()); res != "" {
buf.WriteString("\n\n")
buf.WriteString(res)
}
buf.WriteString("\n</skill_content>")
t.activated[name] = true
return fantasy.NewTextResponse(buf.String()), nil
}
-107
View File
@@ -1,107 +0,0 @@
package skilltool
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/skills"
)
func writeSkillFile(t *testing.T, dir, name string) *skills.Skill {
t.Helper()
skillDir := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "scripts", "run.py"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
path := filepath.Join(skillDir, "SKILL.md")
content := "---\nname: " + name + "\ndescription: A test skill\n---\nDo the thing."
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return &skills.Skill{Name: name, Description: "A test skill", Path: path}
}
func TestNew_NilWhenNoSkills(t *testing.T) {
if tool := New(nil, func() []*skills.Skill { return nil }); tool != nil {
t.Error("expected nil tool when no skill names provided")
}
}
func TestActivateSkill_LoadsAndDedups(t *testing.T) {
dir := t.TempDir()
s := writeSkillFile(t, dir, "extract")
provider := func() []*skills.Skill { return []*skills.Skill{s} }
tool := New([]string{"extract"}, provider)
if tool == nil {
t.Fatal("expected a tool")
}
// First activation loads content + resources.
resp, err := tool.Run(context.Background(), fantasy.ToolCall{Input: `{"name":"extract"}`})
if err != nil {
t.Fatal(err)
}
out := responseText(resp)
if !strings.Contains(out, "Do the thing.") {
t.Errorf("expected skill body, got: %q", out)
}
if !strings.Contains(out, "<skill_content name=\"extract\"") {
t.Errorf("expected skill_content wrapper, got: %q", out)
}
if !strings.Contains(out, "scripts/run.py") {
t.Errorf("expected enumerated resources, got: %q", out)
}
// Second activation is deduplicated.
resp2, err := tool.Run(context.Background(), fantasy.ToolCall{Input: `{"name":"extract"}`})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(responseText(resp2), "already loaded") {
t.Errorf("expected dedup message, got: %q", responseText(resp2))
}
}
func TestActivateSkill_UnknownSkill(t *testing.T) {
provider := func() []*skills.Skill { return nil }
tool := New([]string{"extract"}, provider)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{Input: `{"name":"nope"}`})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(responseText(resp), "unknown skill") {
t.Errorf("expected unknown-skill error, got: %q", responseText(resp))
}
}
// TestActivateSkill_DisabledNotActivatable verifies a skill flagged
// disable-model-invocation cannot be activated through the model-facing tool.
func TestActivateSkill_DisabledNotActivatable(t *testing.T) {
dir := t.TempDir()
s := writeSkillFile(t, dir, "extract")
s.DisableModelInvocation = true
provider := func() []*skills.Skill { return []*skills.Skill{s} }
tool := New([]string{"extract"}, provider)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{Input: `{"name":"extract"}`})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(responseText(resp), "unknown skill") {
t.Errorf("disabled skill should not be activatable, got: %q", responseText(resp))
}
}
// responseText extracts the text from a tool response.
func responseText(resp fantasy.ToolResponse) string {
return resp.Content
}
-153
View File
@@ -1,153 +0,0 @@
// Package trust manages a persisted allowlist of project directories that the
// user has marked as trusted for loading project-local skills.
//
// Project-local skills (discovered under <project>/.agents/skills/ and
// <project>/.kit/skills/) are injected into the system prompt. A freshly
// cloned, untrusted repository could therefore smuggle instructions into the
// agent the moment the user runs Kit inside it. To mitigate this prompt-
// injection vector, project-level skill loading can be gated on an explicit
// trust decision recorded here.
//
// The allowlist is stored as JSON at $XDG_CONFIG_HOME/kit/trusted-projects.json
// (default ~/.config/kit/trusted-projects.json).
package trust
import (
"encoding/json"
"os"
"path/filepath"
"sync"
)
// Decision is the outcome of a trust prompt.
type Decision int
const (
// Skip declines to load project skills this session and records nothing.
Skip Decision = iota
// Trust loads project skills this session and persists the directory.
Trust
// TrustOnce loads project skills this session without persisting.
TrustOnce
)
// storeFileName is the basename of the persisted allowlist.
const storeFileName = "trusted-projects.json"
// Store is a persisted set of trusted project directories. The zero value is
// not usable — construct one with Load.
type Store struct {
mu sync.Mutex
path string
trusted map[string]bool
}
// store mirrors the on-disk JSON layout.
type store struct {
Projects []string `json:"projects"`
}
// DefaultPath returns the path the trust allowlist is persisted to, respecting
// $XDG_CONFIG_HOME. Returns the empty string when no home directory can be
// determined.
func DefaultPath() string {
base := os.Getenv("XDG_CONFIG_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
base = filepath.Join(home, ".config")
}
return filepath.Join(base, "kit", storeFileName)
}
// Load reads the trust allowlist from path. A missing file yields an empty
// store (not an error). Pass an empty path to use DefaultPath.
func Load(path string) (*Store, error) {
if path == "" {
path = DefaultPath()
}
s := &Store{path: path, trusted: map[string]bool{}}
if path == "" {
return s, nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return s, nil
}
return s, err
}
var raw store
if err := json.Unmarshal(data, &raw); err != nil {
return s, err
}
for _, p := range raw.Projects {
s.trusted[normalize(p)] = true
}
return s, nil
}
// IsTrusted reports whether dir has been marked trusted.
func (s *Store) IsTrusted(dir string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.trusted[normalize(dir)]
}
// Trust records dir as trusted and persists the allowlist to disk.
func (s *Store) Trust(dir string) error {
s.mu.Lock()
s.trusted[normalize(dir)] = true
s.mu.Unlock()
return s.save()
}
// Untrust removes dir from the allowlist and persists the change.
func (s *Store) Untrust(dir string) error {
s.mu.Lock()
delete(s.trusted, normalize(dir))
s.mu.Unlock()
return s.save()
}
// save writes the allowlist to disk, creating parent directories as needed.
func (s *Store) save() error {
if s.path == "" {
return nil
}
s.mu.Lock()
projects := make([]string, 0, len(s.trusted))
for p := range s.trusted {
projects = append(projects, p)
}
s.mu.Unlock()
data, err := json.MarshalIndent(store{Projects: projects}, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
return os.WriteFile(s.path, data, 0o644)
}
// normalize resolves dir to an absolute, symlink-evaluated path for stable
// comparison. It falls back to the cleaned input when resolution fails.
func normalize(dir string) string {
if dir == "" {
return ""
}
abs, err := filepath.Abs(dir)
if err != nil {
return filepath.Clean(dir)
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
return resolved
}
return abs
}
-60
View File
@@ -1,60 +0,0 @@
package trust
import (
"path/filepath"
"testing"
)
func TestStore_TrustAndPersist(t *testing.T) {
dir := t.TempDir()
storePath := filepath.Join(dir, "trusted-projects.json")
project := filepath.Join(dir, "repo")
s, err := Load(storePath)
if err != nil {
t.Fatal(err)
}
if s.IsTrusted(project) {
t.Fatal("project should not be trusted initially")
}
if err := s.Trust(project); err != nil {
t.Fatal(err)
}
if !s.IsTrusted(project) {
t.Fatal("project should be trusted after Trust")
}
// Reload from disk to confirm persistence.
s2, err := Load(storePath)
if err != nil {
t.Fatal(err)
}
if !s2.IsTrusted(project) {
t.Fatal("trust decision should persist across reloads")
}
}
func TestStore_Untrust(t *testing.T) {
dir := t.TempDir()
storePath := filepath.Join(dir, "trusted-projects.json")
project := filepath.Join(dir, "repo")
s, _ := Load(storePath)
_ = s.Trust(project)
if err := s.Untrust(project); err != nil {
t.Fatal(err)
}
if s.IsTrusted(project) {
t.Fatal("project should not be trusted after Untrust")
}
}
func TestStore_MissingFileIsEmpty(t *testing.T) {
s, err := Load(filepath.Join(t.TempDir(), "does-not-exist.json"))
if err != nil {
t.Fatalf("missing file should not error: %v", err)
}
if s.IsTrusted("/anything") {
t.Fatal("empty store should trust nothing")
}
}
+1 -2
View File
@@ -146,10 +146,9 @@ var SlashCommands = []SlashCommand{
},
{
Name: "/new",
Description: "Start a new session (optionally with an initial prompt)",
Description: "Start a new session",
Category: "Navigation",
Aliases: []string{"/n"},
HasArgs: true,
},
{
Name: "/name",
+17 -119
View File
@@ -445,12 +445,9 @@ type AppModelOptions struct {
EmitBeforeFork func(targetID string, isUserMsg bool, userText string) (bool, string)
// EmitBeforeSessionSwitch, if non-nil, is called before switching
// to a new session branch (e.g. /new, /clear). reason is the trigger
// ("new", "clear", "extension"); initialPrompt is the user prompt
// that will run as the first turn of the new session (empty when
// /new is called without arguments). Returns (cancelled, reason).
// May be nil if no extensions are loaded.
EmitBeforeSessionSwitch func(reason, initialPrompt string) (bool, string)
// to a new session branch (e.g. /new, /clear). Returns (cancelled,
// reason). May be nil if no extensions are loaded.
EmitBeforeSessionSwitch func(reason string) (bool, string)
// GetGlobalShortcuts, if non-nil, returns extension-registered global
// keyboard shortcuts. Keys are binding strings (e.g., "ctrl+p").
@@ -578,13 +575,6 @@ type AppModel struct {
// flushed first, preserving chronological order.
pendingUserPrints []string
// newSessionResultCh, when non-nil, receives the outcome of an
// in-flight extension-triggered NewSession request. Set when an
// app.NewSessionRequestEvent arrives; cleared (with a result sent)
// in performNewSession success/failure paths or in the
// beforeSessionSwitchResultMsg cancellation path.
newSessionResultCh chan<- error
// canceling tracks whether the user has pressed ESC once during stateWorking.
// A second ESC within 2 seconds will cancel the current step.
canceling bool
@@ -687,7 +677,7 @@ type AppModel struct {
// emitBeforeSessionSwitch emits a before-session-switch event to extensions.
// Returns (cancelled, reason). May be nil if no extensions are loaded.
emitBeforeSessionSwitch func(reason, initialPrompt string) (bool, string)
emitBeforeSessionSwitch func(reason string) (bool, string)
// thinkingLevel is the current extended thinking level.
thinkingLevel string
@@ -2202,25 +2192,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
ic.textarea.CursorEnd()
}
case app.NewSessionRequestEvent:
// Extension wants to end the current session and start a fresh
// one (with an optional initial prompt). Stash the response
// channel so performNewSession (or the before-hook cancellation
// path) can signal completion, then run the same /new pipeline
// the user would trigger.
if msg.ResponseCh != nil {
// Only one new-session request in flight at a time. If a
// previous response channel is still pending, fail it before
// replacing it so the prior extension goroutine unblocks.
if m.newSessionResultCh != nil {
m.newSessionResultCh <- fmt.Errorf("superseded by a newer NewSession request")
}
m.newSessionResultCh = msg.ResponseCh
}
if cmd := m.handleNewCommand(msg.InitialPrompt); cmd != nil {
cmds = append(cmds, cmd)
}
case app.PasswordPromptEvent:
// Sudo password prompt - show a modal input prompt
// If already in prompt state, cancel the new request
@@ -2426,9 +2397,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// session reset if the hook did not cancel.
if msg.cancelled {
m.printSystemMessage(msg.reason)
m.signalNewSessionResult(fmt.Errorf("session switch cancelled: %s", msg.reason))
} else {
cmds = append(cmds, m.performNewSession(msg.initialPrompt))
cmds = append(cmds, m.performNewSession())
}
case beforeForkResultMsg:
@@ -3271,7 +3241,7 @@ func (m *AppModel) handleSlashCommand(sc *commands.SlashCommand, args string) te
case "/fork":
return m.handleForkCommand()
case "/new":
return m.handleNewCommand(args)
return m.handleNewCommand()
case "/name":
return m.handleNameCommand(args)
case "/resume":
@@ -3702,7 +3672,7 @@ func (m *AppModel) printHelpMessage() {
"**Navigation:**\n" +
"- `/tree`: Navigate session tree (switch branches)\n" +
"- `/fork`: Branch from an earlier message\n" +
"- `/new [prompt]`: Start a new session (discards context, saves old session). With a prompt, runs it as the first message; supports `@file` attachments.\n" +
"- `/new`: Start a new session (discards context, saves old session)\n" +
"- `/resume`: Open session picker to switch sessions\n" +
"- `/name <name>`: Set a display name for this session\n\n" +
"**System:**\n" +
@@ -4398,12 +4368,7 @@ func (m *AppModel) handleForkCommand() tea.Cmd {
// handleNewCommand starts a completely new session (Pi-style /new behavior).
// Creates a new session file, discarding all context from the previous conversation.
// If initialPrompt is non-empty it is submitted as the first user turn of the
// new session, with @file references expanded the same way they are for
// regular user input.
func (m *AppModel) handleNewCommand(initialPrompt string) tea.Cmd {
initialPrompt = strings.TrimSpace(initialPrompt)
func (m *AppModel) handleNewCommand() tea.Cmd {
// Emit before-session-switch event in a goroutine so that extension
// handlers can call blocking operations (e.g. ctx.PromptConfirm) without
// deadlocking the BubbleTea event loop.
@@ -4411,25 +4376,23 @@ func (m *AppModel) handleNewCommand(initialPrompt string) tea.Cmd {
emit := m.emitBeforeSessionSwitch
ctrl := m.appCtrl
go func() {
cancelled, reason := emit("new", initialPrompt)
cancelled, reason := emit("new")
ctrl.SendEvent(beforeSessionSwitchResultMsg{
cancelled: cancelled,
reason: reason,
initialPrompt: initialPrompt,
cancelled: cancelled,
reason: reason,
})
}()
return noopCmd
}
return m.performNewSession(initialPrompt)
return m.performNewSession()
}
// performNewSession performs the actual session reset. Called either directly
// (when no before-hook exists) or after the async hook completes.
// Matches Pi behavior: creates a completely new session file, discarding all
// context from the previous conversation. If initialPrompt is non-empty it
// is submitted as the first user turn (with @file expansion).
func (m *AppModel) performNewSession(initialPrompt string) tea.Cmd {
// context from the previous conversation.
func (m *AppModel) performNewSession() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
// No tree session — just clear messages.
@@ -4443,16 +4406,13 @@ func (m *AppModel) performNewSession(initialPrompt string) tea.Cmd {
// Clear the ScrollList so the new session starts fresh.
m.messages = []MessageItem{}
m.printSystemMessage("Conversation cleared. Starting fresh.")
cmd := m.submitInitialPrompt(initialPrompt)
m.signalNewSessionResult(nil)
return cmd
return nil
}
// Create a brand new session file (Pi-style /new behavior)
newTs, err := session.CreateTreeSession(m.cwd)
if err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to create new session: %v", err))
m.signalNewSessionResult(fmt.Errorf("create new session: %w", err))
return nil
}
@@ -4465,67 +4425,6 @@ func (m *AppModel) performNewSession(initialPrompt string) tea.Cmd {
// Clear the ScrollList so the new session starts fresh.
m.messages = []MessageItem{}
m.printSystemMessage("New session started. Previous conversation saved.")
cmd := m.submitInitialPrompt(initialPrompt)
m.signalNewSessionResult(nil)
return cmd
}
// signalNewSessionResult delivers the outcome of an extension-triggered
// NewSession request (if one is in flight) and clears the response channel.
// Safe to call when no request is pending.
func (m *AppModel) signalNewSessionResult(err error) {
if m.newSessionResultCh == nil {
return
}
ch := m.newSessionResultCh
m.newSessionResultCh = nil
// Channel is buffered (cap >= 1) by contract — send is non-blocking.
ch <- err
}
// submitInitialPrompt is the shared submission path used by /new <prompt>
// and ctx.NewSession(prompt). It mirrors the SubmitMsg handler: @file
// references are expanded via fileutil.ProcessFileAttachments and the
// resulting prompt is forwarded to AppController.Run / RunWithFiles.
// Returns nil when prompt is empty.
func (m *AppModel) submitInitialPrompt(prompt string) tea.Cmd {
prompt = strings.TrimSpace(prompt)
if prompt == "" || m.appCtrl == nil {
return nil
}
processedText := prompt
var fileParts []kit.LLMFilePart
if m.cwd != "" {
result := fileutil.ProcessFileAttachments(prompt, m.cwd, m.mcpResourceReader)
processedText = result.ProcessedText
for _, fp := range result.FileParts {
fileParts = append(fileParts, kit.LLMFilePart{
Filename: fp.Filename,
Data: fp.Data,
MediaType: fp.MediaType,
})
}
}
displayText := prompt
if len(fileParts) > 0 {
displayText = fmt.Sprintf("%s\n[%d file(s) attached]", prompt, len(fileParts))
}
var qLen int
if len(fileParts) > 0 {
qLen = m.appCtrl.RunWithFiles(processedText, fileParts)
} else {
qLen = m.appCtrl.Run(processedText)
}
if qLen > 0 {
m.queuedMessages = append(m.queuedMessages, displayText)
m.layoutDirty = true
} else {
m.pendingUserPrints = append(m.pendingUserPrints, displayText)
m.flushStreamAndPendingUserMessages()
}
return nil
}
@@ -5234,9 +5133,8 @@ type mcpPromptResultMsg struct {
// executed before-session-switch hook. The hook runs in a goroutine so that
// blocking operations like ctx.PromptConfirm() do not deadlock the TUI.
type beforeSessionSwitchResultMsg struct {
cancelled bool
reason string
initialPrompt string
cancelled bool
reason string
}
// beforeForkResultMsg carries the result of an asynchronously executed
-125
View File
@@ -1144,128 +1144,3 @@ func TestRenderQueuedMessages_truncatesLongMessages(t *testing.T) {
t.Fatalf("expected truncated output to be ≤10 lines, got %d lines", lines)
}
}
// --------------------------------------------------------------------------
// /new <prompt> and ctx.NewSession
// --------------------------------------------------------------------------
// TestNewCommand_noPrompt verifies that /new without an argument resets the
// session (clears messages, prints the system message) and does NOT submit
// any prompt to the controller.
func TestNewCommand_noPrompt(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.cwd = t.TempDir()
_ = m.handleNewCommand("")
if len(ctrl.runCalls) != 0 {
t.Fatalf("expected no Run calls for empty prompt, got %v", ctrl.runCalls)
}
if ctrl.clearMsgCalled == 0 {
t.Fatal("expected ClearMessages to be called when no tree session is active")
}
}
// TestNewCommand_withPrompt verifies that /new <prompt> submits the prompt
// to AppController.Run after clearing the session.
func TestNewCommand_withPrompt(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.cwd = t.TempDir()
_ = m.handleNewCommand("continue from where we left off")
if len(ctrl.runCalls) != 1 {
t.Fatalf("expected exactly 1 Run call, got %d (%v)", len(ctrl.runCalls), ctrl.runCalls)
}
if ctrl.runCalls[0] != "continue from where we left off" {
t.Fatalf("unexpected prompt submitted: %q", ctrl.runCalls[0])
}
}
// TestNewCommand_whitespacePromptIsEmpty verifies that an all-whitespace
// prompt is treated as empty (no Run call).
func TestNewCommand_whitespacePromptIsEmpty(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.cwd = t.TempDir()
_ = m.handleNewCommand(" \n\t ")
if len(ctrl.runCalls) != 0 {
t.Fatalf("expected no Run calls for whitespace-only prompt, got %v", ctrl.runCalls)
}
}
// TestNewSessionRequestEvent_signalsResponseCh verifies that
// app.NewSessionRequestEvent runs the same /new pipeline and delivers a
// nil error to the response channel on success.
func TestNewSessionRequestEvent_signalsResponseCh(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.cwd = t.TempDir()
ch := make(chan error, 1)
m = sendMsg(m, app.NewSessionRequestEvent{
InitialPrompt: "hello from extension",
ResponseCh: ch,
})
select {
case err := <-ch:
if err != nil {
t.Fatalf("expected nil error on success, got %v", err)
}
default:
t.Fatal("expected ResponseCh to receive a value")
}
if len(ctrl.runCalls) != 1 || ctrl.runCalls[0] != "hello from extension" {
t.Fatalf("expected prompt to be submitted to Run, got %v", ctrl.runCalls)
}
if m.newSessionResultCh != nil {
t.Fatal("expected newSessionResultCh to be cleared after signaling")
}
}
// TestNewSessionRequestEvent_cancelledByExtension verifies that when the
// before-session-switch hook cancels, the response channel receives an
// error.
func TestNewSessionRequestEvent_cancelledByExtension(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.cwd = t.TempDir()
m.emitBeforeSessionSwitch = func(reason, prompt string) (bool, string) {
return true, "vetoed by test"
}
ch := make(chan error, 1)
m = sendMsg(m, app.NewSessionRequestEvent{
InitialPrompt: "should be cancelled",
ResponseCh: ch,
})
// The before-hook runs in a goroutine, which sends back a
// beforeSessionSwitchResultMsg. Pump that synchronously by reading
// the SendEvent call indirectly: SendEvent on stub is a no-op so we
// need to dispatch the message ourselves to simulate the round trip.
sendMsg(m, beforeSessionSwitchResultMsg{
cancelled: true,
reason: "vetoed by test",
initialPrompt: "should be cancelled",
})
select {
case err := <-ch:
if err == nil {
t.Fatal("expected non-nil error on cancellation")
}
if !strings.Contains(err.Error(), "vetoed by test") {
t.Fatalf("expected error to mention the veto reason, got %v", err)
}
default:
t.Fatal("expected ResponseCh to receive a value")
}
if len(ctrl.runCalls) != 0 {
t.Fatalf("expected no Run calls when cancelled, got %v", ctrl.runCalls)
}
}
+2 -4
View File
@@ -91,10 +91,8 @@ func (h *Harness) LoadString(src string, path string) *extensions.LoadedExtensio
func (h *Harness) loadSource(src string, path string) *extensions.LoadedExtension {
h.t.Helper()
// Create a fresh interpreter. Seed the virtualized environment with the
// process environment so extensions can read env vars via os.Getenv,
// mirroring the production loader (see internal/extensions/loader.go).
i := interp.New(interp.Options{Env: os.Environ()})
// Create a fresh interpreter
i := interp.New(interp.Options{})
// Expose Go stdlib
if err := i.Use(stdlib.Symbols); err != nil {
+5 -27
View File
@@ -74,8 +74,7 @@ host, err := kit.NewAgent(ctx,
Helpers: `WithModel`, `WithSystemPrompt`, `WithStreaming`, `WithMaxTokens`,
`WithThinkingLevel`, `WithTools`, `WithExtraTools`, `WithProviderAPIKey`,
`WithProviderURL`, `WithConfigFile`, `WithDebug`, `WithDebugLogger`, and
`Ephemeral`. `Option` is
`WithProviderURL`, `WithConfigFile`, `WithDebug`, and `Ephemeral`. `Option` is
a plain `func(*Options)`, so you can define your own. For fields without a
`With*` helper (`MCPConfig`, `InProcessMCPServers`, `SessionManager`, MCP task
tuning) construct an `Options` value and call `kit.New`.
@@ -330,6 +329,7 @@ kit.LLMFilePart // {Filename, Data []byte, MediaType}
// Agent configuration — concrete Kit-owned structs and function types.
// All fields use SDK types (e.g. `[]kit.Tool`), so consumers can construct
// these without importing any LLM-provider package.
kit.AgentConfig // Lower-level agent config — prefer Options unless you need direct control
kit.DebugLogger // Interface: LogDebug(string) / IsDebugEnabled() bool
kit.MCPTaskConfig // Task-aware MCP tools/call config (modes, polling, progress)
kit.ToolCallHandler // func(toolCallID, toolName, toolArgs string)
@@ -364,28 +364,15 @@ msg := kit.ConvertFromLLMMessage(lMsg) // LLMMessage → SDK Message
- `Option` - Functional option (`func(*Options)`) for `NewAgent`
- `Message` - Conversation message with typed content parts
- `Tool` - Agent tool interface
- `TurnResult` - Full result from a prompt including usage stats, captured
stream deltas (`Stream`), and any tool-driven halt (`FinalValue` /
`HaltedByTool`)
- `StreamEvent` / `StreamEventKind` - Ordered delta events captured in
`TurnResult.Stream`
- `ToolOutput` - Custom tool return value; set `Halt`/`FinalValue` to end the
agent loop and surface a typed result
- Provider-error sentinels - `ErrContextOverflow`, `ErrRateLimit`, `ErrAuth`,
`ErrProviderUnavailable`, `ErrInvalidRequest`; classify with
`ClassifyProviderError(err)` and match via `errors.Is`
- `TurnResult` - Full result from a prompt including usage stats
### Key Methods
- `New(ctx, opts)` - Create new Kit instance
- `NewAgent(ctx, ...Option)` - Create a Kit via functional options (streaming on by default)
- `Prompt(ctx, message)` - Send message and get response string
- `PromptResult(ctx, message)` - Send message and get full TurnResult (blocks
until end-of-turn; populates `TurnResult.Stream` in streaming mode)
- `PromptResult(ctx, message)` - Send message and get full TurnResult
- `PromptWithOptions(ctx, message, opts)` - Prompt with per-call options
(system message, model, thinking level, provider credentials, extra tools)
- `PromptResultWithOptions(ctx, message, opts)` - Per-call options variant that
returns the full TurnResult
- `Steer(ctx, instruction)` - System-level steering
- `FollowUp(ctx, text)` - Continue without new user input
- `SetModel(ctx, model)` - Switch model at runtime
@@ -397,15 +384,7 @@ msg := kit.ConvertFromLLMMessage(lMsg) // LLMMessage → SDK Message
- `AddSkill(*Skill)` / `LoadAndAddSkill(path)` / `RemoveSkill(name)` / `SetSkills([])` - Manage skills at runtime
- `AddContextFile(*ContextFile)` / `AddContextFileContent(path, content)` / `LoadAndAddContextFile(path)` / `RemoveContextFile(path)` / `SetContextFiles([])` - Manage AGENTS.md-style context files at runtime
- `RefreshSystemPrompt()` - Re-apply the composed system prompt to the agent
- `NewTool[T]` / `NewParallelTool[T]` - Create a typed custom tool
- `NewRawTool(name, desc, schema, fn)` - Create a schema-driven tool when the
input shape isn't known at compile time (skill/MCP catalogs)
- `LoadSkillsFromFS(fsys, root)` - `fs.FS`-typed skill loader (embed.FS,
fstest.MapFS, per-tenant virtual filesystems)
- `CollapseBranch(fromID, toID, summary)` - Collapse a branch range into a
summary (works with any `SessionManager` via `AppendBranchSummary`)
- `Close()` - Clean up resources
- `CloseContext(ctx)` - Clean up resources with a shutdown deadline
### Options
@@ -424,8 +403,7 @@ Key `Options` fields for SDK usage:
| `SessionPath` | Open specific session file |
| `Continue` | Resume most recent session |
| `InProcessMCPServers` | Map of name → `*kit.MCPServer` for in-process MCP servers |
| `Debug` | Enable debug logging via the built-in console logger (ignored when `DebugLogger` is set) |
| `DebugLogger` | Custom `DebugLogger` implementation — routes engine + MCP debug output into your own logging system |
| `Debug` | Enable debug logging |
## Environment Variables
-5
View File
@@ -153,11 +153,6 @@ func (a *treeManagerAdapter) GetContextEntryIDs() []string {
return a.inner.GetContextEntryIDs()
}
// AppendBranchSummary implements SessionManager.
func (a *treeManagerAdapter) AppendBranchSummary(fromID, summary string) (string, error) {
return a.inner.AppendBranchSummary(fromID, summary)
}
// Close implements SessionManager.
func (a *treeManagerAdapter) Close() error {
return a.inner.Close()
+208
View File
@@ -0,0 +1,208 @@
package kit
import (
"context"
"errors"
"testing"
"time"
"github.com/mark3labs/kit/internal/agent"
)
// TestAgentConfigToInternal verifies that the SDK-side AgentConfig converts
// faithfully to the internal agent.AgentConfig representation, preserving
// every field consumed by the internal agent layer.
//
// Regression test for https://github.com/mark3labs/kit/issues/30.
func TestAgentConfigToInternal(t *testing.T) {
t.Run("nil receiver returns nil", func(t *testing.T) {
var c *AgentConfig
if got := c.toInternal(); got != nil {
t.Errorf("nil.toInternal() = %v, want nil", got)
}
})
t.Run("scalar fields round-trip", func(t *testing.T) {
c := &AgentConfig{
SystemPrompt: "sys",
MaxSteps: 7,
StreamingEnabled: true,
DisableCoreTools: true,
}
got := c.toInternal()
if got == nil {
t.Fatal("toInternal() = nil")
}
if got.SystemPrompt != "sys" {
t.Errorf("SystemPrompt = %q, want %q", got.SystemPrompt, "sys")
}
if got.MaxSteps != 7 {
t.Errorf("MaxSteps = %d, want 7", got.MaxSteps)
}
if !got.StreamingEnabled {
t.Error("StreamingEnabled = false, want true")
}
if !got.DisableCoreTools {
t.Error("DisableCoreTools = false, want true")
}
})
t.Run("tool slices propagate without conversion", func(t *testing.T) {
// Tool is a type alias for the underlying LLM-tool type, so the
// SDK []Tool and internal []fantasy.AgentTool slices share the
// same backing array after conversion.
tool := NewTool[struct{}]("noop", "noop", nil)
c := &AgentConfig{
CoreTools: []Tool{tool},
ExtraTools: []Tool{tool, tool},
}
got := c.toInternal()
if len(got.CoreTools) != 1 {
t.Errorf("CoreTools len = %d, want 1", len(got.CoreTools))
}
if len(got.ExtraTools) != 2 {
t.Errorf("ExtraTools len = %d, want 2", len(got.ExtraTools))
}
})
t.Run("tool wrapper is invoked through internal config", func(t *testing.T) {
called := false
c := &AgentConfig{
ToolWrapper: func(in []Tool) []Tool {
called = true
return in
},
}
got := c.toInternal()
if got.ToolWrapper == nil {
t.Fatal("internal ToolWrapper is nil")
}
_ = got.ToolWrapper(nil)
if !called {
t.Error("SDK ToolWrapper was not invoked through the internal config")
}
})
t.Run("OnMCPServerLoaded propagates", func(t *testing.T) {
var captured string
wantErr := errors.New("boom")
c := &AgentConfig{
OnMCPServerLoaded: func(name string, _ int, _ error) {
captured = name
},
}
got := c.toInternal()
got.OnMCPServerLoaded("svr", 3, wantErr)
if captured != "svr" {
t.Errorf("OnMCPServerLoaded captured = %q, want %q", captured, "svr")
}
})
t.Run("DebugLogger propagates", func(t *testing.T) {
dl := &fakeDebugLogger{enabled: true}
c := &AgentConfig{DebugLogger: dl}
got := c.toInternal()
if got.DebugLogger == nil {
t.Fatal("internal DebugLogger is nil")
}
if !got.DebugLogger.IsDebugEnabled() {
t.Error("IsDebugEnabled = false, want true")
}
got.DebugLogger.LogDebug("hello")
if len(dl.messages) != 1 || dl.messages[0] != "hello" {
t.Errorf("messages = %v, want [hello]", dl.messages)
}
})
t.Run("MCPTaskConfig propagates with mode + progress", func(t *testing.T) {
c := &AgentConfig{
MCPTaskConfig: MCPTaskConfig{
PerServerMode: map[string]MCPTaskMode{
"build-svr": MCPTaskModeAlways,
},
DefaultTTL: 30 * time.Second,
PollInterval: 250 * time.Millisecond,
MaxPollInterval: 2 * time.Second,
Timeout: 5 * time.Minute,
Progress: func(_ MCPTaskProgress) {},
},
}
got := c.toInternal()
if got.MCPTaskConfig.DefaultTTL != 30*time.Second {
t.Errorf("DefaultTTL = %v, want 30s", got.MCPTaskConfig.DefaultTTL)
}
if got.MCPTaskConfig.PollInterval != 250*time.Millisecond {
t.Errorf("PollInterval = %v, want 250ms", got.MCPTaskConfig.PollInterval)
}
if got.MCPTaskConfig.MaxPollInterval != 2*time.Second {
t.Errorf("MaxPollInterval = %v, want 2s", got.MCPTaskConfig.MaxPollInterval)
}
if got.MCPTaskConfig.Timeout != 5*time.Minute {
t.Errorf("Timeout = %v, want 5m", got.MCPTaskConfig.Timeout)
}
mode, ok := got.MCPTaskConfig.PerServerMode["build-svr"]
if !ok {
t.Fatal("PerServerMode missing 'build-svr'")
}
if string(mode) != string(MCPTaskModeAlways) {
t.Errorf("mode = %q, want %q", mode, MCPTaskModeAlways)
}
if got.MCPTaskConfig.Progress == nil {
t.Fatal("internal Progress handler is nil")
}
})
t.Run("auth and token store factories are wired", func(t *testing.T) {
auth := &fakeAuthHandler{}
tokenCalls := 0
var tokenServer string
factory := MCPTokenStoreFactory(func(server string) (MCPTokenStore, error) {
tokenCalls++
tokenServer = server
return nil, nil
})
c := &AgentConfig{
AuthHandler: auth,
TokenStoreFactory: factory,
}
got := c.toInternal()
if got.AuthHandler == nil {
t.Fatal("internal AuthHandler is nil")
}
if got.TokenStoreFactory == nil {
t.Fatal("internal TokenStoreFactory is nil")
}
_, _ = got.TokenStoreFactory("https://example.test")
if tokenCalls != 1 {
t.Errorf("token factory call count = %d, want 1", tokenCalls)
}
if tokenServer != "https://example.test" {
t.Errorf("token factory server arg = %q", tokenServer)
}
if got.AuthHandler.RedirectURI() != "redirect" {
t.Errorf("RedirectURI = %q, want %q", got.AuthHandler.RedirectURI(), "redirect")
}
})
// Compile-time check that the internal type is what we expect.
//nolint:staticcheck // QF1011: explicit type asserts the conversion target.
var _ *agent.AgentConfig = (&AgentConfig{}).toInternal()
}
// fakeAuthHandler implements both kit.MCPAuthHandler and the structurally
// identical tools.MCPAuthHandler used by the internal layer.
type fakeAuthHandler struct{}
func (f *fakeAuthHandler) RedirectURI() string { return "redirect" }
func (f *fakeAuthHandler) HandleAuth(_ context.Context, _ string, _ string) (string, error) {
return "", nil
}
// fakeDebugLogger implements kit.DebugLogger for tests.
type fakeDebugLogger struct {
enabled bool
messages []string
}
func (f *fakeDebugLogger) LogDebug(m string) { f.messages = append(f.messages, m) }
func (f *fakeDebugLogger) IsDebugEnabled() bool { return f.enabled }
+1 -13
View File
@@ -112,20 +112,8 @@ func (m *Kit) Compact(ctx context.Context, opts *CompactionOptions, customInstru
}
// compactInternal is the shared compaction implementation. The isAutomatic
// flag distinguishes user-triggered from auto-compaction for hooks/events.
// On failure it emits a CompactionEvent carrying the error so embedders can
// observe the failure path symmetrically with the success path.
// flag distinguishes auto-triggered compaction from manual /compact.
func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, customInstructions string, isAutomatic bool) (*CompactionResult, error) {
result, err := m.compactImpl(ctx, opts, customInstructions, isAutomatic)
if err != nil {
m.events.emit(CompactionEvent{Err: err})
}
return result, err
}
// compactImpl performs the actual compaction work. On success it emits a
// CompactionEvent via persistAndEmitCompaction.
func (m *Kit) compactImpl(ctx context.Context, opts *CompactionOptions, customInstructions string, isAutomatic bool) (*CompactionResult, error) {
if opts == nil {
if m.compactionOpts != nil {
opts = m.compactionOpts
-113
View File
@@ -1,113 +0,0 @@
package kit
import (
"errors"
"strings"
)
// Provider-error sentinels. Provider and turn execution paths wrap these via
// fmt.Errorf("%w: %s", …) so embedders can classify failures with errors.Is
// instead of brittle string matching. Use [ClassifyProviderError] to map an
// arbitrary provider error to one of these sentinels.
var (
// ErrContextOverflow indicates the request exceeded the model's maximum
// context window. Embedders typically respond by compacting and retrying.
ErrContextOverflow = errors.New("context window exceeded")
// ErrRateLimit indicates the provider throttled the request. Embedders
// typically respond by backing off and retrying.
ErrRateLimit = errors.New("rate limited by provider")
// ErrAuth indicates a credential / authorization failure.
ErrAuth = errors.New("provider authentication failed")
// ErrProviderUnavailable indicates a transient provider/upstream failure
// (5xx, network error, timeout).
ErrProviderUnavailable = errors.New("provider unavailable")
// ErrInvalidRequest indicates the request was structurally invalid and
// retrying will not help.
ErrInvalidRequest = errors.New("invalid request to provider")
)
// ClassifyProviderError inspects err and returns it wrapped with the matching
// provider-error sentinel ([ErrContextOverflow], [ErrRateLimit], [ErrAuth],
// [ErrProviderUnavailable], or [ErrInvalidRequest]) when the underlying cause
// can be recognized. The returned error satisfies errors.Is against both the
// sentinel and the original cause, so the full chain stays inspectable.
//
// When err is nil it returns nil. When the cause cannot be classified the
// original err is returned unchanged so callers never lose information.
//
// Classification is heuristic: it first honors any sentinel already present in
// the chain (so double-classification is idempotent), then falls back to
// matching common provider status codes and phrases in the error text.
func ClassifyProviderError(err error) error {
if err == nil {
return nil
}
// Already classified — keep as-is so the call is idempotent.
for _, sentinel := range []error{
ErrContextOverflow, ErrRateLimit, ErrAuth,
ErrProviderUnavailable, ErrInvalidRequest,
} {
if errors.Is(err, sentinel) {
return err
}
}
if sentinel := classifyProviderErrorText(err.Error()); sentinel != nil {
return wrapSentinel(sentinel, err)
}
return err
}
// wrapSentinel returns an error that satisfies errors.Is(_, sentinel) while
// keeping the original cause inspectable via errors.Is.
func wrapSentinel(sentinel, cause error) error {
return &sentinelError{sentinel: sentinel, cause: cause}
}
type sentinelError struct {
sentinel error
cause error
}
func (e *sentinelError) Error() string {
return e.sentinel.Error() + ": " + e.cause.Error()
}
// Unwrap returns both the sentinel and the cause so errors.Is matches the
// sentinel and the underlying error chain stays reachable.
func (e *sentinelError) Unwrap() []error {
return []error{e.sentinel, e.cause}
}
// classifyProviderErrorText returns the sentinel matching common provider
// error phrasings, or nil if none match.
func classifyProviderErrorText(msg string) error {
m := strings.ToLower(msg)
switch {
case containsAny(m, "context_length_exceeded", "context window", "maximum context length", "too many tokens", "prompt is too long"):
return ErrContextOverflow
case containsAny(m, "rate limit", "rate_limit", "too many requests", "status 429", "429"):
return ErrRateLimit
case containsAny(m, "unauthorized", "authentication", "invalid api key", "invalid_api_key", "permission denied", "status 401", "status 403", "401", "403"):
return ErrAuth
case containsAny(m, "status 500", "status 502", "status 503", "status 504", "internal server error", "bad gateway", "service unavailable", "gateway timeout", "timeout", "connection refused", "no such host", "eof"):
return ErrProviderUnavailable
case containsAny(m, "status 400", "invalid request", "bad request", "unprocessable"):
return ErrInvalidRequest
default:
return nil
}
}
func containsAny(s string, subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
-64
View File
@@ -1,64 +0,0 @@
package kit_test
import (
"errors"
"fmt"
"testing"
"github.com/mark3labs/kit/pkg/kit"
)
func TestClassifyProviderError(t *testing.T) {
cases := []struct {
name string
in error
want error
}{
{"nil", nil, nil},
{"context overflow", errors.New("error: context_length_exceeded for this model"), kit.ErrContextOverflow},
{"context window phrase", errors.New("the prompt is too long for the context window"), kit.ErrContextOverflow},
{"rate limit", errors.New("HTTP status 429: rate limit exceeded"), kit.ErrRateLimit},
{"auth 401", errors.New("status 401 unauthorized"), kit.ErrAuth},
{"auth invalid key", errors.New("invalid api key provided"), kit.ErrAuth},
{"unavailable 503", errors.New("status 503 service unavailable"), kit.ErrProviderUnavailable},
{"invalid request", errors.New("status 400 bad request: malformed body"), kit.ErrInvalidRequest},
{"unclassified", errors.New("something totally unexpected"), nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := kit.ClassifyProviderError(tc.in)
if tc.in == nil {
if got != nil {
t.Fatalf("expected nil, got %v", got)
}
return
}
if tc.want == nil {
// Unclassified errors are returned unchanged.
if got.Error() != tc.in.Error() {
t.Fatalf("expected unchanged error, got %v", got)
}
return
}
if !errors.Is(got, tc.want) {
t.Fatalf("errors.Is(%v, %v) = false", got, tc.want)
}
// Original cause must remain reachable.
if !errors.Is(got, tc.in) {
t.Fatalf("original cause not preserved in %v", got)
}
})
}
}
func TestClassifyProviderErrorIdempotent(t *testing.T) {
wrapped := fmt.Errorf("%w: upstream detail", kit.ErrRateLimit)
got := kit.ClassifyProviderError(wrapped)
if got != wrapped {
t.Fatalf("already-classified error should be returned unchanged")
}
if !errors.Is(got, kit.ErrRateLimit) {
t.Fatalf("expected ErrRateLimit to remain")
}
}
+1 -8
View File
@@ -370,10 +370,7 @@ type StepUsageEvent struct {
// EventType implements Event.
func (e StepUsageEvent) EventType() EventType { return EventStepUsage }
// CompactionEvent fires after a compaction attempt. On success Err is nil and
// the summary/token/file fields are populated. On failure Err is non-nil and
// the remaining fields are zero-valued, so embedders can wire symmetric
// start/end lifecycle telemetry around both outcomes.
// CompactionEvent fires after a successful compaction.
type CompactionEvent struct {
Summary string
OriginalTokens int
@@ -381,10 +378,6 @@ type CompactionEvent struct {
MessagesRemoved int
ReadFiles []string
ModifiedFiles []string
// Err is non-nil when compaction failed. On the failure path the other
// fields are zero-valued.
Err error
}
// EventType implements Event.
-6
View File
@@ -20,9 +20,3 @@ func (m *Kit) ConfigFloatForTest(key string) float64 { return m.v.GetFloat64(key
// ConfigBoolForTest returns the bool value of key from this Kit's isolated
// configuration store.
func (m *Kit) ConfigBoolForTest(key string) bool { return m.v.GetBool(key) }
// ConfigStringSliceForTest returns the string slice value of key from this
// Kit's isolated configuration store.
func (m *Kit) ConfigStringSliceForTest(key string) []string {
return m.v.GetStringSlice(key)
}
+1 -11
View File
@@ -137,7 +137,6 @@ type ExtensionAPI interface {
EmitCustomEvent(name, data string)
EmitBeforeFork(targetID string, isUserMsg bool, userText string) (cancelled bool, reason string)
EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string)
EmitBeforeSessionSwitchWithPrompt(switchReason, initialPrompt string) (cancelled bool, reason string)
// Commands
Commands() []ExtensionCommandDef
@@ -568,20 +567,11 @@ func (e *extensionAPI) EmitBeforeFork(targetID string, isUserMsg bool, userText
}
func (e *extensionAPI) EmitBeforeSessionSwitch(switchReason string) (cancelled bool, reason string) {
return e.EmitBeforeSessionSwitchWithPrompt(switchReason, "")
}
// EmitBeforeSessionSwitchWithPrompt is like EmitBeforeSessionSwitch but also
// supplies the initial user prompt (if any) that will be submitted as the
// first turn of the new session. Extensions inspecting BeforeSessionSwitchEvent
// see this value in the event's InitialPrompt field.
func (e *extensionAPI) EmitBeforeSessionSwitchWithPrompt(switchReason, initialPrompt string) (cancelled bool, reason string) {
if e.kit.extRunner == nil || !e.kit.extRunner.HasHandlers(extensions.BeforeSessionSwitch) {
return false, ""
}
result, _ := e.kit.extRunner.Emit(extensions.BeforeSessionSwitchEvent{
Reason: switchReason,
InitialPrompt: initialPrompt,
Reason: switchReason,
})
if r, ok := result.(extensions.BeforeSessionSwitchResult); ok && r.Cancel {
reason := r.Reason
+19 -359
View File
@@ -23,7 +23,6 @@ import (
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/session"
"github.com/mark3labs/kit/internal/skills"
"github.com/mark3labs/kit/internal/skilltool"
"github.com/mark3labs/kit/internal/tools"
"github.com/spf13/viper"
@@ -116,11 +115,6 @@ type Kit struct {
steerMu sync.Mutex
steerCh chan agent.SteerMessage
leftoverSteer []agent.SteerMessage // unconsumed steer messages from the last turn
// promptOptsMu serializes per-call PromptOptions overrides that mutate
// shared agent state (model, thinking level, provider creds, extra tools)
// so the apply/restore window of one call never races another.
promptOptsMu sync.Mutex
}
// Subscribe registers an EventListener that will be called for every lifecycle
@@ -1010,24 +1004,9 @@ type Options struct {
// Skills
Skills []string // Explicit skill files/dirs to load (empty = auto-discover)
SkillsDir string // Direct skills directory to scan (overrides auto-discovery; scanned as-is)
SkillsDir string // Override default project-local skills directory
NoSkills bool // Disable skill loading entirely (auto-discovery and explicit)
// SkillsDisable names skills (by Name) to exclude from the model-facing
// catalog. Disabled skills remain available via the /skill: slash command.
SkillsDisable []string
// SkillTrustPrompt is an optional callback invoked the first time Kit
// auto-discovers project-local skills (under <project>/.agents/skills or
// <project>/.kit/skills) in a directory that is not yet on the trust
// allowlist. It receives the project directory and the number of skills
// found, and returns a TrustDecision controlling whether the skills load.
//
// When nil, project-local skills are loaded without prompting (historical
// behaviour). Directories trusted via TrustProject are persisted to
// ~/.config/kit/trusted-projects.json and not prompted again.
SkillTrustPrompt func(projectDir string, skillCount int) TrustDecision
// NoExtensions disables Yaegi extension loading entirely.
NoExtensions bool
@@ -1068,25 +1047,9 @@ type Options struct {
AutoCompact bool // Auto-compact when near context limit
CompactionOptions *CompactionOptions // Config for auto-compaction (nil = defaults)
// Debug enables debug logging for the SDK. When DebugLogger is nil this
// flag selects between the default no-op SimpleDebugLogger (Debug=false)
// and the built-in console/buffered logger (Debug=true). When DebugLogger
// is non-nil this flag is ignored — the supplied logger's
// IsDebugEnabled() controls whether downstream code emits messages.
// Debug enables debug logging for the SDK.
Debug bool
// DebugLogger, if non-nil, routes low-level debug output from the engine
// and the MCP tool plumbing to a caller-supplied implementation. This is
// the SDK escape hatch for embedders that want to forward debug output
// into their own logging system (zap, slog, log/charm, an in-app TUI
// panel, etc.) instead of the built-in console logger.
//
// When nil (default) the Debug bool controls whether the built-in logger
// is installed. When non-nil this logger is used unconditionally and the
// Debug bool is ignored; the supplied logger's IsDebugEnabled() reports
// whether downstream code should bother formatting messages.
DebugLogger DebugLogger
// MCPAuthHandler handles OAuth authorization for remote MCP servers.
// When set, remote transports (streamable HTTP, SSE) are configured
// with OAuth support. If the server returns a 401, the handler is
@@ -1367,37 +1330,12 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
}
// Load skills — either from explicit paths or via auto-discovery.
// Merge viper config with opts: CLI flag / config file values are
// already bound to viper by cmd/root.go, so v.GetBool("no-skills"),
// v.GetStringSlice("skill"), and v.GetString("skills-dir") capture
// both --flag and .kit.yml keys transparently.
noSkills := opts.NoSkills || v.GetBool("no-skills")
skillPaths := opts.Skills
if len(skillPaths) == 0 {
skillPaths = v.GetStringSlice("skill")
}
skillsDir := opts.SkillsDir
if skillsDir == "" {
skillsDir = v.GetString("skills-dir")
}
if !noSkills {
mergedOpts := *opts
mergedOpts.Skills = skillPaths
mergedOpts.SkillsDir = skillsDir
if !opts.NoSkills {
var err error
loadedSkills, err = loadSkills(&mergedOpts)
loadedSkills, err = loadSkills(opts)
if err != nil {
return fmt.Errorf("failed to load skills: %w", err)
}
// Apply per-skill disable list (--skill-disable / skill-disable
// config key). Disabled skills stay loaded (so /skill: still
// works) but are hidden from the model-facing catalog.
disable := opts.SkillsDisable
if len(disable) == 0 {
disable = v.GetStringSlice("skill-disable")
}
applySkillDisableList(loadedSkills, disable)
}
// Always compose the system prompt with runtime context: base prompt +
@@ -1551,41 +1489,15 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
// Build agent setup options, pulling CLI-specific fields when available.
// Pass the pre-built ProviderConfig and scalar viper snapshots so
// SetupAgent doesn't need to re-read viper (which would require the lock).
// Register the dedicated activate_skill tool when at least one skill is
// loaded (issue #65, gaps #13/#14). The provider closure reads the live
// skill set from the Kit instance once it exists so runtime additions
// resolve; skillToolKit is assigned after construction below.
var skillToolKit *Kit
extraTools := opts.ExtraTools
if len(loadedSkills) > 0 {
names := make([]string, 0, len(loadedSkills))
for _, s := range loadedSkills {
if !s.DisableModelInvocation {
names = append(names, s.Name)
}
}
provider := func() []*skills.Skill {
if skillToolKit == nil {
return loadedSkills
}
return skillToolKit.GetSkills()
}
if t := skilltool.New(names, provider); t != nil {
extraTools = append(extraTools, t)
}
}
setupOpts := kitsetup.AgentSetupOptions{
MCPConfig: mcpConfig,
Quiet: opts.Quiet,
CoreTools: opts.Tools,
DisableCoreTools: disableCoreTools,
ExtraTools: extraTools,
ExtraTools: opts.ExtraTools,
ToolWrapper: hookToolWrapper(beforeToolCall, afterToolResult),
ProviderConfig: providerConfig,
Debug: debug,
DebugLogger: opts.DebugLogger,
NoExtensions: noExtensions,
MaxSteps: maxSteps,
StreamingEnabled: streaming,
@@ -1681,10 +1593,6 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
prepareStep: prepareStep,
}
// Point the activate_skill provider closure at the live Kit instance so it
// resolves skills mutated after construction.
skillToolKit = k
// Bridge extension events to SDK hooks.
if agentResult.ExtRunner != nil {
k.bridgeExtensions(agentResult.ExtRunner)
@@ -1795,14 +1703,6 @@ func (m *Kit) expandSkillCommand(prompt string) string {
fmt.Fprintf(&buf, "<skill name=%q location=%q>\n", loaded.Name, loaded.Path)
fmt.Fprintf(&buf, "References are relative to %s.\n\n", baseDir)
buf.WriteString(loaded.Content)
// Enumerate bundled resources (scripts/, references/, assets/) so the model
// knows what it can read without listing the directory itself.
if res := skills.FormatResources(loaded.Resources()); res != "" {
buf.WriteString("\n\n")
buf.WriteString(res)
}
buf.WriteString("\n</skill>")
args = strings.TrimSpace(args)
@@ -1819,33 +1719,18 @@ func (m *Kit) expandSkillCommand(prompt string) string {
// ---------------------------------------------------------------------------
// loadSkills loads skills based on Options. If explicit paths are provided
// they are loaded directly. If SkillsDir is set it is treated as a direct
// skills directory (scanned as-is, not as a parent of .agents/.kit). Otherwise
// auto-discovery runs against the standard scopes rooted at SessionDir.
// they are loaded directly; otherwise auto-discovery runs.
func loadSkills(opts *Options) ([]*skills.Skill, error) {
if len(opts.Skills) > 0 {
return loadExplicitSkills(opts.Skills)
}
// An explicit --skills-dir is a direct skills directory: scan it as-is
// rather than appending .agents/skills and .kit/skills beneath it.
if opts.SkillsDir != "" {
return skills.LoadSkillsFromDir(opts.SkillsDir)
}
// Auto-discover from the standard scopes rooted at the session directory.
// Project-local skills are injected into the system prompt, so they are
// gated on a trust decision when a SkillTrustPrompt is configured.
cwd := opts.SessionDir
// Auto-discover from standard directories.
cwd := opts.SkillsDir
if cwd == "" {
cwd, _ = os.Getwd()
cwd = opts.SessionDir
}
user := skills.LoadUserSkills()
project := skills.LoadProjectSkills(cwd)
if len(project) > 0 && !projectSkillsTrusted(opts, cwd, len(project)) {
project = nil
}
return skills.Combine(user, project), nil
return skills.LoadSkills(cwd)
}
// loadExplicitSkills loads skills from a list of explicit paths. Each path
@@ -1923,58 +1808,6 @@ type TurnResult struct {
// any tool call/result messages added during the agent loop.
// Each message carries role and plain-text content.
Messages []LLMMessage
// FinalValue is set when a tool returned a [ToolOutput] with Halt=true
// during the turn. The dynamic type is whatever the tool handler placed
// in [ToolOutput.FinalValue]. Nil when no tool halted the turn.
FinalValue any
// HaltedByTool is the name of the tool that returned Halt=true, or empty
// if the turn ended for any other reason.
HaltedByTool string
// Stream contains every delta event observed during the turn in emit
// order. It is populated regardless of streaming mode (in non-streaming
// mode it carries the coarse-grained events the provider reported).
// PromptResult and the other turn-returning entry points always block
// until end-of-turn, so Stream is complete when they return.
Stream []StreamEvent
}
// StreamEventKind classifies a [StreamEvent] captured during a turn.
type StreamEventKind string
// Stream event kinds captured in [TurnResult.Stream].
const (
StreamEventTextDelta StreamEventKind = "text_delta"
StreamEventReasoningStart StreamEventKind = "reasoning_start"
StreamEventReasoningDelta StreamEventKind = "reasoning_delta"
StreamEventReasoningEnd StreamEventKind = "reasoning_end"
StreamEventToolCallChunk StreamEventKind = "tool_call_chunk"
)
// StreamEvent is a single delta observed during a turn, captured in
// [TurnResult.Stream]. It lets embedders assert streamed ordering
// deterministically without re-implementing an OnMessageUpdate collector.
type StreamEvent struct {
// Kind classifies the event.
Kind StreamEventKind
// Text carries the assistant text for StreamEventTextDelta.
Text string
// Reasoning carries the reasoning text for StreamEventReasoningDelta.
Reasoning string
// ToolName is the tool name for StreamEventToolCallChunk.
ToolName string
// ToolID is the tool call ID for StreamEventToolCallChunk.
ToolID string
// Args carries the (accumulating) tool-call argument JSON for
// StreamEventToolCallChunk.
Args string
}
// ---------------------------------------------------------------------------
@@ -2215,9 +2048,6 @@ func (m *Kit) Subagent(ctx context.Context, cfg SubagentConfig) (*SubagentResult
// All prompt modes (Prompt, Steer, FollowUp, PromptWithOptions) share this
// single code path so callback wiring is never duplicated.
func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.GenerateWithLoopResult, error) {
// Capture the per-turn stream collector (set by runTurn) so streamed
// deltas are recorded into TurnResult.Stream in emit order.
collector := streamCollectorFromContext(ctx)
// Create a per-turn steer channel and attach it to the context so the
// agent's PrepareStep can inject steering messages between steps.
steerCh := make(chan agent.SteerMessage, 16)
@@ -2335,30 +2165,24 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
i := strings.Index(remaining, thinkClose)
if i == -1 {
m.events.emit(ReasoningDeltaEvent{Delta: remaining})
collector.add(StreamEvent{Kind: StreamEventReasoningDelta, Reasoning: remaining})
return
}
if i > 0 {
m.events.emit(ReasoningDeltaEvent{Delta: remaining[:i]})
collector.add(StreamEvent{Kind: StreamEventReasoningDelta, Reasoning: remaining[:i]})
}
inThinkTag = false
m.events.emit(ReasoningCompleteEvent{})
collector.add(StreamEvent{Kind: StreamEventReasoningEnd})
remaining = remaining[i+len(thinkClose):]
} else {
i := strings.Index(remaining, thinkOpen)
if i == -1 {
m.events.emit(MessageUpdateEvent{Chunk: remaining})
collector.add(StreamEvent{Kind: StreamEventTextDelta, Text: remaining})
return
}
if i > 0 {
m.events.emit(MessageUpdateEvent{Chunk: remaining[:i]})
collector.add(StreamEvent{Kind: StreamEventTextDelta, Text: remaining[:i]})
}
inThinkTag = true
collector.add(StreamEvent{Kind: StreamEventReasoningStart})
remaining = remaining[i+len(thinkOpen):]
}
}
@@ -2366,11 +2190,9 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
}(),
OnReasoningDelta: func(delta string) {
m.events.emit(ReasoningDeltaEvent{Delta: delta})
collector.add(StreamEvent{Kind: StreamEventReasoningDelta, Reasoning: delta})
},
OnReasoningComplete: func() {
m.events.emit(ReasoningCompleteEvent{})
collector.add(StreamEvent{Kind: StreamEventReasoningEnd})
},
OnToolOutput: func(toolCallID, toolName, chunk string, isStderr bool) {
m.events.emit(ToolOutputEvent{
@@ -2417,14 +2239,12 @@ func (m *Kit) generate(ctx context.Context, messages []fantasy.Message) (*agent.
ToolName: toolName,
ToolKind: toolKindFor(toolName),
})
collector.add(StreamEvent{Kind: StreamEventToolCallChunk, ToolID: toolCallID, ToolName: toolName})
},
OnToolCallDelta: func(toolCallID, delta string) {
m.events.emit(ToolCallDeltaEvent{
ToolCallID: toolCallID,
Delta: delta,
})
collector.add(StreamEvent{Kind: StreamEventToolCallChunk, ToolID: toolCallID, Args: delta})
},
OnToolCallEnd: func(toolCallID string) {
m.events.emit(ToolCallEndEvent{
@@ -2576,14 +2396,6 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
sentCount := len(messages)
// Attach a per-turn stream collector and halt holder so generate's
// callbacks can capture delta events (TurnResult.Stream) and tools can
// signal loop termination (TurnResult.FinalValue / HaltedByTool).
collector := &streamCollector{}
holder := &haltHolder{}
ctx = context.WithValue(ctx, streamCollectorKey{}, collector)
ctx = context.WithValue(ctx, haltHolderKey{}, holder)
m.events.emit(TurnStartEvent{Prompt: promptLabel})
m.events.emit(MessageStartEvent{})
@@ -2606,7 +2418,7 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
m.events.emit(TurnEndEvent{Error: err})
// Run AfterTurn hooks even on error.
m.afterTurn.run(AfterTurnHook{Error: err})
return nil, ClassifyProviderError(err)
return nil, err
}
responseText := result.FinalResponse.Content.Text()
@@ -2659,13 +2471,6 @@ func (m *Kit) runTurn(ctx context.Context, promptLabel string, prompt string, pr
turnResult.FinalUsage = &finalUsage
}
// Surface captured stream deltas and any tool-driven halt signal.
turnResult.Stream = collector.drain()
if halted, toolName, value := holder.snapshot(); halted {
turnResult.HaltedByTool = toolName
turnResult.FinalValue = value
}
return turnResult, nil
}
@@ -2807,158 +2612,28 @@ type PromptOptions struct {
// Use it to inject per-call instructions or context without permanently
// modifying the agent's system prompt.
SystemMessage string
// Model overrides the agent's configured model for this call only. Empty
// string means "use the agent's default". The previous model is restored
// after the call returns.
Model string
// ThinkingLevel overrides the agent's reasoning level for this call only
// (e.g. "off", "low", "medium", "high"). Empty string means "use the
// agent's default". The previous level is restored after the call.
ThinkingLevel string
// ExtraTools are added to the effective tool set for this call only and
// removed afterwards.
ExtraTools []Tool
// ProviderURL overrides the provider base URL for this call only. Useful
// for multi-tenant embedders that resolve endpoints per request. The
// previous value is restored after the call.
ProviderURL string
// ProviderAPIKey overrides the provider credential for this call only.
// The previous value is restored after the call.
ProviderAPIKey string
}
// applyPromptOptions applies the per-call overrides in opts to the shared
// agent state and returns a restore function that reverts every change. It
// holds promptOptsMu for the lifetime of the override window (the returned
// restore releases it), so concurrent option-driven prompts are serialized.
// On error nothing is changed and the lock is released.
func (m *Kit) applyPromptOptions(ctx context.Context, opts PromptOptions) (func(), error) {
needsModelRebuild := opts.Model != "" || opts.ThinkingLevel != "" ||
opts.ProviderURL != "" || opts.ProviderAPIKey != ""
if !needsModelRebuild && len(opts.ExtraTools) == 0 {
return func() {}, nil
}
m.promptOptsMu.Lock()
var restores []func()
restore := func() {
for i := len(restores) - 1; i >= 0; i-- {
restores[i]()
}
m.promptOptsMu.Unlock()
}
// Extra tools (additive) — restored by re-setting the prior slice.
if len(opts.ExtraTools) > 0 {
prev := m.agent.GetExtraTools()
merged := make([]Tool, 0, len(prev)+len(opts.ExtraTools))
merged = append(merged, prev...)
merged = append(merged, opts.ExtraTools...)
m.agent.SetExtraTools(merged)
restores = append(restores, func() { m.agent.SetExtraTools(prev) })
}
if needsModelRebuild {
prevModel := m.modelString
prevThinkingSet := m.v.IsSet("thinking-level")
prevThinking := m.v.GetString("thinking-level")
prevURLSet := m.v.IsSet("provider-url")
prevURL := m.v.GetString("provider-url")
prevKeySet := m.v.IsSet("provider-api-key")
prevKey := m.v.GetString("provider-api-key")
if opts.ThinkingLevel != "" {
m.v.Set("thinking-level", opts.ThinkingLevel)
}
if opts.ProviderURL != "" {
m.v.Set("provider-url", opts.ProviderURL)
}
if opts.ProviderAPIKey != "" {
m.v.Set("provider-api-key", opts.ProviderAPIKey)
}
targetModel := opts.Model
if targetModel == "" {
targetModel = prevModel
}
if err := m.SetModel(ctx, targetModel); err != nil {
// Revert config keys we may have set, then unwind prior restores.
restoreViperString(m.v, "thinking-level", prevThinking, prevThinkingSet)
restoreViperString(m.v, "provider-url", prevURL, prevURLSet)
restoreViperString(m.v, "provider-api-key", prevKey, prevKeySet)
restore()
return nil, err
}
restores = append(restores, func() {
restoreViperString(m.v, "thinking-level", prevThinking, prevThinkingSet)
restoreViperString(m.v, "provider-url", prevURL, prevURLSet)
restoreViperString(m.v, "provider-api-key", prevKey, prevKeySet)
// Use a fresh context: the rollback must complete even if the
// caller's ctx was canceled or expired during the call, otherwise
// the per-call model override would leak into subsequent calls.
_ = m.SetModel(context.Background(), prevModel)
})
}
return restore, nil
}
// restoreViperString restores a config key to its prior value, clearing it
// back to the unset state when it was not explicitly set before.
func restoreViperString(v *viper.Viper, key, prev string, wasSet bool) {
if wasSet {
v.Set(key, prev)
return
}
v.Set(key, "")
}
// PromptWithOptions sends a message with per-call configuration. It behaves
// like Prompt but applies the overrides in opts (system message, model,
// thinking level, provider credentials, extra tools) for this call only,
// restoring the agent's prior state afterwards.
// like Prompt but allows injecting an additional system message before the
// user prompt. Both messages are persisted to the session.
func (m *Kit) PromptWithOptions(ctx context.Context, msg string, opts PromptOptions) (string, error) {
result, err := m.PromptResultWithOptions(ctx, msg, opts)
if err != nil {
return "", err
}
return result.Response, nil
}
// PromptResultWithOptions is the [TurnResult]-returning counterpart of
// PromptWithOptions. Like all turn-returning entry points it blocks until
// end-of-turn, so the returned TurnResult (including TurnResult.Stream) is
// complete when it returns. Per-call overrides in opts are applied for this
// call only and the agent's prior state is restored before returning.
func (m *Kit) PromptResultWithOptions(ctx context.Context, msg string, opts PromptOptions) (*TurnResult, error) {
restore, err := m.applyPromptOptions(ctx, opts)
if err != nil {
return nil, err
}
defer restore()
var preMessages []fantasy.Message
if opts.SystemMessage != "" {
preMessages = append(preMessages, fantasy.NewSystemMessage(opts.SystemMessage))
}
preMessages = append(preMessages, fantasy.NewUserMessage(msg))
return m.runTurn(ctx, msg, msg, preMessages)
result, err := m.runTurn(ctx, msg, msg, preMessages)
if err != nil {
return "", err
}
return result.Response, nil
}
// PromptResult sends a message and returns the full turn result including
// usage statistics and conversation messages. Use this instead of Prompt()
// when you need more than just the response text.
//
// PromptResult blocks until end-of-turn regardless of whether streaming is
// enabled. When streaming is enabled, every delta observed during the turn is
// also captured in order in [TurnResult.Stream], so callers can assert
// streamed ordering deterministically without an OnMessageUpdate collector.
func (m *Kit) PromptResult(ctx context.Context, message string) (*TurnResult, error) {
return m.runTurn(ctx, message, message, []fantasy.Message{
fantasy.NewUserMessage(message),
@@ -3096,18 +2771,7 @@ func extractFileParts(msg fantasy.Message) []fantasy.FilePart {
// Close cleans up resources including MCP server connections, model resources,
// and the tree session file handle. Should be called when the Kit instance is
// no longer needed. Returns an error if cleanup fails.
//
// Close is equivalent to CloseContext(context.Background()). Use
// [Kit.CloseContext] when shutdown must be bounded by a deadline.
func (m *Kit) Close() error {
return m.CloseContext(context.Background())
}
// CloseContext is like [Kit.Close] but accepts a context so graceful shutdown
// can be bounded by a deadline or cancellation. The context is honored on a
// best-effort basis: if it is already done when CloseContext is called, the
// context error is returned after a best-effort cleanup pass.
func (m *Kit) CloseContext(ctx context.Context) error {
// Emit SessionShutdown for extensions.
if m.extRunner != nil && m.extRunner.HasHandlers(extensions.SessionShutdown) {
_, _ = m.extRunner.Emit(extensions.SessionShutdownEvent{})
@@ -3119,11 +2783,7 @@ func (m *Kit) CloseContext(ctx context.Context) error {
if closer, ok := m.authHandler.(interface{ Close() error }); ok {
_ = closer.Close()
}
err := m.agent.Close()
if ctxErr := ctx.Err(); ctxErr != nil && err == nil {
return ctxErr
}
return err
return m.agent.Close()
}
// Conversion helpers are defined in adapter.go.
-75
View File
@@ -365,81 +365,6 @@ func TestNewSystemPromptFilePath(t *testing.T) {
}
}
// TestNewWithSkillsOptions verifies that the three skills-related Options
// fields (NoSkills, Skills, SkillsDir) are wired correctly into kit.New().
func TestNewWithSkillsOptions(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
ctx := context.Background()
t.Run("NoSkills disables skill loading", func(t *testing.T) {
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
NoSkills: true,
})
if err != nil {
t.Fatalf("kit.New failed: %v", err)
}
defer func() { _ = host.Close() }()
if got := host.GetSkills(); len(got) != 0 {
t.Errorf("NoSkills=true: expected 0 skills, got %d", len(got))
}
})
t.Run("SkillsDir propagates", func(t *testing.T) {
// Use a non-existent dir — no skills will load but the option must be
// accepted without error and result in zero skills.
dir := t.TempDir()
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
SkillsDir: dir,
})
if err != nil {
t.Fatalf("kit.New failed: %v", err)
}
defer func() { _ = host.Close() }()
// Empty dir → no skills; the important thing is no error.
_ = host.GetSkills()
})
t.Run("explicit Skills paths load correctly", func(t *testing.T) {
// Write a minimal skill file to a temp dir.
dir := t.TempDir()
skillFile := dir + "/my-skill.md"
content := "---\nname: test-skill\ndescription: A test skill\n---\nDo the thing.\n"
if err := os.WriteFile(skillFile, []byte(content), 0o644); err != nil {
t.Fatalf("failed to write skill file: %v", err)
}
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
Skills: []string{skillFile},
})
if err != nil {
t.Fatalf("kit.New failed: %v", err)
}
defer func() { _ = host.Close() }()
skills := host.GetSkills()
if len(skills) != 1 {
t.Fatalf("expected 1 skill, got %d", len(skills))
}
if skills[0].Name != "test-skill" {
t.Errorf("skill name = %q; want %q", skills[0].Name, "test-skill")
}
})
}
// TestNewSystemPromptInline confirms that inline system-prompt strings still
// flow through unchanged after the file-path resolution change.
func TestNewSystemPromptInline(t *testing.T) {
+33 -5
View File
@@ -102,11 +102,10 @@ type MCPTaskProgressHandler func(MCPTaskProgress)
// are optional; the zero value disables progress callbacks and applies
// sensible polling defaults inside the engine.
//
// Most consumers configure these via the flat [Options] fields
// (`MCPTaskMode`, `MCPTaskTTL`, `MCPTaskPollInterval`,
// `MCPTaskMaxPollInterval`, `MCPTaskTimeout`, `MCPTaskProgress`). The
// MCPTaskConfig type itself is retained for downstream consumers that
// receive it on engine-facing call sites.
// For most consumers, the flat [Options] fields (`MCPTaskMode`,
// `MCPTaskTTL`, `MCPTaskPollInterval`, `MCPTaskMaxPollInterval`,
// `MCPTaskTimeout`, `MCPTaskProgress`) are the preferred entry point.
// MCPTaskConfig is exposed for the low-level [AgentConfig] path.
type MCPTaskConfig struct {
// PerServerMode overrides the per-server task mode resolved from
// [MCPServerConfig]. Keys are server names. Missing entries fall back
@@ -134,6 +133,35 @@ type MCPTaskConfig struct {
Progress MCPTaskProgressHandler
}
// toToolsConfig converts the SDK-level [MCPTaskConfig] to the internal
// tools-package representation. Keeps the dependency arrow internal-only.
func (c MCPTaskConfig) toToolsConfig() tools.MCPTaskConfig {
cfg := tools.MCPTaskConfig{
DefaultTTL: c.DefaultTTL,
PollInterval: c.PollInterval,
MaxPollInterval: c.MaxPollInterval,
Timeout: c.Timeout,
}
if len(c.PerServerMode) > 0 {
cfg.PerServerMode = make(map[string]tools.MCPTaskMode, len(c.PerServerMode))
for k, v := range c.PerServerMode {
cfg.PerServerMode[k] = tools.MCPTaskMode(v)
}
}
if c.Progress != nil {
h := c.Progress
cfg.Progress = func(p tools.MCPTaskProgress) {
h(MCPTaskProgress{
Server: p.Server,
TaskID: p.TaskID,
Status: MCPTaskStatus(p.Status),
Message: p.Message,
})
}
}
return cfg
}
// mcpTaskOptions carries SDK consumer configuration into the agent setup.
// Stored on Options as a single value so the public surface stays compact;
// individual fields are exposed via WithMCP* builder functions.
-11
View File
@@ -83,17 +83,6 @@ func WithConfigFile(path string) Option { return func(o *Options) { o.ConfigFile
// WithDebug enables SDK debug logging.
func WithDebug() Option { return func(o *Options) { o.Debug = true } }
// WithDebugLogger installs a caller-supplied [DebugLogger] for low-level
// engine and MCP tool plumbing output. When set this overrides the built-in
// logger selected by [WithDebug] — messages flow into the supplied logger
// unconditionally, and the logger's IsDebugEnabled reports whether downstream
// code should bother formatting them. Use this to forward Kit's debug output
// into your application's logging system (slog, zap, charm/log, an in-app
// panel, etc.).
func WithDebugLogger(l DebugLogger) Option {
return func(o *Options) { o.DebugLogger = l }
}
// Ephemeral configures an in-memory session with no persistence (equivalent to
// Options.NoSession = true).
func Ephemeral() Option { return func(o *Options) { o.NoSession = true } }
-102
View File
@@ -1,102 +0,0 @@
package kit_test
import (
"context"
"testing"
"charm.land/fantasy"
"github.com/mark3labs/kit/pkg/kit"
)
func TestNewRawTool(t *testing.T) {
schema := map[string]any{
"type": "object",
"properties": map[string]any{
"city": map[string]any{"type": "string", "description": "City name"},
},
"required": []any{"city"},
}
var gotArgs map[string]any
tool := kit.NewRawTool("get_weather", "Get weather", schema,
func(ctx context.Context, args map[string]any) (kit.ToolOutput, error) {
gotArgs = args
return kit.TextResult("72F in " + args["city"].(string)), nil
},
)
info := tool.Info()
if info.Name != "get_weather" {
t.Fatalf("name = %q", info.Name)
}
if info.Parameters["type"] != "object" {
t.Fatalf("schema not propagated: %#v", info.Parameters)
}
if len(info.Required) != 1 || info.Required[0] != "city" {
t.Fatalf("required not propagated: %#v", info.Required)
}
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call_1",
Input: `{"city":"Boston"}`,
})
if err != nil {
t.Fatalf("Run error: %v", err)
}
if resp.IsError {
t.Fatalf("unexpected error response: %q", resp.Content)
}
if resp.Content != "72F in Boston" {
t.Fatalf("content = %q", resp.Content)
}
if gotArgs["city"] != "Boston" {
t.Fatalf("args not decoded: %#v", gotArgs)
}
}
func TestNewRawToolInvalidArgs(t *testing.T) {
tool := kit.NewRawTool("t", "d", nil,
func(ctx context.Context, args map[string]any) (kit.ToolOutput, error) {
t.Fatal("handler should not be called for invalid args")
return kit.ToolOutput{}, nil
},
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{ID: "x", Input: `not json`})
if err != nil {
t.Fatalf("Run error: %v", err)
}
if !resp.IsError {
t.Fatalf("expected error response for invalid args")
}
}
// Contract: null / whitespace-padded-null inputs must hand the handler a
// non-nil empty map (not a nil map), so handlers can read or write keys
// without a nil-map panic. Inputs are normalised before reaching the handler.
func TestNewRawToolNullArgs(t *testing.T) {
for _, input := range []string{"null", " null ", "\tnull\n"} {
called := false
var gotNil bool
tool := kit.NewRawTool("t", "d", nil,
func(ctx context.Context, args map[string]any) (kit.ToolOutput, error) {
called = true
gotNil = args == nil
return kit.TextResult("ok"), nil
},
)
resp, err := tool.Run(context.Background(), fantasy.ToolCall{ID: "x", Input: input})
if err != nil {
t.Fatalf("input %q: Run error: %v", input, err)
}
if resp.IsError {
t.Fatalf("input %q: unexpected error response: %q", input, resp.Content)
}
if !called {
t.Fatalf("input %q: handler not called", input)
}
if gotNil {
t.Fatalf("input %q: args was nil, want non-nil empty map", input)
}
}
}
-11
View File
@@ -1,14 +1,9 @@
package kit
import (
"errors"
"time"
)
// ErrBranchSummaryNotSupported is returned by SessionManager implementations
// that do not support collapsing a branch range into a summary entry.
var ErrBranchSummaryNotSupported = errors.New("session manager does not support branch summaries")
// SessionManager defines the contract for conversation storage backends.
// Implementations can use files (default), databases, cloud storage, etc.
//
@@ -94,12 +89,6 @@ type SessionManager interface {
// determine which entries to summarize.
GetContextEntryIDs() []string
// AppendBranchSummary collapses the range from fromID to the current leaf
// on the active branch into a single summary entry and returns the new
// entry ID. It backs [Kit.CollapseBranch]. Managers that do not track
// branch summaries should return [ErrBranchSummaryNotSupported].
AppendBranchSummary(fromID, summary string) (entryID string, err error)
// Close releases resources (database connections, file handles, etc.).
Close() error
}
+9 -9
View File
@@ -217,19 +217,19 @@ func (m *Kit) SummarizeBranch(fromID, toID string) (string, error) {
// CollapseBranch replaces a branch range with a summary entry.
// Returns an error if the session is unavailable or the operation fails.
// Custom SessionManagers that do not support branch summaries surface
// [ErrBranchSummaryNotSupported].
//
// The branch is always collapsed from fromID to the current leaf; the toID
// parameter is currently unused (the underlying AppendBranchSummary primitive
// only supports collapsing to the leaf) and is retained for forward
// compatibility.
func (m *Kit) CollapseBranch(fromID, toID, summary string) error {
if m.session == nil {
return fmt.Errorf("no session available")
}
_, err := m.session.AppendBranchSummary(fromID, summary)
return err
// Note: This operation is not directly supported by SessionManager interface
// as it requires AppendBranchSummary which is TreeManager-specific.
// For custom SessionManagers, this would need to be implemented differently.
// For now, we try to use the underlying TreeManager if available.
if adapter, ok := m.session.(*treeManagerAdapter); ok {
_, err := adapter.inner.AppendBranchSummary(fromID, summary)
return err
}
return fmt.Errorf("CollapseBranch not supported by custom session manager")
}
// branchEntryToTreeNode converts a BranchEntry to a TreeNode.
+10 -137
View File
@@ -2,12 +2,10 @@ package kit
import (
"fmt"
"io/fs"
"os"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/skills"
"github.com/mark3labs/kit/internal/trust"
)
// ==== Skills Types ====
@@ -38,28 +36,12 @@ func LoadSkillsFromDir(dir string) ([]*Skill, error) {
return skills.LoadSkillsFromDir(dir)
}
// LoadSkillsFromFS is the [fs.FS]-typed counterpart of [LoadSkillsFromDir].
// It walks fsys starting at root (which may be "." or a subdirectory), finds
// *.md/*.txt files and SKILL.md files in subdirectories, parses YAML
// frontmatter + markdown body, and returns the loaded skills. Use it when
// skill discovery is wrapped in an fs.FS abstraction (embed.FS distribution,
// fstest.MapFS tests, or per-tenant virtual filesystems).
//
// Each loaded skill's Path is its slash-separated path within fsys, since
// fs.FS has no notion of an absolute on-disk path.
func LoadSkillsFromFS(fsys fs.FS, root string) ([]*Skill, error) {
return skills.LoadSkillsFromFS(fsys, root)
}
// LoadSkills auto-discovers skills from standard directories:
// - User-level: ~/.agents/skills/ (cross-client convention)
// - User-level: $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
// - Project-local: <cwd>/.agents/skills/ (cross-client convention)
// - Project-local: <cwd>/.kit/skills/ (Kit-specific)
// - Global: $XDG_CONFIG_HOME/kit/skills/ (default ~/.config/kit/skills/)
// - Project-local: <cwd>/.kit/skills/
//
// Project-level skills take precedence over user-level skills with the same
// name. cwd is the working directory for project-local discovery; if empty
// the current working directory is used.
// cwd is the working directory for project-local discovery; if empty the
// current working directory is used.
func LoadSkills(cwd string) ([]*Skill, error) {
return skills.LoadSkills(cwd)
}
@@ -131,17 +113,12 @@ func (m *Kit) LoadSkillsFromDirForExtension(dir string) extensions.SkillLoadResu
// convertSkill converts internal skill to extension-facing format.
func (m *Kit) convertSkill(s *skills.Skill) *extensions.Skill {
return &extensions.Skill{
Name: s.Name,
Description: s.Description,
Content: s.Content,
Path: s.Path,
License: s.License,
Compatibility: s.Compatibility,
Metadata: s.Metadata,
AllowedTools: s.AllowedTools,
DisableModelInvocation: s.DisableModelInvocation,
Tags: s.Tags,
When: s.When,
Name: s.Name,
Description: s.Description,
Content: s.Content,
Path: s.Path,
Tags: s.Tags,
When: s.When,
}
}
@@ -309,107 +286,3 @@ func (m *Kit) applyComposedSystemPrompt() {
func (m *Kit) RefreshSystemPrompt() {
m.applyComposedSystemPrompt()
}
// ---------------------------------------------------------------------------
// Per-skill disable (Issue #65, gap #10)
// ---------------------------------------------------------------------------
// applySkillDisableList sets DisableModelInvocation on every skill whose Name
// appears in names. Disabled skills remain loaded (so explicit /skill:
// activation still works) but are hidden from the model-facing catalog.
func applySkillDisableList(skillList []*skills.Skill, names []string) {
if len(names) == 0 {
return
}
disabled := make(map[string]bool, len(names))
for _, n := range names {
disabled[n] = true
}
for _, s := range skillList {
if disabled[s.Name] {
s.DisableModelInvocation = true
}
}
}
// DisableSkill hides the named skill from the model-facing catalog while
// keeping it loaded (so it can still be activated explicitly via /skill:).
// The system prompt is recomposed and applied. Returns true when a skill with
// that name was found.
func (m *Kit) DisableSkill(name string) bool {
return m.setSkillModelInvocation(name, true)
}
// EnableSkill re-exposes a previously disabled skill in the model-facing
// catalog. The system prompt is recomposed and applied. Returns true when a
// skill with that name was found.
func (m *Kit) EnableSkill(name string) bool {
return m.setSkillModelInvocation(name, false)
}
// setSkillModelInvocation toggles DisableModelInvocation on the named skill
// and refreshes the system prompt. Returns true when the skill was found.
func (m *Kit) setSkillModelInvocation(name string, disabled bool) bool {
m.runtimeMu.Lock()
found := false
for _, s := range m.skills {
if s.Name == name {
s.DisableModelInvocation = disabled
found = true
break
}
}
m.runtimeMu.Unlock()
if !found {
return false
}
m.ClearSkillCache()
m.applyComposedSystemPrompt()
return true
}
// ---------------------------------------------------------------------------
// Project-skill trust gate (Issue #65, gap #8)
// ---------------------------------------------------------------------------
// TrustDecision is the outcome of a project-skill trust prompt.
type TrustDecision = trust.Decision
// Trust-prompt outcomes. They mirror the trust package decisions.
const (
// SkipProjectSkills declines to load project skills this session.
SkipProjectSkills = trust.Skip
// TrustProject loads project skills and persists the directory as trusted.
TrustProject = trust.Trust
// TrustProjectOnce loads project skills this session without persisting.
TrustProjectOnce = trust.TrustOnce
)
// projectSkillsTrusted decides whether project-local skills discovered in dir
// should be loaded. When no SkillTrustPrompt is configured the directory is
// trusted by default (preserving historical behaviour). Otherwise a persisted
// allowlist is consulted first, then the prompt is invoked for an unknown
// directory and the decision is persisted when the user chooses TrustProject.
func projectSkillsTrusted(opts *Options, dir string, count int) bool {
if opts.SkillTrustPrompt == nil {
return true
}
store, err := trust.Load("")
if err == nil && store.IsTrusted(dir) {
return true
}
switch opts.SkillTrustPrompt(dir, count) {
case TrustProject:
if store != nil {
_ = store.Trust(dir)
}
return true
case TrustProjectOnce:
return true
default:
return false
}
}
-120
View File
@@ -1,120 +0,0 @@
package kit
import (
"os"
"path/filepath"
"testing"
)
func writeSkillFile(t *testing.T, path, name, desc string) {
t.Helper()
content := "---\nname: " + name + "\ndescription: " + desc + "\n---\nBody."
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// TestLoadSkills_SkillsDirIsDirect verifies --skills-dir scans the directory
// directly rather than appending .agents/.kit beneath it (issue #65, gap #3).
func TestLoadSkills_SkillsDirIsDirect(t *testing.T) {
dir := t.TempDir()
writeSkillFile(t, filepath.Join(dir, "direct.md"), "direct", "A direct skill")
got, err := loadSkills(&Options{SkillsDir: dir})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Name != "direct" {
t.Fatalf("expected 1 skill named 'direct', got %+v", got)
}
}
// TestApplySkillDisableList verifies the disable list hides a skill from the
// catalog (issue #65, gap #10).
func TestApplySkillDisableList(t *testing.T) {
dir := t.TempDir()
writeSkillFile(t, filepath.Join(dir, "a.md"), "a", "skill a")
writeSkillFile(t, filepath.Join(dir, "b.md"), "b", "skill b")
got, err := loadSkills(&Options{SkillsDir: dir})
if err != nil {
t.Fatal(err)
}
applySkillDisableList(got, []string{"a"})
var aDisabled, bDisabled bool
for _, s := range got {
switch s.Name {
case "a":
aDisabled = s.DisableModelInvocation
case "b":
bDisabled = s.DisableModelInvocation
}
}
if !aDisabled {
t.Error("skill 'a' should be disabled")
}
if bDisabled {
t.Error("skill 'b' should not be disabled")
}
}
// TestProjectSkillsTrust verifies the trust gate drops untrusted project
// skills and honours a Trust decision (issue #65, gap #8).
func TestProjectSkillsTrust(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "xdg"))
t.Setenv("HOME", filepath.Join(dir, "home"))
projectDir := filepath.Join(dir, "repo")
skillsDir := filepath.Join(projectDir, ".agents", "skills")
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
t.Fatal(err)
}
writeSkillFile(t, filepath.Join(skillsDir, "proj.md"), "proj", "project skill")
// Skip decision → no project skills loaded.
skipped, err := loadSkills(&Options{
SessionDir: projectDir,
SkillTrustPrompt: func(_ string, _ int) TrustDecision {
return SkipProjectSkills
},
})
if err != nil {
t.Fatal(err)
}
if len(skipped) != 0 {
t.Fatalf("expected 0 skills when trust is skipped, got %d", len(skipped))
}
// Trust decision → project skills loaded and directory persisted.
prompted := 0
trusted, err := loadSkills(&Options{
SessionDir: projectDir,
SkillTrustPrompt: func(_ string, _ int) TrustDecision {
prompted++
return TrustProject
},
})
if err != nil {
t.Fatal(err)
}
if len(trusted) != 1 {
t.Fatalf("expected 1 skill when trusted, got %d", len(trusted))
}
// A subsequent load should not prompt again (persisted trust).
again, err := loadSkills(&Options{
SessionDir: projectDir,
SkillTrustPrompt: func(_ string, _ int) TrustDecision {
t.Error("should not prompt for an already-trusted directory")
return SkipProjectSkills
},
})
if err != nil {
t.Fatal(err)
}
if len(again) != 1 {
t.Fatalf("expected 1 skill on trusted reload, got %d", len(again))
}
}
-15
View File
@@ -1,15 +0,0 @@
//go:build testing
package kit
import "github.com/mark3labs/kit/internal/config"
// ResetForTesting clears package-global state that survives across tests in
// the same binary. It is intended for test-binary teardown / between-test
// cleanup. Safe to call concurrently with no in-flight kit.New() calls.
//
// This function is only compiled under the "testing" build tag so it never
// ships in production binaries.
func ResetForTesting() {
config.SetConfigPath("")
}
-155
View File
@@ -1,12 +1,8 @@
package kit
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"charm.land/fantasy"
@@ -44,20 +40,6 @@ type ToolOutput struct {
// Metadata is optional opaque metadata attached to the response.
// It is not sent to the LLM but may be consumed by hooks or the UI.
Metadata any
// FinalValue, when Halt is true, is propagated to the turn's
// [TurnResult.FinalValue] so the caller can recover a typed result
// produced by the tool (e.g. a structured "finish" tool). The dynamic
// type is whatever the tool handler stored. Ignored when Halt is false.
FinalValue any
// Halt, when true, signals that the agent loop should terminate after
// this tool call. Content is still returned to the model for the current
// step, but [TurnResult.FinalValue] and [TurnResult.HaltedByTool] are
// populated so embedders building structured-result extraction patterns
// (model calls a finish(...) tool, the loop ends, the typed value is
// returned) no longer need a side-channel.
Halt bool
}
// TextResult creates a successful text [ToolOutput].
@@ -90,49 +72,6 @@ func MediaResult(content string, data []byte, mediaType string) ToolOutput {
// toolCallIDKey is the context key for the tool call ID.
type toolCallIDKey struct{}
// haltHolderKey is the context key for the per-turn halt holder. It is
// injected by runTurn so tool handlers created with [NewTool],
// [NewParallelTool], and [NewRawTool] can signal loop termination and carry a
// final value out to the [TurnResult] without an embedder-side side-channel.
type haltHolderKey struct{}
// haltHolder captures a Halt signal raised by a tool handler during a turn.
type haltHolder struct {
mu sync.Mutex
halted bool
toolName string
value any
}
func (h *haltHolder) set(toolName string, value any) {
h.mu.Lock()
defer h.mu.Unlock()
// First halt wins so the earliest finishing tool determines the result.
if h.halted {
return
}
h.halted = true
h.toolName = toolName
h.value = value
}
func (h *haltHolder) snapshot() (bool, string, any) {
h.mu.Lock()
defer h.mu.Unlock()
return h.halted, h.toolName, h.value
}
// recordHalt records a Halt signal from a tool handler onto the per-turn halt
// holder, if one is present in the context.
func recordHalt(ctx context.Context, toolName string, result ToolOutput) {
if !result.Halt {
return
}
if holder, ok := ctx.Value(haltHolderKey{}).(*haltHolder); ok && holder != nil {
holder.set(toolName, result.FinalValue)
}
}
// ToolCallIDFromContext extracts the tool call ID from the context.
// The call ID is set automatically by [NewTool] and [NewParallelTool]
// before invoking the handler. Returns an empty string if no ID is present.
@@ -205,7 +144,6 @@ func NewTool[TInput any](name, description string, fn func(ctx context.Context,
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
recordHalt(ctx, name, result)
return toolOutputToResponse(result), nil
},
)
@@ -222,104 +160,11 @@ func NewParallelTool[TInput any](name, description string, fn func(ctx context.C
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
recordHalt(ctx, name, result)
return toolOutputToResponse(result), nil
},
)
}
// rawToolInput is the decoded carrier used by [NewRawTool]. Using
// json.RawMessage lets the typed-tool machinery in fantasy generate a
// permissive object schema while we forward the raw arguments to the handler
// as a decoded map.
type rawToolInput = json.RawMessage
// NewRawTool is the schema-driven counterpart to [NewTool]. Use it when the
// tool's input shape isn't known at compile time — for example tools loaded
// from JSON Schema definitions in skill files or MCP server catalogs.
//
// schema must be a valid JSON Schema describing the tool's input object; it is
// advertised to the model as the tool's parameter schema. fn receives the
// decoded JSON arguments as a map and returns a [ToolOutput]. Like [NewTool],
// the tool call ID is injected into the context and can be retrieved with
// [ToolCallIDFromContext], and [ToolOutput.Halt] is honored.
//
// If the model sends arguments that are not a valid JSON object the call
// short-circuits with an error [ToolResponse] before fn is invoked.
func NewRawTool(
name, description string,
schema map[string]any,
fn func(ctx context.Context, args map[string]any) (ToolOutput, error),
) Tool {
tool := fantasy.NewAgentTool(name, description,
func(ctx context.Context, input rawToolInput, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
ctx = context.WithValue(ctx, toolCallIDKey{}, call.ID)
args := map[string]any{}
// Normalise whitespace before the null/empty guard so values like
// " null " or "\tnull\n" take the same skip-unmarshal path as the
// bare "null" and the handler always receives a non-nil empty map.
// (fantasy currently trims via its RawMessage decode, but this keeps
// the guard correct independent of that upstream behaviour.)
trimmed := bytes.TrimSpace(input)
if len(trimmed) > 0 && !bytes.Equal(trimmed, []byte("null")) {
if err := json.Unmarshal(trimmed, &args); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid arguments for tool %q: %v", name, err)), nil
}
}
result, err := fn(ctx, args)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
recordHalt(ctx, name, result)
return toolOutputToResponse(result), nil
},
)
// Override the auto-generated schema with the caller-supplied one so the
// model sees the real input shape instead of the permissive raw-message
// schema.
if len(schema) > 0 {
info := tool.Info()
info.Parameters = schema
info.Required = requiredFromSchema(schema)
tool = &schemaOverrideTool{AgentTool: tool, info: info}
}
return tool
}
// schemaOverrideTool wraps an [fantasy.AgentTool] to advertise a
// caller-supplied JSON Schema instead of the auto-generated one. Used by
// [NewRawTool].
type schemaOverrideTool struct {
fantasy.AgentTool
info fantasy.ToolInfo
}
// Info returns the tool info carrying the overridden parameter schema.
func (t *schemaOverrideTool) Info() fantasy.ToolInfo { return t.info }
// requiredFromSchema extracts the top-level "required" array from a JSON
// Schema object, tolerating both []string and []any element types.
func requiredFromSchema(schema map[string]any) []string {
raw, ok := schema["required"]
if !ok {
return nil
}
switch v := raw.(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, e := range v {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
default:
return nil
}
}
// --- Individual tool constructors ---
// NewReadTool creates a file-reading tool.
-48
View File
@@ -1,48 +0,0 @@
package kit
import (
"context"
"sync"
)
// streamCollectorKey is the context key carrying the per-turn stream collector
// so the agent callbacks in generate can capture delta events into
// TurnResult.Stream without re-implementing an OnMessageUpdate handler.
type streamCollectorKey struct{}
// streamCollector accumulates StreamEvents observed during a single turn in
// emit order. It is safe for concurrent use because tool-call deltas and text
// deltas may be emitted from different goroutines.
type streamCollector struct {
mu sync.Mutex
events []StreamEvent
}
func (c *streamCollector) add(e StreamEvent) {
if c == nil {
return
}
c.mu.Lock()
c.events = append(c.events, e)
c.mu.Unlock()
}
func (c *streamCollector) drain() []StreamEvent {
if c == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if len(c.events) == 0 {
return nil
}
out := make([]StreamEvent, len(c.events))
copy(out, c.events)
return out
}
// streamCollectorFromContext returns the per-turn stream collector if present.
func streamCollectorFromContext(ctx context.Context) *streamCollector {
c, _ := ctx.Value(streamCollectorKey{}).(*streamCollector)
return c
}
-86
View File
@@ -1,86 +0,0 @@
package kit
import (
"context"
"testing"
)
func TestHaltHolderFirstWins(t *testing.T) {
h := &haltHolder{}
if halted, _, _ := h.snapshot(); halted {
t.Fatal("new holder should not be halted")
}
h.set("finish", 42)
h.set("other", 99) // ignored — first halt wins
halted, name, val := h.snapshot()
if !halted {
t.Fatal("holder should be halted")
}
if name != "finish" {
t.Fatalf("toolName = %q, want finish", name)
}
if v, ok := val.(int); !ok || v != 42 {
t.Fatalf("value = %#v, want 42", val)
}
}
func TestRecordHalt(t *testing.T) {
holder := &haltHolder{}
ctx := context.WithValue(context.Background(), haltHolderKey{}, holder)
// Non-halting output records nothing.
recordHalt(ctx, "noop", ToolOutput{Content: "ok"})
if halted, _, _ := holder.snapshot(); halted {
t.Fatal("non-halting output should not halt")
}
recordHalt(ctx, "finish", ToolOutput{Halt: true, FinalValue: "done"})
halted, name, val := holder.snapshot()
if !halted || name != "finish" || val != "done" {
t.Fatalf("halt not recorded: halted=%v name=%q val=%v", halted, name, val)
}
// Missing holder in context is a safe no-op.
recordHalt(context.Background(), "finish", ToolOutput{Halt: true})
}
func TestStreamCollector(t *testing.T) {
c := &streamCollector{}
if c.drain() != nil {
t.Fatal("empty collector should drain to nil")
}
c.add(StreamEvent{Kind: StreamEventTextDelta, Text: "A"})
c.add(StreamEvent{Kind: StreamEventTextDelta, Text: "B"})
c.add(StreamEvent{Kind: StreamEventToolCallChunk, ToolName: "x"})
out := c.drain()
if len(out) != 3 {
t.Fatalf("len = %d, want 3", len(out))
}
if out[0].Text != "A" || out[1].Text != "B" {
t.Fatalf("order not preserved: %#v", out)
}
if out[2].Kind != StreamEventToolCallChunk || out[2].ToolName != "x" {
t.Fatalf("tool chunk wrong: %#v", out[2])
}
}
// nil receiver collector (no per-turn collector attached) must be safe.
func TestStreamCollectorNil(t *testing.T) {
var c *streamCollector
c.add(StreamEvent{Kind: StreamEventTextDelta, Text: "x"}) // no panic
if c.drain() != nil {
t.Fatal("nil collector should drain to nil")
}
}
func TestStreamCollectorFromContext(t *testing.T) {
if streamCollectorFromContext(context.Background()) != nil {
t.Fatal("expected nil collector for bare context")
}
c := &streamCollector{}
ctx := context.WithValue(context.Background(), streamCollectorKey{}, c)
if streamCollectorFromContext(ctx) != c {
t.Fatal("collector not retrieved from context")
}
}
+108 -4
View File
@@ -5,11 +5,13 @@ import (
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/agent"
"github.com/mark3labs/kit/internal/compaction"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/kit/internal/message"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/session"
"github.com/mark3labs/kit/internal/tools"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/server"
)
@@ -81,10 +83,9 @@ type MCPServerConfig = config.MCPServerConfig
// concurrent use.
//
// Most consumers do not need to provide one; pass [Options.Debug] = true
// (or use [WithDebug]) to install the built-in console logger. DebugLogger
// is the escape hatch for embedders that want to route debug output into
// their own logging system — install one via [Options.DebugLogger] or
// [WithDebugLogger].
// to use the default logger. DebugLogger is exposed for the low-level
// [AgentConfig] path and for embedders that want to route debug output
// into their own logging system.
type DebugLogger interface {
// LogDebug records a single debug message. Implementations may drop,
// buffer, or render the message however they choose.
@@ -94,6 +95,109 @@ type DebugLogger interface {
IsDebugEnabled() bool
}
// AgentConfig holds configuration options for constructing an agent at the
// SDK boundary. All fields use SDK-owned types, so consumers can populate
// this struct without importing any underlying LLM-provider package.
//
// For most use cases, prefer the high-level [New] entry point with
// [Options]. AgentConfig is exposed for advanced consumers that need
// direct access to the lower-level agent configuration shape.
type AgentConfig struct {
// ModelConfig holds the LLM provider configuration. A nil value means
// that the default provider/model resolution will be used.
ModelConfig *ProviderConfig
// MCPConfig describes any MCP servers whose tools should be loaded
// alongside core tools.
MCPConfig *Config
// SystemPrompt is the system prompt sent to the LLM.
SystemPrompt string
// MaxSteps caps the number of LLM iterations per turn. A value of
// zero means no cap is applied at this layer.
MaxSteps int
// StreamingEnabled controls whether the agent streams responses.
StreamingEnabled bool
// AuthHandler handles OAuth authorization for remote MCP servers.
// When nil, remote MCP servers requiring OAuth will fail to connect.
AuthHandler MCPAuthHandler
// TokenStoreFactory, if non-nil, creates a custom token store for each
// remote MCP server's OAuth tokens. When nil, the default file-based
// token store is used.
TokenStoreFactory MCPTokenStoreFactory
// CoreTools overrides the default core tool set. If empty, [AllTools]
// is used. Provide a custom tool set (e.g. [CodingTools] or tools
// built with a custom WorkDir) to scope agent capabilities.
CoreTools []Tool
// DisableCoreTools, when true, prevents loading any core tools.
// Combined with empty CoreTools this yields a chat-only agent with
// no built-in tools.
DisableCoreTools bool
// ExtraTools are additional tools loaded alongside core and MCP tools.
ExtraTools []Tool
// ToolWrapper, if non-nil, wraps the combined tool list before it is
// handed to the LLM. Used to intercept tool calls or results.
ToolWrapper func([]Tool) []Tool
// OnMCPServerLoaded, if non-nil, is invoked once for each MCP server
// when its tools have finished loading (or failed). Called from a
// background goroutine.
OnMCPServerLoaded func(serverName string, toolCount int, err error)
// DebugLogger receives low-level debug output from the engine and the
// MCP tool plumbing. Nil means no debug output is emitted at this
// layer (regardless of [Options.Debug], which feeds the higher-level
// [New] entry point). Pass an implementation here when wiring a custom
// logger through the lower-level AgentConfig path.
DebugLogger DebugLogger
// MCPTaskConfig configures task-aware MCP tools/call execution — mode
// overrides, polling intervals, timeouts, and the progress handler.
// The zero value preserves historical synchronous-only behaviour for
// any server that didn't advertise task support during initialize.
MCPTaskConfig MCPTaskConfig
}
// toInternal converts an AgentConfig to its internal representation.
// Slice and function fields convert without allocation because [Tool]
// is a type alias for the underlying LLM-tool type.
func (c *AgentConfig) toInternal() *agent.AgentConfig {
if c == nil {
return nil
}
out := &agent.AgentConfig{
ModelConfig: c.ModelConfig,
MCPConfig: c.MCPConfig,
SystemPrompt: c.SystemPrompt,
MaxSteps: c.MaxSteps,
StreamingEnabled: c.StreamingEnabled,
CoreTools: c.CoreTools,
DisableCoreTools: c.DisableCoreTools,
ExtraTools: c.ExtraTools,
ToolWrapper: c.ToolWrapper,
OnMCPServerLoaded: c.OnMCPServerLoaded,
}
if c.AuthHandler != nil {
out.AuthHandler = c.AuthHandler
}
if c.TokenStoreFactory != nil {
out.TokenStoreFactory = tools.TokenStoreFactory(c.TokenStoreFactory)
}
if c.DebugLogger != nil {
out.DebugLogger = c.DebugLogger
}
out.MCPTaskConfig = c.MCPTaskConfig.toToolsConfig()
return out
}
// ToolCallHandler is invoked when the LLM produces a tool call. It receives
// the call ID, tool name, and the JSON-encoded input arguments.
type ToolCallHandler func(toolCallID, toolName, toolArgs string)
+41 -35
View File
@@ -264,31 +264,30 @@ func TestConvertFromLLMMessage(t *testing.T) {
}
}
// TestOptionsNoFantasyImport verifies Options can be populated with the
// tool-related fields — Tools and ExtraTools — using only SDK-owned types.
// This test deliberately does not import "charm.land/fantasy"; the package
// compiling at all is the proof that the SDK no longer leaks the dependency
// name through the Options surface.
//
// Tool-call interception (formerly the AgentConfig.ToolWrapper escape hatch)
// is covered by the hook system — [Kit.OnBeforeToolCall] /
// [Kit.OnAfterToolResult] — whose hook payload types also use only
// SDK-owned identifiers; see hooks_test.go.
// TestAgentConfigNoFantasyImport verifies AgentConfig can be populated with
// every field — including CoreTools, ExtraTools, and ToolWrapper — using
// only SDK-owned types. This test deliberately does not import
// "charm.land/fantasy"; the package compiling at all is the proof that the
// SDK no longer leaks the dependency name through AgentConfig.
//
// Regression test for https://github.com/mark3labs/kit/issues/30.
func TestOptionsNoFantasyImport(t *testing.T) {
func TestAgentConfigNoFantasyImport(t *testing.T) {
myTool := kit.NewTool[struct{}]("noop", "does nothing", func(_ context.Context, _ struct{}) (kit.ToolOutput, error) {
return kit.TextResult("ok"), nil
})
streaming := true
cfg := kit.Options{
SystemPrompt: "you are a tester",
MaxSteps: 5,
Streaming: &streaming,
Tools: []kit.Tool{myTool},
ExtraTools: []kit.Tool{myTool},
DisableCoreTools: false,
wrapperCalled := false
cfg := kit.AgentConfig{
SystemPrompt: "you are a tester",
MaxSteps: 5,
StreamingEnabled: true,
CoreTools: []kit.Tool{myTool},
ExtraTools: []kit.Tool{myTool},
DisableCoreTools: false,
ToolWrapper: func(in []kit.Tool) []kit.Tool {
wrapperCalled = true
return in
},
OnMCPServerLoaded: func(_ string, _ int, _ error) {},
}
@@ -298,29 +297,36 @@ func TestOptionsNoFantasyImport(t *testing.T) {
if cfg.MaxSteps != 5 {
t.Errorf("MaxSteps = %d, want 5", cfg.MaxSteps)
}
if cfg.Streaming == nil || !*cfg.Streaming {
t.Error("Streaming = false/nil, want true")
if !cfg.StreamingEnabled {
t.Error("StreamingEnabled = false, want true")
}
if len(cfg.Tools) != 1 {
t.Errorf("Tools len = %d, want 1", len(cfg.Tools))
if len(cfg.CoreTools) != 1 {
t.Errorf("CoreTools len = %d, want 1", len(cfg.CoreTools))
}
if len(cfg.ExtraTools) != 1 {
t.Errorf("ExtraTools len = %d, want 1", len(cfg.ExtraTools))
}
// Exercise the wrapper to confirm the func type is usable.
out := cfg.ToolWrapper(cfg.CoreTools)
if !wrapperCalled {
t.Error("ToolWrapper was not invoked")
}
if len(out) != 1 {
t.Errorf("wrapped tool list len = %d, want 1", len(out))
}
}
// TestToolSliceSignature documents that the kit.Tool alias — used by every
// SDK tool-related surface (Options.Tools, Options.ExtraTools, WithTools,
// WithExtraTools, hook payloads) — is referenced under its SDK-owned name
// in user code, without any fantasy import.
func TestToolSliceSignature(t *testing.T) {
var tools []kit.Tool
tools = append(tools, kit.NewTool[struct{}]("noop", "",
func(_ context.Context, _ struct{}) (kit.ToolOutput, error) {
return kit.TextResult("ok"), nil
}))
if len(tools) != 1 {
t.Fatalf("unexpected tool slice length: %d", len(tools))
// TestAgentConfigToolWrapperSignature documents that AgentConfig.ToolWrapper
// uses kit.Tool (not the underlying provider type) in its signature.
func TestAgentConfigToolWrapperSignature(t *testing.T) {
//nolint:staticcheck // QF1011: explicit type asserts the SDK-side func signature.
var _ func([]kit.Tool) []kit.Tool = func(in []kit.Tool) []kit.Tool { return in }
cfg := kit.AgentConfig{
ToolWrapper: func(in []kit.Tool) []kit.Tool { return in },
}
if cfg.ToolWrapper == nil {
t.Fatal("ToolWrapper assignment failed")
}
}
-171
View File
@@ -63,52 +63,6 @@ func TestOptionFunctionsPlumbing(t *testing.T) {
}
}
// recordingDebugLogger is a kit.DebugLogger used to verify WithDebugLogger
// plumbs the supplied logger into Options. It records each LogDebug call.
type recordingDebugLogger struct {
enabled bool
messages []string
}
func (l *recordingDebugLogger) LogDebug(m string) { l.messages = append(l.messages, m) }
func (l *recordingDebugLogger) IsDebugEnabled() bool { return l.enabled }
// TestWithDebugLoggerPlumbing verifies that kit.WithDebugLogger assigns the
// supplied logger to Options.DebugLogger. End-to-end propagation into the
// engine is covered indirectly by the existing kitsetup tests; this test
// pins the SDK-surface contract.
func TestWithDebugLoggerPlumbing(t *testing.T) {
l := &recordingDebugLogger{enabled: true}
o := &kit.Options{}
kit.WithDebugLogger(l)(o)
if o.DebugLogger == nil {
t.Fatal("WithDebugLogger: expected Options.DebugLogger to be set")
}
if o.DebugLogger != l {
t.Error("WithDebugLogger: expected the supplied logger to be installed verbatim")
}
// Sanity: the installed logger satisfies the SDK interface contract.
if !o.DebugLogger.IsDebugEnabled() {
t.Error("installed logger IsDebugEnabled() returned false")
}
o.DebugLogger.LogDebug("hello")
if len(l.messages) != 1 || l.messages[0] != "hello" {
t.Errorf("LogDebug not forwarded; got %v", l.messages)
}
}
// TestWithDebugLoggerNilClears verifies that passing a nil logger to
// WithDebugLogger clears any previously-installed logger. This lets later
// options override earlier ones the same way WithModel / WithStreaming do.
func TestWithDebugLoggerNilClears(t *testing.T) {
o := &kit.Options{}
kit.WithDebugLogger(&recordingDebugLogger{enabled: true})(o)
kit.WithDebugLogger(nil)(o)
if o.DebugLogger != nil {
t.Errorf("WithDebugLogger(nil): expected DebugLogger to be cleared; got %#v", o.DebugLogger)
}
}
// TestOptionOrderingOverrides verifies later options override earlier ones.
func TestOptionOrderingOverrides(t *testing.T) {
o := &kit.Options{}
@@ -251,131 +205,6 @@ func TestNewZeroOptionsKeepsStreamingDefault(t *testing.T) {
}
}
// TestSkillsViperKeys verifies that the three skills config keys (no-skills,
// skill, skills-dir) flow through viper when set via a config file, matching
// the pattern used by no-extensions and no-core-tools. This test does not
// require an API key because it only exercises Options struct plumbing.
func TestSkillsViperKeys(t *testing.T) {
t.Run("NoSkills option disables skill loading", func(t *testing.T) {
o := &kit.Options{}
o.NoSkills = true
if !o.NoSkills {
t.Error("Options.NoSkills = true not reflected on struct")
}
})
t.Run("Skills paths set on Options", func(t *testing.T) {
o := &kit.Options{
Skills: []string{"/a/skill.md", "/b/skill.md"},
}
if len(o.Skills) != 2 {
t.Errorf("Options.Skills: got %d paths, want 2", len(o.Skills))
}
if o.Skills[0] != "/a/skill.md" {
t.Errorf("Options.Skills[0] = %q; want %q", o.Skills[0], "/a/skill.md")
}
})
t.Run("SkillsDir set on Options", func(t *testing.T) {
o := &kit.Options{
SkillsDir: "/custom/skills",
}
if o.SkillsDir != "/custom/skills" {
t.Errorf("Options.SkillsDir = %q; want %q", o.SkillsDir, "/custom/skills")
}
})
}
// TestSkillsConfigFileKeys verifies that no-skills, skill, and skills-dir
// config file keys are read via viper and applied correctly. Requires an API
// key because kit.New() is called to exercise the full config-load path.
func TestSkillsConfigFileKeys(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
ctx := context.Background()
t.Run("no-skills config key disables skill loading", func(t *testing.T) {
// Write a config file with no-skills: true.
cfgFile := t.TempDir() + "/.kit.yml"
if err := os.WriteFile(cfgFile, []byte("no-skills: true\n"), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
ConfigFile: cfgFile,
})
if err != nil {
t.Fatalf("kit.New failed: %v", err)
}
defer func() { _ = host.Close() }()
if got := host.GetSkills(); len(got) != 0 {
t.Errorf("no-skills:true in config: expected 0 skills, got %d", len(got))
}
})
t.Run("skill config key loads explicit skill files", func(t *testing.T) {
dir := t.TempDir()
skillFile := dir + "/cfg-skill.md"
if err := os.WriteFile(skillFile, []byte("---\nname: cfg-skill\ndescription: from config\n---\nContent.\n"), 0o644); err != nil {
t.Fatalf("failed to write skill file: %v", err)
}
cfgContent := "skill:\n - " + skillFile + "\n"
cfgFile := dir + "/.kit.yml"
if err := os.WriteFile(cfgFile, []byte(cfgContent), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
ConfigFile: cfgFile,
})
if err != nil {
t.Fatalf("kit.New failed: %v", err)
}
defer func() { _ = host.Close() }()
skills := host.GetSkills()
if len(skills) != 1 {
t.Fatalf("expected 1 skill from config, got %d", len(skills))
}
if skills[0].Name != "cfg-skill" {
t.Errorf("skill name = %q; want %q", skills[0].Name, "cfg-skill")
}
})
t.Run("skills-dir config key overrides auto-discovery root", func(t *testing.T) {
dir := t.TempDir()
cfgContent := "skills-dir: " + dir + "\n"
cfgFile := dir + "/.kit.yml"
if err := os.WriteFile(cfgFile, []byte(cfgContent), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
ConfigFile: cfgFile,
})
if err != nil {
t.Fatalf("kit.New failed: %v", err)
}
defer func() { _ = host.Close() }()
// Empty dir → 0 skills; the key point is no error during init.
_ = host.GetSkills()
})
}
// TestNewStreamingExplicitOptOut verifies that a raw Options can still disable
// streaming by setting Streaming to a pointer to false.
func TestNewStreamingExplicitOptOut(t *testing.T) {
-18
View File
@@ -93,21 +93,3 @@ result, err := host.PromptResult(ctx, "Count files")
fmt.Println(result.Response)
fmt.Println(result.Usage.TotalTokens)
```
`PromptResult` blocks until end-of-turn regardless of streaming mode. When
streaming is enabled, every delta observed during the turn is also captured in
order in `result.Stream` (`[]kit.StreamEvent`), so you can assert streamed
ordering deterministically without wiring an `OnMessageUpdate` collector:
```go
for _, ev := range result.Stream {
switch ev.Kind {
case kit.StreamEventTextDelta:
fmt.Print(ev.Text)
case kit.StreamEventReasoningDelta:
fmt.Print(ev.Reasoning)
case kit.StreamEventToolCallChunk:
fmt.Printf("[%s %s]", ev.ToolName, ev.Args)
}
}
```
-98
View File
@@ -56,104 +56,6 @@ kit install --all # Install all extensions without prompting
kit skill # Install the Kit extensions skill via skills.sh
```
### Skills CLI flags
Control which skills are loaded at startup:
```bash
# Load a specific skill file
kit --skill path/to/skill.md "prompt"
# Load multiple skill files or directories (flag is repeatable)
kit --skill ./skill1.md --skill ./skill2.md "prompt"
# Scan a directory directly for skills (overrides auto-discovery)
kit --skills-dir /path/to/skills "prompt"
# Hide a skill from the model catalog by name (still usable via /skill:)
kit --skill-disable noisy-skill "prompt"
# Disable all skill loading (auto-discovery and explicit)
kit --no-skills "prompt"
```
Skills follow the [agentskills.io](https://agentskills.io/specification) convention. They are auto-discovered from four canonical scopes:
| Scope | Location |
|-------|----------|
| User-level (cross-client) | `~/.agents/skills/` |
| User-level (Kit) | `~/.config/kit/skills/` (honors `$XDG_CONFIG_HOME`) |
| Project-local (cross-client) | `<project>/.agents/skills/` |
| Project-local (Kit) | `<project>/.kit/skills/` |
When two skills share the same `name`, the project-level one takes precedence over the user-level one. Use `--skills-dir` to scan one directory directly instead (it is **not** treated as a parent of `.agents`/`.kit` — the directory itself is scanned). `--skill` loads files explicitly (which disables auto-discovery), and `--no-skills` suppresses all skill loading regardless of other flags.
Disabled skills (`--skill-disable`, the `skill-disable` config key, or `disable-model-invocation: true` in a skill's frontmatter) are hidden from the model-facing `<available_skills>` catalog but remain available for explicit activation via the `/skill:<name>` command.
### Skill frontmatter
A skill is a markdown file (`SKILL.md` in a directory, or a standalone `.md`/`.txt` file) with optional YAML frontmatter. Kit reads the full [agentskills.io](https://agentskills.io/specification) field set plus two Kit-specific extensions:
```yaml
---
name: pdf-extractor # required
description: Use when extracting tables from PDFs # required (drives model discovery)
license: MIT # optional, SPDX identifier
compatibility: claude-code, cursor # optional, targeted environments
allowed-tools: read, bash # optional (experimental) tool restriction
disable-model-invocation: false # optional; true hides from the catalog
metadata: # optional arbitrary key/value pairs
author: you
tags: [pdf, data] # Kit extension
when: on-demand # Kit extension
---
```
`name` and `description` are required — a skill missing its description is skipped with a logged warning, since the description is the sole basis on which the model decides relevance. Descriptions are XML-escaped before they enter the catalog, so characters like `<`, `>`, and `&` are safe. A skill directory may bundle `scripts/`, `references/`, and `assets/` subdirectories; when a skill is activated those files are enumerated in a `<skill_resources>` block so the model knows what it can read.
### Project trust prompt
Because project-local skills are injected into the system prompt, entering a repository that ships `.agents/skills/` or `.kit/skills/` for the first time prompts you to trust it before any project skill loads — a safeguard against a freshly cloned, untrusted repo smuggling instructions into the agent:
```
This project provides 2 skills under .agents/skills or .kit/skills:
/path/to/repo
Load them into the agent? [t]rust always / [o]nce / [s]kip (default skip):
```
Choosing **trust always** persists the directory to `~/.config/kit/trusted-projects.json` so you are not asked again. The prompt is skipped (skills load silently) in non-interactive runs — when a prompt is passed positionally, `--quiet` is set, or stdin is not a TTY.
## GitHub integration
Scaffold a GitHub Actions workflow that runs Kit as an automated collaborator/reviewer. The workflow triggers when someone comments `/kit ...` on an issue or pull request review, runs the agent non-interactively in the runner, and lets it respond.
```bash
kit github install # Scaffold .github/workflows/kit.yml
kit github install --model anthropic/claude-sonnet-4-5-20250929 # Skip the model prompt
kit github install --force # Overwrite an existing workflow file
kit github install --no-secret # Skip the offer to set the provider secret via the gh CLI
```
By default the command prompts for the model (pre-filled with a sensible default). If the [`gh` CLI](https://cli.github.com/) is detected on your `PATH` and the provider API key is present in your environment, you'll be offered the option to store it as a repository secret automatically.
The generated workflow:
- Triggers only on `issue_comment` and `pull_request_review_comment` (`types: [created]`).
- Runs only when the comment begins with the `/kit` command token.
- Restricts triggers to repository owners, members, and collaborators (via `author_association`).
- Uses least-privilege `permissions` and `persist-credentials: false`.
- Authenticates git/PR operations with the built-in `secrets.GITHUB_TOKEN` and the provider via a repository secret (e.g. `ANTHROPIC_API_KEY`).
After committing the workflow and setting the provider secret, comment `/kit <your request>` on any issue or pull request to trigger Kit.
The generated workflow uses the bundled [`mark3labs/kit`](https://github.com/mark3labs/kit/blob/master/action.yml) composite action, which installs the Kit binary and runs `kit github run`. That command reads the triggering event, enforces permissions, reacts with an emoji, runs the agent against the issue thread or PR, posts the response as a comment, and — if the agent changed files — pushes a `kit-agent[bot]` branch and opens a pull request.
| Flag | Description |
|------|-------------|
| `--model` | Provider/model to write into the workflow |
| `--force` | Overwrite an existing workflow file |
| `--no-secret` | Skip the offer to set the provider secret via the `gh` CLI |
## Interactive slash commands
These commands are available inside the Kit TUI during an interactive session:
-9
View File
@@ -48,15 +48,6 @@ These flags control Kit's behavior. When a prompt is passed as a positional argu
| `--prompt-template` | — | — | Load a specific prompt template by name |
| `--no-prompt-templates` | — | `false` | Disable prompt template loading |
## Skills
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--skill` | — | — | Load skill file or directory (repeatable) |
| `--skills-dir` | — | — | Scan this directory directly for skills (overrides auto-discovery) |
| `--skill-disable` | — | — | Hide a skill from the model catalog by name (repeatable); still usable via `/skill:` |
| `--no-skills` | — | `false` | Disable skill loading (auto-discovery and explicit) |
## Generation parameters
| Flag | Short | Default | Description |
-6
View File
@@ -47,10 +47,6 @@ stream: true
| `theme` | object or string | — | UI theme ([inline overrides or file path](/themes)) |
| `prompt-templates` | bool | `true` | Enable prompt template loading |
| `prompt-template` | string | — | Specific template to load by name |
| `no-skills` | bool | `false` | Disable skill loading (auto-discovery and explicit) |
| `skill` | list | — | Explicit skill files or directories to load (disables auto-discovery) |
| `skills-dir` | string | — | Scan this directory directly for skills (overrides auto-discovery; not treated as a parent of `.agents`/`.kit`) |
| `skill-disable` | list | — | Skill names to hide from the model catalog (still usable via `/skill:`) |
## Environment variables
@@ -152,7 +148,6 @@ customModels:
name: "My Custom Model"
baseUrl: "http://localhost:8080/v1"
apiKey: "my-secret-key"
apiModelName: "gpt-4-turbo"
reasoning: true
temperature: true
cost:
@@ -170,7 +165,6 @@ customModels:
| `name` | string | Yes | Display name for the model |
| `baseUrl` | string | No | Per-model base URL override; when set, `--provider-url` is not required |
| `apiKey` | string | No | Per-model API key override |
| `apiModelName` | string | No | Overrides the model identifier sent in API requests; defaults to the config key |
| `reasoning` | bool | No | Whether the model supports reasoning/thinking |
| `temperature` | bool | No | Whether the model supports temperature adjustment |
| `cost.input` | float | No | Cost per 1K input tokens |
+2 -5
View File
@@ -447,14 +447,11 @@ Load and inject skills dynamically at runtime:
```go
// Discover skills from standard locations
result := ctx.DiscoverSkills() // ext.SkillLoadResult{Skills, Error}
// Standard locations: ~/.agents/skills/, ~/.config/kit/skills/,
// <project>/.agents/skills/, <project>/.kit/skills/
// Standard locations: ~/.config/kit/skills/, .kit/skills/, .agents/skills/
// Load a specific skill file
skill, err := ctx.LoadSkill("/path/to/skill.md") // (*ext.Skill, error string)
// Spec fields: skill.Name, skill.Description, skill.License, skill.Compatibility,
// skill.Metadata, skill.AllowedTools, skill.DisableModelInvocation
// Plus content/path and Kit extensions: skill.Content, skill.Path, skill.Tags, skill.When
// skill.Name, skill.Description, skill.Content, skill.Tags, skill.When
// Load all skills from a directory
result := ctx.LoadSkillsFromDir("/path/to/skills") // ext.SkillLoadResult
-31
View File
@@ -117,34 +117,3 @@ func Init(api ext.API) {
})
}
```
### Standard library access
Extensions can import the full Go standard library, plus `os/exec` for spawning
subprocesses. Environment variables are also readable: `os.Getenv`,
`os.LookupEnv`, and `os.Environ` return Kit's process environment, so extensions
can pick up CI-provided variables (for example `GITHUB_EVENT_PATH` or a provider
API key) and any vars the user exported before launching Kit.
```go
package main
import (
"os"
"kit/ext"
)
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
if eventPath := os.Getenv("GITHUB_EVENT_PATH"); eventPath != "" {
ctx.PrintInfo("Running in GitHub Actions: " + eventPath)
}
})
}
```
Environment access is read-only from the host's perspective: the environment is
snapshotted when the extension loads, and calls to `os.Setenv` mutate only the
extension's sandboxed copy — they never change Kit's process environment or the
host. This keeps extensions from leaking state into Kit or other extensions
while still letting them read the configuration they need.
-19
View File
@@ -392,25 +392,6 @@ harness2.LoadFile("ext2.go")
// Events to one don't affect the other
```
### Testing extensions that read environment variables
The harness seeds the interpreter with the process environment, mirroring the
production loader, so an extension's `os.Getenv` / `os.LookupEnv` / `os.Environ`
calls work in tests. Set test-specific variables with `t.Setenv` **before**
loading the extension, since the environment is snapshotted at load time:
```go
func TestReadsEnv(t *testing.T) {
t.Setenv("MY_API_KEY", "test-value")
harness := test.New(t)
harness.LoadFile("my-ext.go") // snapshots the env, incl. MY_API_KEY
harness.Emit(extensions.SessionStartEvent{SessionID: "s1"})
// assert on behavior that depends on MY_API_KEY
}
```
### Running Tests
Run all tests in your extension directory:
-1
View File
@@ -20,7 +20,6 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
- **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
- **GitHub Integration** — Scaffold a GitHub Actions workflow with `kit github install` to run Kit as a collaborator/reviewer on `/kit` comments
- **ACP Server** — Run Kit as an [Agent Client Protocol](https://agentclientprotocol.com) agent over stdio
- **Go SDK** — Embed Kit in your own applications
+1 -19
View File
@@ -176,30 +176,12 @@ Lower values run first. First non-nil result wins.
| `SourceEvent` | `OnSource` | LLM referenced a source (e.g., web search) |
| `ErrorEvent` | `OnError` | Agent-level error during streaming |
| `RetryEvent` | `OnRetry` | LLM request retried after transient error |
| `CompactionEvent` | `OnCompaction` | Conversation compacted (fires on success **and** failure — check `Err`) |
| `CompactionEvent` | `OnCompaction` | Conversation compacted |
| `SteerConsumedEvent` | `OnSteerConsumed` | Steering messages injected into turn |
| `PasswordPromptEvent` | — | Sudo command needs password (respond via `ResponseCh`) |
> **Note:** `OnStreaming` is a deprecated alias for `OnMessageUpdate` and will be removed in a future release.
### Compaction telemetry
`CompactionEvent` fires after every compaction attempt. On success `Err` is
`nil` and the summary/token/file fields are populated; on failure `Err` is
non-nil and the rest are zero-valued. This lets you wire symmetric
start/end lifecycle telemetry without hand-rolling the failure path:
```go
host.OnCompaction(func(e kit.CompactionEvent) {
if e.Err != nil {
log.Printf("compaction failed: %v", e.Err)
return
}
log.Printf("compacted %d → %d tokens (%d messages removed)",
e.OriginalTokens, e.CompactedTokens, e.MessagesRemoved)
})
```
## Subagent event monitoring
Monitor real-time events from LLM-initiated subagents (when the model uses the `subagent` tool):
+6 -83
View File
@@ -31,7 +31,6 @@ host, err := kit.New(ctx, &kit.Options{
Streaming: ptrBool(true), // *bool: nil = unset (default true), &false = off
Quiet: true,
Debug: true,
DebugLogger: myLogger, // optional; overrides Debug + built-in logger when non-nil
// Generation parameters (override env/config/per-model defaults)
MaxTokens: 16384, // 0 = auto-resolve; non-zero suppresses right-sizing
@@ -65,10 +64,9 @@ host, err := kit.New(ctx, &kit.Options{
AutoCompact: true,
// Skills
Skills: []string{"/path/to/skill.md"},
SkillsDir: "/path/to/skills/",
SkillsDisable: []string{"noisy-skill"},
NoSkills: true,
Skills: []string{"/path/to/skill.md"},
SkillsDir: "/path/to/skills/",
NoSkills: true,
// Feature toggles
NoExtensions: true, // disable Yaegi extension loading
@@ -105,8 +103,7 @@ host, err := kit.New(ctx, &kit.Options{
| `MaxSteps` | `int` | `0` | Max agent steps (0 = unlimited) |
| `Streaming` | `*bool` | `nil` | Enable streaming output. `nil` leaves it to the precedence chain (env → config → default `true`); `&true`/`&false` forces it. Pointer so unset is distinct from explicit `false`. |
| `Quiet` | `bool` | `false` | Suppress output |
| `Debug` | `bool` | `false` | Enable debug logging via the built-in console / buffered logger. Ignored when `DebugLogger` is non-nil. |
| `DebugLogger` | `DebugLogger` | `nil` | Caller-supplied logger that receives low-level engine + MCP tool plumbing debug output. When non-nil this overrides `Debug` — the supplied logger's `IsDebugEnabled()` controls downstream emission. See [Custom debug logger](#custom-debug-logger). |
| `Debug` | `bool` | `false` | Enable debug logging |
### Generation parameters
@@ -171,48 +168,13 @@ when embedding Kit as a library.
|-------|------|---------|-------------|
| `SkipConfig` | `bool` | `false` | Skip `.kit.yml` file loading (viper defaults + env vars still apply) |
| `Skills` | `[]string` | — | Explicit skill files/dirs to load |
| `SkillsDir` | `string` | — | Scan this directory directly for skills (overrides auto-discovery; scanned as-is) |
| `SkillsDisable` | `[]string` | — | Skill names to hide from the model catalog (still usable via `/skill:`) |
| `SkillTrustPrompt` | `func(projectDir string, skillCount int) TrustDecision` | `nil` | Callback gating project-local skill loading on a trust decision (see below) |
| `SkillsDir` | `string` | — | Override default skills directory |
| `NoSkills` | `bool` | `false` | Disable skill loading entirely |
#### Project-skill trust gate
Project-local skills (under `<project>/.agents/skills/` or `<project>/.kit/skills/`)
are injected into the system prompt, so loading them from an untrusted, freshly
cloned repository is a prompt-injection vector. Set `SkillTrustPrompt` to gate
that first load on an explicit decision. When `nil` (the default), project
skills load without prompting — preserving historical behaviour.
```go
opts := &kit.Options{
SkillTrustPrompt: func(projectDir string, skillCount int) kit.TrustDecision {
// Consult your own UI / policy here.
if userApproves(projectDir, skillCount) {
return kit.TrustProject // load and persist the directory as trusted
}
return kit.SkipProjectSkills // do not load project skills
},
}
```
The callback returns one of three `TrustDecision` values:
| Decision | Effect |
|----------|--------|
| `kit.TrustProject` | Load project skills and persist `projectDir` to `~/.config/kit/trusted-projects.json` (not prompted again) |
| `kit.TrustProjectOnce` | Load project skills for this run only, without persisting |
| `kit.SkipProjectSkills` | Do not load project skills |
A directory already on the persisted allowlist is trusted without invoking the
callback. The Kit CLI wires this to an interactive terminal prompt automatically
for TTY sessions.
These fields only control the **initial** skill and context-file set picked
up by `New()`. To add, remove, or replace skills and `AGENTS.md`-style
context files at runtime (e.g. per user or per session), use the
`AddSkill` / `LoadAndAddSkill` / `RemoveSkill` / `SetSkills` /
`DisableSkill` / `EnableSkill` and
`AddSkill` / `LoadAndAddSkill` / `RemoveSkill` / `SetSkills` and
`AddContextFile` / `AddContextFileContent` / `RemoveContextFile` /
`SetContextFiles` methods on `*kit.Kit`. See
[Runtime skills and context files](/sdk/overview#runtime-skills-and-context-files).
@@ -384,45 +346,6 @@ loaded MCP server that advertises the corresponding capability.
Context cancellation also works end-to-end: cancelling the `ctx` passed to a
tool execution triggers a best-effort `tasks/cancel` before the call returns.
## Custom debug logger
Kit's engine and MCP tool plumbing emit low-level debug output through a
`DebugLogger` interface. By default, setting `Debug: true` (or calling
`WithDebug()`) installs the built-in console logger. To route the same output
into your application's logging system instead, provide a custom
implementation via `Options.DebugLogger` or `WithDebugLogger`.
```go
type DebugLogger interface {
LogDebug(message string)
IsDebugEnabled() bool
}
```
When `DebugLogger` is non-nil it takes precedence over `Debug` — the
supplied logger's `IsDebugEnabled()` reports whether downstream code should
bother formatting messages.
**Example: forward to `log/slog`:**
```go
import "log/slog"
type slogDebugLogger struct{ l *slog.Logger }
func (s *slogDebugLogger) LogDebug(m string) { s.l.Debug(m) }
func (s *slogDebugLogger) IsDebugEnabled() bool { return true }
host, _ := kit.NewAgent(ctx,
kit.WithModel("anthropic/claude-sonnet-4-5-20250929"),
kit.WithDebugLogger(&slogDebugLogger{l: slog.Default()}),
)
```
Implementations must be safe for concurrent use — messages can arrive
from the engine goroutine, MCP connection pool, and tool execution paths
simultaneously.
## Precedence
For any given generation or provider field, the effective value is resolved
+3 -166
View File
@@ -80,7 +80,6 @@ Available options:
| `WithProviderURL(string)` | `Options.ProviderURL` |
| `WithConfigFile(string)` | `Options.ConfigFile` |
| `WithDebug()` | `Options.Debug = true` |
| `WithDebugLogger(DebugLogger)` | `Options.DebugLogger` (route engine + MCP debug output into a custom logger; overrides `WithDebug` when set) |
| `Ephemeral()` | `Options.NoSession = true` |
Options are applied in order, so later options override earlier ones. `Option`
@@ -130,36 +129,12 @@ The SDK provides several prompt variants:
| Method | Description |
|--------|-------------|
| `Prompt(ctx, message)` | Simple prompt, returns response string |
| `PromptWithOptions(ctx, message, opts)` | With per-call options (model, tools, thinking level, provider creds) |
| `PromptWithOptions(ctx, message, opts)` | With per-call options |
| `PromptResult(ctx, message)` | Returns full `TurnResult` with usage stats |
| `PromptResultWithOptions(ctx, message, opts)` | Per-call options variant that returns the full `TurnResult` |
| `PromptResultWithFiles(ctx, message, files)` | Multimodal with file attachments |
| `Steer(ctx, instruction)` | System-level steering without user message |
| `FollowUp(ctx, text)` | Continue without new user input |
### Per-call overrides
`PromptOptions` scopes configuration to a **single call** and restores the
agent's prior state afterwards — no need to rebuild a `*Kit` per request. This
suits multi-tenant hosts that resolve the model, credentials, or tool set per
request:
```go
result, err := host.PromptResultWithOptions(ctx, "Summarise this ticket", kit.PromptOptions{
SystemMessage: "You are a concise triage assistant.", // prepended for this call
Model: "anthropic/claude-haiku-3-5-20241022", // overrides the default model
ThinkingLevel: "low", // "off" | "low" | "medium" | "high"
ExtraTools: []kit.Tool{lookupTool}, // added on top of the core set
ProviderURL: "https://proxy.tenant-a/v1", // per-tenant endpoint
ProviderAPIKey: tenantKey, // per-tenant credential
})
```
Every field is optional; a zero value means "use the agent's default." The
prior model, thinking level, provider credentials, and tool set are all
restored before the call returns, and concurrent option-driven prompts are
serialized so the apply/restore window of one call never races another.
## Custom tools
Create custom tools with `kit.NewTool`. The JSON schema is auto-generated from the input struct — no external dependencies required:
@@ -200,64 +175,6 @@ Binary data (images, audio, etc.) in `ToolOutput.Data` is automatically forwarde
Use `kit.NewParallelTool` for tools that are safe to run concurrently. Use `kit.ToolCallIDFromContext(ctx)` to retrieve the LLM-assigned call ID for logging or tracing.
### Schema-driven tools
When the tool's input shape isn't known at compile time — tools sourced from
JSON Schema definitions in skill files, MCP server catalogs, or user-supplied
definitions — use `kit.NewRawTool`. It takes a JSON Schema and a handler that
receives the decoded arguments as a `map[string]any`, so no Go input type is
required:
```go
schema := map[string]any{
"type": "object",
"properties": map[string]any{
"city": map[string]any{"type": "string", "description": "City name"},
},
"required": []any{"city"},
}
weatherTool := kit.NewRawTool("get_weather", "Get current weather for a city", schema,
func(ctx context.Context, args map[string]any) (kit.ToolOutput, error) {
return kit.TextResult("72°F, sunny in " + args["city"].(string)), nil
},
)
```
The `schema` is advertised to the model as the tool's parameter schema. If the
model sends arguments that aren't a valid JSON object, the call short-circuits
with an error result before your handler runs.
### Halting the agent loop
For structured-result patterns — the model calls a `finish(...)` tool with a
typed argument and the loop should terminate, returning that value to the
caller — set `Halt` and `FinalValue` on the returned `ToolOutput` instead of
smuggling the value out through a side-channel:
```go
finishTool := kit.NewTool("finish", "Return the final structured answer",
func(ctx context.Context, input AnswerInput) (kit.ToolOutput, error) {
return kit.ToolOutput{
Content: "done",
Halt: true, // terminate the agent loop after this call
FinalValue: input, // surfaced to the caller
}, nil
},
)
result, _ := host.PromptResult(ctx, "Extract the order details")
if result.HaltedByTool == "finish" {
answer := result.FinalValue.(AnswerInput) // the typed value your handler stored
_ = answer
}
```
`TurnResult.HaltedByTool` names the tool that halted the turn (empty if the
turn ended for any other reason), and `TurnResult.FinalValue` carries whatever
your handler placed in `ToolOutput.FinalValue`. `Halt`/`FinalValue` work with
`NewTool`, `NewParallelTool`, and `NewRawTool` alike.
## Generation & provider overrides
SDK consumers can configure generation parameters and provider endpoints
@@ -382,11 +299,6 @@ host.LoadAndAddContextFile("/etc/agents/tenant-acme.md")
host.RemoveSkill("polite-french")
host.RemoveContextFile(fmt.Sprintf("session://%s/AGENTS.md", userID))
// Hide a skill from the model-facing catalog without unloading it — it stays
// available for explicit /skill: activation. EnableSkill reverses this.
host.DisableSkill("refund-policy")
host.EnableSkill("refund-policy")
// Or replace the whole set in one call.
host.SetSkills(activeSkillsForUser)
host.SetContextFiles(activeContextForUser)
@@ -412,35 +324,9 @@ Key points:
from multiple goroutines; the underlying state is guarded by an internal
`RWMutex`.
- **Init-time options still apply.** `Options.Skills`, `Options.SkillsDir`,
`Options.SkillsDisable`, `Options.SkillTrustPrompt`, `Options.NoSkills`, and
`Options.NoContextFiles` continue to control the startup set; the runtime API
mutates from whatever state `New()` produced.
`Options.NoSkills`, and `Options.NoContextFiles` continue to control the
startup set; the runtime API mutates from whatever state `New()` produced.
See [SDK options](/sdk/options#skills--configuration).
- **Auto-discovery scopes.** When no explicit `Skills`/`SkillsDir` are given,
`New()` scans four [agentskills.io](https://agentskills.io/specification)
locations: `~/.agents/skills/`, `~/.config/kit/skills/`,
`<project>/.agents/skills/`, and `<project>/.kit/skills/`. Project-level
skills override user-level skills of the same `name`. Skills missing a
`description` are skipped with a logged warning, and a skill's
`disable-model-invocation: true` (or `Options.SkillsDisable`) hides it from
the catalog while keeping it available for explicit activation.
- **Skill helpers.** A `kit.Skill` exposes `BaseDir()` (its directory) and
`Resources()` (the files bundled under `scripts/`, `references/`, and
`assets/`), which power the `<skill_resources>` enumeration shown when a skill
is activated.
- **`fs.FS`-backed discovery.** The package-level loaders `kit.LoadSkill`,
`kit.LoadSkillsFromDir`, and `kit.LoadSkills` are path-string based;
`kit.LoadSkillsFromFS(fsys, root)` is the `fs.FS`-typed counterpart for
`embed.FS` distribution, `fstest.MapFS` tests, or per-tenant virtual
filesystems. Feed the result into `host.SetSkills(...)`:
```go
//go:embed skills
var skillsFS embed.FS
loaded, _ := kit.LoadSkillsFromFS(skillsFS, "skills")
host.SetSkills(loaded)
```
## MCP prompts and resources
@@ -496,55 +382,6 @@ if host.ShouldCompact() {
}
```
## Provider error classification
Provider failures are wrapped with exported sentinels so you can branch on the
failure category with `errors.Is` instead of string-matching the underlying
HTTP error. `PromptResult` / `Prompt` already return classified errors; you can
also classify any provider error yourself with `kit.ClassifyProviderError`:
```go
_, err := host.PromptResult(ctx, prompt)
switch {
case errors.Is(err, kit.ErrContextOverflow):
host.Compact(ctx, nil, "") // compact and retry
case errors.Is(err, kit.ErrRateLimit):
backoffAndRetry()
case errors.Is(err, kit.ErrAuth):
rePromptForKey()
case errors.Is(err, kit.ErrProviderUnavailable):
retryLater()
case errors.Is(err, kit.ErrInvalidRequest):
log.Printf("non-retryable: %v", err)
}
```
| Sentinel | Meaning |
|----------|---------|
| `kit.ErrContextOverflow` | Request exceeded the model's context window |
| `kit.ErrRateLimit` | Provider throttled the request |
| `kit.ErrAuth` | Credential / authorization failure |
| `kit.ErrProviderUnavailable` | Transient upstream failure (5xx, network, timeout) |
| `kit.ErrInvalidRequest` | Structurally invalid request — retrying won't help |
The original error stays reachable via `errors.Is`, so you never lose the
provider's detail message.
## Graceful shutdown
`Close()` releases MCP connections, model resources, and the session file
handle. When shutdown must be bounded by a deadline, use `CloseContext`:
```go
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := host.CloseContext(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
```
`Close()` is equivalent to `CloseContext(context.Background())`.
## In-process subagents
Spawn child Kit instances without subprocess overhead:
+1 -7
View File
@@ -99,12 +99,6 @@ host, _ := kit.New(ctx, &kit.Options{
})
```
The interface requires methods for message storage, branching, compaction, branch summaries, extension data, and lifecycle management. See the [`SessionManager` interface definition](https://pkg.go.dev/github.com/mark3labs/kit/pkg/kit#SessionManager) for the complete method set.
The `AppendBranchSummary(fromID, summary)` method backs `host.CollapseBranch`,
which collapses a branch range into a single summary entry. Custom managers
that don't track branch summaries can return `kit.ErrBranchSummaryNotSupported`
from that method; `host.CollapseBranch` then surfaces the same sentinel so
callers can detect it with `errors.Is`.
The interface requires methods for message storage, branching, compaction, extension data, and lifecycle management. See the [SDK skill reference](https://github.com/mark3labs/kit) for the complete interface definition.
When using a custom `SessionManager`, the `SessionPath`, `Continue`, and `NoSession` options are ignored — your manager handles its own storage and session selection.