Compare commits

..

13 Commits

Author SHA1 Message Date
Ed Zynda 888c6c7953 chore(models): refresh embedded models database from models.dev
- add GLM-5.2 across 9 providers (alibaba-token-plan-cn, baseten,
  cloudflare-workers-ai, fireworks-ai, neuralwatt, opencode-go,
  openrouter, venice, vercel)
- add moonshotai/Kimi-K2.7-Code on baseten
- drop deprecated neuralwatt models (MiniMax-M2.5,
  Devstral-Small-2-24B-Instruct-2512, gpt-oss-20b)
- pick up new reasoning_options metadata on several models
2026-06-18 12:42:11 +03:00
Ed Zynda a9d808eb9f build(deps): bump go module dependencies
- charm.land/fantasy v0.31.0 -> v0.32.0
- alecthomas/chroma/v2 v2.26.1 -> v2.27.0
- charmbracelet/openai-go to 20260617131321
- mark3labs/mcp-go v0.54.1 -> v0.55.0
- kaptinlin/jsonschema v0.8.0 -> v0.8.1
- pelletier/go-toml/v2 v2.3.1 -> v2.4.0
- google.golang.org/api v0.284.0 -> v0.285.0
- google.golang.org/genai v1.60.0 -> v1.61.0
2026-06-18 12:37:37 +03:00
Ed Zynda d7948a64f3 fix(app): make ctx.NewSession wait for agent idle (#63) (#64)
- Add ErrAgentBusy sentinel (shared between internal/app and
  internal/extensions) so callers can detect the busy condition with
  errors.Is instead of substring-matching the error message.
- Add App.WaitForIdle(timeout) backed by a per-busy-cycle idleCh closed
  by a new setBusyLocked chokepoint; all busy transitions now route
  through it to keep the channel in sync with the busy flag.
- Have RequestNewSessionFromExtension wait for idle (up to
  DefaultNewSessionIdleWait = 10m) instead of failing fast on IsBusy.
  This fixes the v0.79.0 phase-handoff race where OnAgentEnd fires from
  inside the agent loop, before drainQueue clears busy, so
  ctx.NewSession reliably failed with 'agent is busy'.
- Expose ext.ErrAgentBusy to Yaegi via symbols.go.
- Update NewSession godoc and phase-handoff example to document the new
  wait-then-send behavior.
- Add regression tests covering already-idle, blocks-until-drain,
  timeout, zero-timeout, app-close, headless guard, and idleCh
  transitions.

Fixes #63
2026-06-18 12:33:54 +03:00
Michal Hrušecký d2e2e5e9b3 feat(models): add apiModelName field to custom model config (#59)
* feat(models): add apiModelName field to custom model config

Allows custom models to specify an alternative model name to send
in API requests, distinct from the config key. Useful when a local
or custom endpoint expects a different model identifier.

Configures createCustomProvider to use modelInfo.APIModelName
when calling p.LanguageModel(), falling back to the config key.

* docs: document apiModelName field in custom model config
2026-06-17 17:17:50 +03:00
Ed Zynda 2c05280150 feat(ui): support /new <prompt> and ctx.NewSession for phase handoffs
- /new now accepts an optional initial prompt that is submitted as the
  first user turn of the new session, with @file expansion mirroring
  normal input submission
- Add ctx.NewSession(prompt) extension API for ending the current
  session and starting a fresh one from an extension (e.g. on AgentEnd)
- Plumb the prompt through BeforeSessionSwitchEvent.InitialPrompt so
  extensions can inspect or veto the switch
- Bridge extension calls into the TUI via app.NewSessionRequestEvent
  with a response channel so the caller observes success or failure
- Add pkg/kit EmitBeforeSessionSwitchWithPrompt; keep the old method
  as a thin compatibility wrapper
- Ship examples/extensions/phase-handoff.go demonstrating automatic
  session handoff on a <HANDOFF_READY> sentinel plus a /handoff command
- Tests cover the new /new prompt path, the extension request event,
  and the before-hook cancellation flow
2026-06-17 17:16:24 +03:00
Ed Zynda 6a1b061d06 build(deps): update dependencies and bump go to 1.26.4
- upgrade charm.land/fantasy v0.25.0 -> v0.31.0
- upgrade charm.land/lipgloss/v2, ultraviolet, and AWS/Azure SDKs
- bump golang.org/x/image and golang.org/x/term
- raise go directive to 1.26.4 as required by updated deps
2026-06-16 14:20:53 +03:00
Ed Zynda 3a35bc5cec chore(models): refresh embedded models.dev snapshot
- Add umans-ai provider (144 -> 145 providers)
- Add new models (kimi-k2.7-code variants, minimax-m3, gemma-4, glm-5.2)
- Remove stale entries (claude-fable-5, older fireworks/siliconflow models)
- Model count 5244 -> 5270
2026-06-16 14:10:24 +03:00
Ed Zynda 08c3d0fe3a feat(github): make GitHub integration work end-to-end via core command + action
Replace the headless-incompatible handler extension with a real,
out-of-the-box integration modeled on opencode:

- add 'kit github run' core command that reads the Actions event, gates
  on author_association, reacts, runs the agent headlessly, posts a
  comment, and opens a kit-agent[bot] PR when files changed
- add a bundled composite action (action.yml) that installs the Kit
  binary and runs 'kit github run' — no separate action repo needed
- point the generated workflow at the in-repo action (mark3labs/kit@v0)
- maintain floating major/minor tags (v0, v0.x) on release so the action
  reference always resolves
- bound git/gh/agent subprocesses with timeouts; ignore mid-sentence
  /kit mentions
- remove the github-handler example extension (superseded) and refresh
  docs to describe the action + command

Part of #60
2026-06-16 13:29:06 +03:00
Ed Zynda 16662ca208 feat(extensions): add GitHub handler extension and env var access (#62)
* feat(extensions): add GitHub handler extension and env var access

- add github-handler example extension that runs Kit as a GitHub
  collaborator inside Actions: parses the event, gates on
  author_association, drives the agent, posts comments, and opens PRs
- seed the Yaegi interpreter with os.Environ() in the loader and test
  harness so extensions can read env vars (e.g. GITHUB_EVENT_PATH) via
  os.Getenv/LookupEnv/Environ without mutating the host environment
- document env var access, the new extension, and env-aware testing
  across the docs site, README, and kit-extensions skill

Part of #60

* fix(extensions): harden github-handler command parsing and subprocesses

- only trigger on /kit at the start or end of a comment line, ignoring
  incidental mid-sentence mentions like "please review /kit behavior"
- bound git/gh subprocess calls with a 30s timeout via CommandContext so
  a stalled network call or auth prompt cannot hang the Actions job
- add a regression test for the mid-sentence mention case

Part of #60
2026-06-16 00:29:13 +03:00
Ed Zynda 7067c99c84 feat(cmd): add kit github install command (#60) (#61)
* feat(cmd): add kit github install command (#60)

- Add `kit github` parent command and `kit github install` subcommand
  that scaffolds .github/workflows/kit.yml to run Kit as a GitHub
  Actions collaborator/reviewer triggered by `/kit` comments
- Generate a least-privilege workflow with persist-credentials: false,
  resolve the provider secret env var from the model registry, and
  refuse to clobber an existing file unless --force
- Offer to set the provider secret via the gh CLI when available;
  flags: --model, --force, --no-secret
- Add unit tests for secret resolution, workflow rendering, and write
- Document the command in README and the docs site (cli/commands, index)

Fixes #60

* fix(cmd): harden kit github install workflow and secret handling

- Pass the provider secret to `gh secret set` via stdin instead of the
  --body flag so the API key never appears in the process argument list
- Gate the generated workflow on author_association (OWNER, MEMBER,
  COLLABORATOR) so untrusted users cannot trigger privileged runs
- Match `/kit` only as a leading command token instead of an incidental
  substring anywhere in the comment body
- Thread cmd.Context() through to the gh invocation
- Update tests and docs to reflect the refined trigger conditions
2026-06-15 23:46:35 +03:00
Ed Zynda feaec4268e chore(models): update embedded models.dev snapshot
- Refresh internal/models/embedded_models.json from models.dev/api.json
- Providers 139 -> 144, models 5121 -> 5244
2026-06-15 16:31:27 +03:00
Sai Karthik 7f366eab84 cmd: add --no-skills, --skill, and --skills-dir CLI flags & config (#55)
* cmd: add --no-skills, --skill, and --skills-dir CLI flags

The pkg/kit Options struct already had full backend support for skills
control (NoSkills, Skills []string, SkillsDir) wired into loadSkills()
in pkg/kit/kit.go, but there were no corresponding CLI flags to drive
them. This commit closes that gap.

Changes in cmd/root.go:

- Add three package-level flag variables alongside the existing
  noExtensionsFlag/extensionPaths group:
    noSkillsFlag bool
    skillsPaths  []string
    skillsDir    string

- Register three persistent cobra flags in init():
    --no-skills        disable skill loading (auto-discovery and explicit)
    --skill <path>     load a skill file or directory (repeatable)
    --skills-dir <dir> override the project-local skills directory
                       used for auto-discovery

- Wire all three into the kitOpts struct literal in runNormalMode()
  so they flow directly into kit.New() -> loadSkills().

No changes to pkg/kit or internal/skills -- the backend was already
complete. No viper binding is needed because kit.go reads these fields
directly from opts rather than from viper (unlike NoExtensions which
uses the viper fallback path).

Example usage:
  kit --no-skills "prompt"
  kit --skill ./my-skill.md --skill ./other-skill.md "prompt"
  kit --skills-dir /path/to/skills "prompt"

Co-authored-by: Claude <claude@anthropic.com>

* docs: document --no-skills, --skill, and --skills-dir CLI flags

Add the three new skills CLI flags to all relevant documentation:

- README.md: add Skills section under Global Flags CLI reference
- www/pages/cli/flags.md: add Skills table (mirrors Extensions section pattern)
- www/pages/cli/commands.md: expand the Skills section with usage examples
  and a description of auto-discovery vs explicit loading vs --no-skills

Co-authored-by: Claude <claude@anthropic.com>

* feat: add config file support for skills options

Skills could previously only be controlled via CLI flags or SDK Options
fields. This commit wires all three skills settings into viper so they
can also be set in .kit.yml / .kit.yaml / .kit.json and via KIT_*
environment variables — matching the pattern used by no-extensions,
no-core-tools, and prompt-template.

cmd/root.go:
- Bind --no-skills, --skill, and --skills-dir flags to viper keys
  (no-skills, skill, skills-dir) so config file values flow through.

pkg/kit/kit.go:
- At skill-load time, merge opts fields with viper values:
  - noSkills = opts.NoSkills || v.GetBool("no-skills")
  - skillPaths: opts.Skills if non-empty, else v.GetStringSlice("skill")
  - skillsDir: opts.SkillsDir if non-empty, else v.GetString("skills-dir")
- Build a shallow-copied mergedOpts so loadSkills() picks up the
  resolved values without mutating the original Options struct.

docs:
- README.md: add skills keys to the Basic Configuration YAML example
- www/pages/configuration.md: add no-skills, skill, skills-dir rows to
  the All configuration keys table

Config file example (.kit.yml):
  no-skills: false
  skill:
    - /path/to/skill.md
  skills-dir: /path/to/skills/

Co-authored-by: Claude <claude@anthropic.com>

* config: add skills keys to default .kit.yml template

Add no-skills, skill, and skills-dir as commented-out examples in the
default config file generated by EnsureConfigExists(), alongside the
existing application settings block.

Co-authored-by: Claude <claude@anthropic.com>

* test: add test coverage for skills CLI flags and config keys

Four test locations updated:

pkg/kit/export_test.go:
- Add ConfigStringSliceForTest() helper to expose v.GetStringSlice()
  from the Kit's isolated viper store, needed to assert skill list values.

pkg/kit/kit_test.go (TestNewWithSkillsOptions):
- NoSkills=true: GetSkills() returns empty slice
- SkillsDir=<empty dir>: kit.New() succeeds with zero skills
- Skills=[file]: single explicit skill file is loaded and name parsed correctly

pkg/kit/viper_isolation_test.go:
- TestSkillsViperKeys: no-API-key struct-level checks for NoSkills, Skills,
  and SkillsDir fields on Options
- TestSkillsConfigFileKeys: full kit.New() round-trips via a written .kit.yml
  for each of the three config keys:
    no-skills: true  → GetSkills() returns empty
    skill: [path]    → named skill loaded from config file path
    skills-dir: dir  → custom discovery root accepted without error

internal/config/config_test.go (TestEnsureConfigExists):
- Assert generated ~/.kit.yml template contains '# Skills configuration',
  'no-skills:', and 'skills-dir:' comment blocks.

Co-authored-by: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
2026-06-12 16:23:17 +03:00
Ed Zynda e8e99b19a8 refactor: dedupe cross-package logic and remove dead code from audit (#58)
* 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)

* 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.

* 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.

* 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

* 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)

* 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.

* 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).

* 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.

* 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.

* 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.

* 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.

* 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:13:18 +03:00
41 changed files with 2687 additions and 292 deletions
+30
View File
@@ -39,6 +39,36 @@ 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
+60
View File
@@ -28,6 +28,7 @@ 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
@@ -128,6 +129,12 @@ 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 three 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: "" # override project-local directory for auto-discovery
```
All of the above keys can also be set programmatically via the SDK
@@ -203,6 +210,11 @@ 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 Override the project-local skills directory for auto-discovery
--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)
@@ -249,6 +261,12 @@ 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
@@ -467,6 +485,48 @@ 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.
+75
View File
@@ -0,0 +1,75 @@
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,6 +69,9 @@ 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
@@ -0,0 +1,255 @@
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
@@ -0,0 +1,498 @@
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
@@ -0,0 +1,190 @@
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
@@ -0,0 +1,102 @@
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)
}
}
+27 -5
View File
@@ -73,6 +73,11 @@ var (
noCoreToolsFlag bool
extensionPaths []string
// Skills control
noSkillsFlag bool
skillsPaths []string
skillsDir string
// TLS configuration
tlsSkipVerify bool
@@ -283,6 +288,14 @@ 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", "", "override the project-local skills directory for auto-discovery")
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)")
@@ -333,6 +346,9 @@ 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"))
// Defaults are already set in flag definitions, no need to duplicate in viper
@@ -654,13 +670,16 @@ 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".
func beforeSessionSwitchProviderForUI(k *kit.Kit) func(string) (bool, string) {
// 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 !k.Extensions().HasExtensions() {
return nil
}
return func(switchReason string) (bool, string) {
return k.Extensions().EmitBeforeSessionSwitch(switchReason)
return func(switchReason, initialPrompt string) (bool, string) {
return k.Extensions().EmitBeforeSessionSwitchWithPrompt(switchReason, initialPrompt)
}
}
@@ -820,6 +839,9 @@ func runNormalMode(ctx context.Context) error {
AutoCompact: autoCompactFlag,
MCPAuthHandler: authHandler,
DisableCoreTools: viper.GetBool("no-core-tools"),
NoSkills: noSkillsFlag,
Skills: skillsPaths,
SkillsDir: skillsDir,
// 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.
@@ -1468,7 +1490,7 @@ type runModeDeps struct {
getUIVisibility func() *ui.UIVisibility
getStatusBarEntries func() []ui.StatusBarEntryData
emitBeforeFork func(string, bool, string) (bool, string)
emitBeforeSessionSwitch func(string) (bool, string)
emitBeforeSessionSwitch func(string, string) (bool, string)
getGlobalShortcuts func() map[string]func()
getExtensionCommands func() []commands.ExtensionCommand
setModel func(string) error
+110
View File
@@ -0,0 +1,110 @@
//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
})
}
+42 -44
View File
@@ -1,21 +1,21 @@
module github.com/mark3labs/kit
go 1.26.3
go 1.26.4
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.7
charm.land/fantasy v0.25.0
charm.land/fantasy v0.32.0
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.3
github.com/alecthomas/chroma/v2 v2.26.1
charm.land/lipgloss/v2 v2.0.4
github.com/alecthomas/chroma/v2 v2.27.0
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-20260319145158-d0740cc34266
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f
github.com/charmbracelet/openai-go v0.0.0-20260617131321-5e4b9c18c4be
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1
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.54.1
github.com/mark3labs/mcp-go v0.55.0
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.41.0
golang.org/x/term v0.43.0
golang.org/x/image v0.42.0
golang.org/x/term v0.44.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.21.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // 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/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/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-20260602025833-85a30b5e440a // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260615092313-b57e5e6d29bb // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260527151214-009e6338d40d // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260615092313-b57e5e6d29bb // 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.1.1 // indirect
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 // 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,16 +84,14 @@ 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/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/kaptinlin/jsonpointer v0.4.26 // indirect
github.com/kaptinlin/jsonschema v0.8.1 // 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.3.1 // indirect
github.com/pelletier/go-toml/v2 v2.4.0 // 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
@@ -113,14 +111,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.52.0 // indirect
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
golang.org/x/net v0.55.0 // 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/oauth2 v0.36.0 // indirect
golang.org/x/time v0.15.0 // 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/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/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
@@ -138,7 +136,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.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0
)
+86 -163
View File
@@ -1,15 +1,13 @@
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.25.0 h1:oXOWY1ivmTSnhYGzAolscF8zKtavWZyBWv0LHRSwN5Q=
charm.land/fantasy v0.25.0/go.mod h1:8QrWUzIcKwZQP+aAnC9vLu3iID6hu9/Jt+rPMiieBkc=
charm.land/fantasy v0.32.0 h1:tlC1qlOdXi2CkF6KB0x8YAAm3hiarI2/69u6pZmOZk8=
charm.land/fantasy v0.32.0/go.mod h1:CWAFEOB21guhmt4qWN9sOnAHkZzVWjKbhxbPHG+oRs8=
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.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=
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
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=
@@ -18,81 +16,60 @@ 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=
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/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=
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.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y=
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/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.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/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/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=
@@ -107,10 +84,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-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/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/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=
@@ -121,14 +98,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-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/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/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-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/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/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=
@@ -143,7 +120,6 @@ 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=
@@ -157,31 +133,25 @@ 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.1.1 h1:LCUGyd9Wf+r+VVOl8Ny38JTpWJcAsdVnCIuhhtthmKw=
github.com/dlclark/regexp2/v2 v2.1.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
github.com/dlclark/regexp2/v2 v2.2.2/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.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
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-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-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-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=
@@ -195,17 +165,12 @@ 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=
@@ -216,32 +181,18 @@ 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/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/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/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=
@@ -250,14 +201,12 @@ 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/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/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/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=
@@ -272,19 +221,14 @@ 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/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/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/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=
@@ -292,10 +236,8 @@ 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=
@@ -307,7 +249,6 @@ 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=
@@ -325,27 +266,20 @@ 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=
@@ -354,49 +288,40 @@ 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=
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/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=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.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/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/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.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/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/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=
@@ -404,8 +329,6 @@ 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,6 +96,9 @@ 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).
+168 -9
View File
@@ -2,6 +2,7 @@ package app
import (
"context"
"errors"
"fmt"
"log"
"os"
@@ -24,6 +25,26 @@ 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.
@@ -55,11 +76,25 @@ type App struct {
// each new step and called by CancelCurrentStep().
cancelStep context.CancelFunc
// mu protects busy, queue, and cancelStep.
// mu protects busy, queue, cancelStep, and idleCh.
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
@@ -95,6 +130,10 @@ 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),
@@ -102,6 +141,90 @@ 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()
}
}
}
@@ -155,7 +278,7 @@ func (a *App) RunWithFiles(prompt string, files []kit.LLMFilePart) int {
return qLen
}
a.busy = true
a.setBusyLocked(true)
a.wg.Add(1)
a.mu.Unlock()
go a.drainQueue(item)
@@ -235,7 +358,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.busy = true
a.setBusyLocked(true)
a.wg.Add(1)
a.mu.Unlock()
go a.drainQueue(item)
@@ -271,7 +394,7 @@ func (a *App) InterruptAndSend(prompt string) {
if !a.busy {
// Not busy — start immediately, same as Run().
a.busy = true
a.setBusyLocked(true)
a.wg.Add(1)
a.mu.Unlock()
go a.drainQueue(item)
@@ -470,7 +593,7 @@ func (a *App) CompactConversation(customInstructions string) error {
a.mu.Unlock()
return fmt.Errorf("SDK instance not available")
}
a.busy = true
a.setBusyLocked(true)
a.wg.Add(1)
a.mu.Unlock()
@@ -532,7 +655,7 @@ func (a *App) CompactAsync(customInstructions string, onComplete func(), onError
a.mu.Unlock()
return fmt.Errorf("SDK instance not available")
}
a.busy = true
a.setBusyLocked(true)
a.wg.Add(1)
a.mu.Unlock()
@@ -621,7 +744,7 @@ func (a *App) releaseBusyAfterCompact() {
// in just before closed was set.
if a.closed {
a.queue = a.queue[:0]
a.busy = false
a.setBusyLocked(false)
a.mu.Unlock()
return
}
@@ -633,7 +756,7 @@ func (a *App) releaseBusyAfterCompact() {
a.queue = a.queue[:0]
if len(pending) == 0 {
a.busy = false
a.setBusyLocked(false)
a.mu.Unlock()
return
}
@@ -850,7 +973,7 @@ func (a *App) drainQueue(first queueItem) {
// Mark as no longer busy
a.mu.Lock()
a.busy = false
a.setBusyLocked(false)
a.mu.Unlock()
}
@@ -1230,6 +1353,42 @@ 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) {
+283 -5
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.busy = true
app.setBusyLocked(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.busy = true
app.setBusyLocked(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.busy = true
app.setBusyLocked(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.busy = true
app.setBusyLocked(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.busy = true
app.setBusyLocked(true)
app.mu.Unlock()
_, _, err := app.PopLastUserMessage()
@@ -1115,3 +1115,281 @@ 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,6 +247,21 @@ 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
+17 -10
View File
@@ -227,16 +227,17 @@ 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"`
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"`
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"`
// Generation parameter defaults for this model.
// These are applied when the user hasn't explicitly set the corresponding
@@ -493,6 +494,12 @@ 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" # Override project-local directory for auto-discovery
# 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
+3
View File
@@ -205,6 +205,9 @@ func TestEnsureConfigExists(t *testing.T) {
"type: \"local\"",
"type: \"remote\"",
"Core tools",
"# Skills configuration",
"no-skills:",
"skills-dir:",
}
for _, expected := range expectedSections {
+67
View File
@@ -1,5 +1,24 @@
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)
// ---------------------------------------------------------------------------
@@ -124,6 +143,48 @@ 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.
@@ -2296,6 +2357,12 @@ 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 }
+6 -2
View File
@@ -372,8 +372,12 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
Handlers: make(map[EventType][]HandlerFunc),
}
// Create a fresh interpreter.
i := interp.New(interp.Options{})
// 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()})
// 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,6 +192,9 @@ 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,6 +28,11 @@ 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)),
+20 -18
View File
@@ -44,13 +44,14 @@ 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,
ID: modelID,
Name: cfg.Name,
Attachment: cfg.Attachment,
Reasoning: cfg.Reasoning,
Temperature: cfg.Temperature,
BaseURL: cfg.BaseURL,
APIKey: cfg.APIKey,
APIModelName: cfg.APIModelName,
Cost: Cost{
Input: cfg.Cost.Input,
Output: cfg.Cost.Output,
@@ -287,17 +288,18 @@ 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"`
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"`
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"`
}
// GenerationParamsConfig is the JSON/YAML-serializable form of generation
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -1533,7 +1533,12 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName
return nil, wrapProviderErr("custom", "provider", err)
}
model, err := p.LanguageModel(ctx, modelName)
apiModelName := modelName
if modelInfo != nil && modelInfo.APIModelName != "" {
apiModelName = modelInfo.APIModelName
}
model, err := p.LanguageModel(ctx, apiModelName)
if err != nil {
return nil, wrapProviderErr("custom", "model", err)
}
+12 -11
View File
@@ -16,17 +16,18 @@ 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)
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)
// Params holds per-model generation parameter defaults. These are applied
// when the user hasn't explicitly set the corresponding CLI flag or global
+2 -1
View File
@@ -146,9 +146,10 @@ var SlashCommands = []SlashCommand{
},
{
Name: "/new",
Description: "Start a new session",
Description: "Start a new session (optionally with an initial prompt)",
Category: "Navigation",
Aliases: []string{"/n"},
HasArgs: true,
},
{
Name: "/name",
+119 -17
View File
@@ -445,9 +445,12 @@ 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). Returns (cancelled,
// reason). May be nil if no extensions are loaded.
EmitBeforeSessionSwitch func(reason string) (bool, string)
// 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)
// GetGlobalShortcuts, if non-nil, returns extension-registered global
// keyboard shortcuts. Keys are binding strings (e.g., "ctrl+p").
@@ -575,6 +578,13 @@ 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
@@ -677,7 +687,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 string) (bool, string)
emitBeforeSessionSwitch func(reason, initialPrompt string) (bool, string)
// thinkingLevel is the current extended thinking level.
thinkingLevel string
@@ -2192,6 +2202,25 @@ 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
@@ -2397,8 +2426,9 @@ 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())
cmds = append(cmds, m.performNewSession(msg.initialPrompt))
}
case beforeForkResultMsg:
@@ -3241,7 +3271,7 @@ func (m *AppModel) handleSlashCommand(sc *commands.SlashCommand, args string) te
case "/fork":
return m.handleForkCommand()
case "/new":
return m.handleNewCommand()
return m.handleNewCommand(args)
case "/name":
return m.handleNameCommand(args)
case "/resume":
@@ -3672,7 +3702,7 @@ func (m *AppModel) printHelpMessage() {
"**Navigation:**\n" +
"- `/tree`: Navigate session tree (switch branches)\n" +
"- `/fork`: Branch from an earlier message\n" +
"- `/new`: Start a new session (discards context, saves old session)\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" +
"- `/resume`: Open session picker to switch sessions\n" +
"- `/name <name>`: Set a display name for this session\n\n" +
"**System:**\n" +
@@ -4368,7 +4398,12 @@ 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.
func (m *AppModel) handleNewCommand() tea.Cmd {
// 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)
// 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.
@@ -4376,23 +4411,25 @@ func (m *AppModel) handleNewCommand() tea.Cmd {
emit := m.emitBeforeSessionSwitch
ctrl := m.appCtrl
go func() {
cancelled, reason := emit("new")
cancelled, reason := emit("new", initialPrompt)
ctrl.SendEvent(beforeSessionSwitchResultMsg{
cancelled: cancelled,
reason: reason,
cancelled: cancelled,
reason: reason,
initialPrompt: initialPrompt,
})
}()
return noopCmd
}
return m.performNewSession()
return m.performNewSession(initialPrompt)
}
// 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.
func (m *AppModel) performNewSession() tea.Cmd {
// 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 {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
// No tree session — just clear messages.
@@ -4406,13 +4443,16 @@ func (m *AppModel) performNewSession() tea.Cmd {
// Clear the ScrollList so the new session starts fresh.
m.messages = []MessageItem{}
m.printSystemMessage("Conversation cleared. Starting fresh.")
return nil
cmd := m.submitInitialPrompt(initialPrompt)
m.signalNewSessionResult(nil)
return cmd
}
// 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
}
@@ -4425,6 +4465,67 @@ func (m *AppModel) performNewSession() 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
}
@@ -5133,8 +5234,9 @@ 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
cancelled bool
reason string
initialPrompt string
}
// beforeForkResultMsg carries the result of an asynchronously executed
+125
View File
@@ -1144,3 +1144,128 @@ 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)
}
}
+4 -2
View File
@@ -91,8 +91,10 @@ 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
i := interp.New(interp.Options{})
// 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()})
// Expose Go stdlib
if err := i.Use(stdlib.Symbols); err != nil {
+6
View File
@@ -20,3 +20,9 @@ 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)
}
+11 -1
View File
@@ -137,6 +137,7 @@ 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
@@ -567,11 +568,20 @@ 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,
Reason: switchReason,
InitialPrompt: initialPrompt,
})
if r, ok := result.(extensions.BeforeSessionSwitchResult); ok && r.Cancel {
reason := r.Reason
+18 -2
View File
@@ -1330,9 +1330,25 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
}
// Load skills — either from explicit paths or via auto-discovery.
if !opts.NoSkills {
// 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
var err error
loadedSkills, err = loadSkills(opts)
loadedSkills, err = loadSkills(&mergedOpts)
if err != nil {
return fmt.Errorf("failed to load skills: %w", err)
}
+75
View File
@@ -365,6 +365,81 @@ 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) {
+125
View File
@@ -205,6 +205,131 @@ 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) {
+51
View File
@@ -56,6 +56,57 @@ 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"
# Load all skills from a custom directory instead of the default locations
kit --skills-dir /path/to/skills "prompt"
# Disable all skill loading (auto-discovery and explicit)
kit --no-skills "prompt"
```
Skills are auto-discovered from `~/.config/kit/skills/`, `.kit/skills/`, and `.agents/skills/` by default. Use `--skills-dir` to override the project-local search root, or `--skill` to load files explicitly (which disables auto-discovery). `--no-skills` suppresses all skill loading regardless of other flags.
## 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:
+8
View File
@@ -48,6 +48,14 @@ 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` | — | — | Override the project-local skills directory for auto-discovery |
| `--no-skills` | — | `false` | Disable skill loading (auto-discovery and explicit) |
## Generation parameters
| Flag | Short | Default | Description |
+5
View File
@@ -47,6 +47,9 @@ 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 | — | Override the project-local directory used for skill auto-discovery |
## Environment variables
@@ -148,6 +151,7 @@ customModels:
name: "My Custom Model"
baseUrl: "http://localhost:8080/v1"
apiKey: "my-secret-key"
apiModelName: "gpt-4-turbo"
reasoning: true
temperature: true
cost:
@@ -165,6 +169,7 @@ 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 |
+31
View File
@@ -117,3 +117,34 @@ 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,6 +392,25 @@ 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,6 +20,7 @@ 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