Compare commits

..

12 Commits

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

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

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

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

- internal/auth: new CredentialSourceOAuth constant + IsAnthropicOAuth()
- internal/ui: new UpdateUsageTrackerForModel(); CreateUsageTracker and
  SetupCLI share lookupTrackableModel (SetupCLI no longer re-inlines the
  tracker construction)
- cmd/root.go + cmd/extension_context.go: verbatim-duplicated tracker
  refresh blocks replaced with ui.UpdateUsageTrackerForModel
- pkg/kit isAnthropicOAuth delegates to auth.IsAnthropicOAuth
- internal/models compares source against the constant
2026-06-11 14:48:28 +03:00
Ed Zynda 60ed2a4964 Consolidate tool-kind classification into internal/extensions
coreToolKinds + toolKindFor were duplicated verbatim in
internal/extensions/wrapper.go and pkg/kit/events.go, risking silent
divergence between extension events and SDK events. Single source of
truth now lives in internal/extensions/toolkinds.go; pkg/kit re-exports
the constants.
2026-06-11 14:45:02 +03:00
Ed Zynda 31d32655f5 pkg/kit: use TreeManager alias in exported signatures
NewTreeManagerAdapter and InitTreeSession now spell their signatures with
the public kit.TreeManager alias instead of internal/session.TreeManager,
so go doc renders domain types rather than internal paths.
2026-06-11 14:43:12 +03:00
Ed Zynda 43af34fdcf Remove dead code: 5 unused symbols across internal packages
- internal/models: LoadModelSettingsFromConfig (zero refs)
- internal/prompts: PromptTemplate.ExpandWithArgs (zero refs)
- internal/app: NewMessageStore (tests migrated to NewMessageStoreWithMessages)
- internal/config: HasEnvVars (+ its test)
- internal/core: ContextWithSudoPassword (test migrated to context.WithValue)
2026-06-11 14:42:33 +03:00
23 changed files with 7 additions and 1586 deletions
-30
View File
@@ -39,36 +39,6 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Keep floating major/minor tags (e.g. v1, v1.2) pointing at the latest
# release so the composite action can be referenced as `mark3labs/kit@v1`.
action-tags:
runs-on: ubuntu-latest
needs: goreleaser
if: ${{ github.event_name == 'push' && needs.goreleaser.result == 'success' }}
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Update floating major/minor tags
env:
FULL_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# FULL_TAG looks like v1.2.3 — derive v1 and v1.2.
VER="${FULL_TAG#v}"
MAJOR="v${VER%%.*}"
MINOR="v${VER%.*}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
for t in "$MAJOR" "$MINOR"; do
echo "Pointing $t at $FULL_TAG"
git tag -f "$t" "$FULL_TAG"
git push -f origin "refs/tags/$t"
done
npm-publish:
runs-on: ubuntu-latest
needs: goreleaser
-60
View File
@@ -28,7 +28,6 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
- **Interactive TUI**: Rich terminal interface powered by Bubble Tea with streaming, syntax highlighting, and custom rendering
- **Session Management**: Tree-based conversation history with branching support
- **Non-Interactive Mode**: Script-friendly positional args with JSON output
- **GitHub Integration**: Scaffold a GitHub Actions workflow with `kit github install` to run Kit as a collaborator/reviewer on `/kit` comments
- **ACP Server**: Run Kit as an [Agent Client Protocol](https://agentclientprotocol.com) agent over stdio
- **Go SDK**: Embed Kit in your own applications with full agent lifecycle events (30+ event types) and behavior-modifying hooks
@@ -129,12 +128,6 @@ temperature: 0.7
stream: true
thinking-level: off # off, none, minimal, low, medium, high
no-core-tools: false # set to true to disable all built-in core tools
# Skills — all 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
@@ -210,11 +203,6 @@ mcpServers:
--prompt-template Load a specific prompt template by name
--no-prompt-templates Disable prompt template loading
# Skills
--skill Load skill file or directory (repeatable)
--skills-dir 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)
@@ -261,12 +249,6 @@ kit install --uninstall <pkg> # Remove an installed package
# Skills
kit skill # Install the Kit extensions skill via skills.sh
# GitHub integration
kit github install # Scaffold .github/workflows/kit.yml (run Kit on '/kit' comments)
kit github install --model anthropic/claude-sonnet-4-5-20250929
kit github install --force # Overwrite an existing workflow file
kit github install --no-secret # Skip the offer to set the provider secret via the gh CLI
# ACP server
kit acp # Start as ACP agent (stdio JSON-RPC)
kit acp --debug # With debug logging to stderr
@@ -485,48 +467,6 @@ Placeholders inside fenced code blocks (```) and inline code spans are ignored.
Disable templates with `--no-prompt-templates` or load a specific template with `--prompt-template <name>`.
## GitHub Integration
Kit can run as an automated collaborator/reviewer inside GitHub Actions. The
`kit github install` command scaffolds a workflow that triggers when someone
comments `/kit ...` on an issue or pull request review, runs the agent
non-interactively in the runner, and lets it respond.
```bash
kit github install
```
This writes `.github/workflows/kit.yml`. By default the command prompts for the
model (pre-filled with a sensible default); pass `--model` to skip the prompt.
If the [`gh` CLI](https://cli.github.com/) is detected on your `PATH` and the
provider API key is present in your environment, you'll be offered the option to
store it as a repository secret automatically.
The generated workflow:
- Triggers only on `issue_comment` and `pull_request_review_comment` (`types: [created]`).
- Runs only when the comment begins with the `/kit` command token.
- Restricts triggers to repository owners, members, and collaborators (via `author_association`).
- Uses least-privilege `permissions` and `persist-credentials: false`.
- Authenticates git/PR operations with the built-in `secrets.GITHUB_TOKEN` and
the provider via a repository secret (e.g. `ANTHROPIC_API_KEY`).
After committing the workflow and setting the provider secret, comment
`/kit <your request>` on any issue or pull request to trigger Kit.
The generated workflow uses the bundled [`mark3labs/kit`](action.yml) composite
action, which installs the Kit binary and runs `kit github run`. That command
reads the triggering event, enforces permissions, reacts with an emoji, runs the
agent against the issue thread or pull request, posts the response as a comment,
and — if the agent changed files — pushes a `kit-agent[bot]` branch and opens a
pull request.
| Flag | Description |
| --- | --- |
| `--model` | Provider/model to write into the workflow |
| `--force` | Overwrite an existing workflow file |
| `--no-secret` | Skip the offer to set the provider secret via the `gh` CLI |
## Session Management
Kit uses a tree-based session model that supports branching and forking conversations.
-75
View File
@@ -1,75 +0,0 @@
name: "Kit"
description: "Run Kit as an automated collaborator/reviewer on GitHub issues and pull requests."
author: "mark3labs"
branding:
icon: "git-merge"
color: "purple"
inputs:
model:
description: "Provider/model Kit should use (e.g. anthropic/claude-sonnet-4-5-20250929). Defaults to Kit's built-in default."
required: false
default: ""
version:
description: "Kit version to install (e.g. v0.77.0). Defaults to the latest release."
required: false
default: "latest"
runs:
using: "composite"
steps:
- name: Install Kit
shell: bash
env:
KIT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
VERSION="${KIT_VERSION:-latest}"
if [ -z "$VERSION" ] || [ "$VERSION" = "latest" ]; then
VERSION="$(curl -fsSL https://api.github.com/repos/mark3labs/kit/releases/latest \
| grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4)"
fi
if [ -z "$VERSION" ]; then
echo "::error::could not determine Kit version to install" >&2
exit 1
fi
VER="${VERSION#v}"
case "$(uname -s)" in
Linux) OS=linux ;;
Darwin) OS=darwin ;;
*) echo "::error::unsupported OS $(uname -s)" >&2; exit 1 ;;
esac
case "$(uname -m)" in
x86_64|amd64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) echo "::error::unsupported arch $(uname -m)" >&2; exit 1 ;;
esac
URL="https://github.com/mark3labs/kit/releases/download/${VERSION}/kit_${VER}_${OS}_${ARCH}.tar.gz"
echo "Installing Kit ${VERSION} from ${URL}"
TMP="$(mktemp -d)"
curl -fsSL "$URL" | tar -xz -C "$TMP"
mkdir -p "$HOME/.kit/bin"
mv "$TMP/kit" "$HOME/.kit/bin/kit"
chmod +x "$HOME/.kit/bin/kit"
echo "$HOME/.kit/bin" >> "$GITHUB_PATH"
rm -rf "$TMP"
- name: Verify Kit
shell: bash
run: kit --version
- name: Run Kit
shell: bash
env:
MODEL: ${{ inputs.model }}
run: |
set -euo pipefail
ARGS=()
if [ -n "${MODEL:-}" ]; then
ARGS+=(--model "$MODEL")
fi
kit github run ${ARGS[@]+"${ARGS[@]}"}
-255
View File
@@ -1,255 +0,0 @@
package cmd
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"charm.land/huh/v2"
"github.com/charmbracelet/log"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/spf13/cobra"
)
// defaultGitHubModel is the model written into the generated workflow when the
// user does not specify one and runs non-interactively.
const defaultGitHubModel = "anthropic/claude-sonnet-4-5-20250929"
// githubWorkflowPath is the repository-relative location of the generated
// GitHub Actions workflow that wires Kit into a repository as a collaborator.
const githubWorkflowPath = ".github/workflows/kit.yml"
var (
githubInstallModel string
githubInstallForce bool
githubInstallNoSecret bool
)
// githubCmd is the parent command for GitHub integration subcommands. It groups
// the turnkey setup tooling that wires Kit into a repository as an automated
// collaborator/reviewer driven by GitHub Actions.
var githubCmd = &cobra.Command{
Use: "github",
Short: "Set up Kit as a GitHub collaborator/reviewer",
Long: `Set up Kit as an automated collaborator/reviewer in a GitHub repository.
Kit runs inside a GitHub Actions runner, reads the relevant context (an issue
thread or pull request), runs the agent non-interactively, and responds by
posting comments and opening pull requests.
Use 'kit github install' to scaffold the GitHub Actions workflow.`,
}
// githubInstallCmd scaffolds the GitHub Actions workflow that runs Kit on
// '/kit' comment triggers. It writes .github/workflows/kit.yml and, when the
// 'gh' CLI is available, offers to set the provider API key as a repository
// secret.
var githubInstallCmd = &cobra.Command{
Use: "install",
Short: "Scaffold the GitHub Actions workflow that runs Kit",
Long: `Scaffold the GitHub Actions workflow that runs Kit as a collaborator.
This writes .github/workflows/kit.yml configured to trigger when someone
comments '/kit ...' on an issue or pull request review. The workflow runs Kit
inside an ephemeral Actions runner with least-privilege permissions and
'persist-credentials: false', mirroring established security practice.
If the GitHub CLI ('gh') is detected on your PATH, you will be offered the
option to store your provider API key as a repository secret automatically.
Flags:
--model Provider/model to write into the workflow (e.g. anthropic/claude-sonnet-4-5)
--force Overwrite an existing workflow file
--no-secret Skip the offer to set the provider secret via the gh CLI
Examples:
kit github install
kit github install --model anthropic/claude-sonnet-4-5-20250929
kit github install --force --no-secret`,
Args: cobra.NoArgs,
RunE: runGitHubInstall,
}
func init() {
githubInstallCmd.Flags().StringVarP(&githubInstallModel, "model", "m", "", "provider/model to write into the workflow")
githubInstallCmd.Flags().BoolVar(&githubInstallForce, "force", false, "overwrite an existing workflow file")
githubInstallCmd.Flags().BoolVar(&githubInstallNoSecret, "no-secret", false, "skip setting the provider secret via the gh CLI")
githubCmd.AddCommand(githubInstallCmd)
rootCmd.AddCommand(githubCmd)
}
func runGitHubInstall(cmd *cobra.Command, _ []string) error {
model, err := resolveGitHubModel()
if err != nil {
return err
}
provider, _, err := kit.ParseModelString(model)
if err != nil {
return fmt.Errorf("invalid model %q: %w", model, err)
}
secretName := providerSecretEnvVar(provider)
if err := writeGitHubWorkflow(model, secretName, githubInstallForce); err != nil {
return err
}
fmt.Printf("✅ Wrote %s\n", githubWorkflowPath)
maybeSetProviderSecret(cmd.Context(), secretName)
printGitHubInstallNextSteps(secretName)
log.Info("github workflow scaffolded", "model", model, "secret", secretName)
return nil
}
// resolveGitHubModel determines the model to embed in the workflow. The
// --model flag takes precedence; otherwise an interactive prompt is shown
// (pre-filled with the default), and non-interactive runs use the default.
func resolveGitHubModel() (string, error) {
if githubInstallModel != "" {
return strings.TrimSpace(githubInstallModel), nil
}
if !isInteractive() {
return defaultGitHubModel, nil
}
model := defaultGitHubModel
err := huh.NewInput().
Title("Model").
Description("Provider/model Kit should use in CI (e.g. anthropic/claude-sonnet-4-5)").
Value(&model).
Run()
if err != nil {
return "", fmt.Errorf("model selection cancelled: %w", err)
}
model = strings.TrimSpace(model)
if model == "" {
return "", fmt.Errorf("model cannot be empty")
}
return model, nil
}
// providerSecretEnvVar returns the environment variable / repository secret
// name that holds the API key for the given provider. It consults the model
// registry and falls back to "<PROVIDER>_API_KEY" for unknown providers.
func providerSecretEnvVar(provider string) string {
if info := kit.GetProviderInfo(provider); info != nil && len(info.Env) > 0 {
return info.Env[0]
}
sanitized := strings.ToUpper(strings.NewReplacer("-", "_", ".", "_").Replace(provider))
return sanitized + "_API_KEY"
}
// renderGitHubWorkflow builds the workflow YAML for the given model and
// provider secret name.
func renderGitHubWorkflow(model, secretName string) string {
return fmt.Sprintf(`name: kit
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
kit:
if: |
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR') &&
(startsWith(github.event.comment.body, '/kit ') ||
github.event.comment.body == '/kit')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: mark3labs/kit@v0
with:
model: %s
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
%s: ${{ secrets.%s }}
`, model, secretName, secretName)
}
// writeGitHubWorkflow writes the generated workflow to githubWorkflowPath,
// creating parent directories as needed. It refuses to overwrite an existing
// file unless force is true.
func writeGitHubWorkflow(model, secretName string, force bool) error {
if _, err := os.Stat(githubWorkflowPath); err == nil && !force {
return fmt.Errorf("%s already exists; re-run with --force to overwrite", githubWorkflowPath)
} else if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("checking %s: %w", githubWorkflowPath, err)
}
if err := os.MkdirAll(filepath.Dir(githubWorkflowPath), 0o755); err != nil {
return fmt.Errorf("creating %s: %w", filepath.Dir(githubWorkflowPath), err)
}
content := renderGitHubWorkflow(model, secretName)
if err := os.WriteFile(githubWorkflowPath, []byte(content), 0o644); err != nil {
return fmt.Errorf("writing %s: %w", githubWorkflowPath, err)
}
return nil
}
// maybeSetProviderSecret offers to set the provider API key as a repository
// secret via the gh CLI when it is available, interactive, the secret value is
// present in the environment, and the user did not pass --no-secret.
func maybeSetProviderSecret(ctx context.Context, secretName string) {
if githubInstallNoSecret || !isInteractive() {
return
}
if _, err := exec.LookPath("gh"); err != nil {
return
}
value := os.Getenv(secretName)
if value == "" {
fmt.Printf("️ %s is not set in your environment; set the repository secret manually with:\n", secretName)
fmt.Printf(" gh secret set %s\n", secretName)
return
}
var confirm bool
if err := huh.NewConfirm().
Title(fmt.Sprintf("Set the %s repository secret via gh?", secretName)).
Description("Uses the value from your current environment.").
Value(&confirm).
Run(); err != nil || !confirm {
return
}
// Feed the secret value via stdin rather than a command-line argument so
// the API key never appears in the process argument list.
cmd := exec.CommandContext(ctx, "gh", "secret", "set", secretName)
cmd.Stdin = strings.NewReader(value)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("⚠️ Failed to set secret via gh: %v\n", err)
fmt.Printf(" Set it manually with: gh secret set %s\n", secretName)
return
}
fmt.Printf("✅ Set repository secret %s\n", secretName)
}
// printGitHubInstallNextSteps prints the manual follow-up actions a user must
// take after the workflow is scaffolded.
func printGitHubInstallNextSteps(secretName string) {
fmt.Println("\nNext steps:")
fmt.Printf(" 1. Commit the workflow: git add %s && git commit -m \"ci: add kit workflow\"\n", githubWorkflowPath)
fmt.Printf(" 2. Set the %s repository secret (Settings → Secrets → Actions), if not already set.\n", secretName)
fmt.Println(" 3. Comment '/kit <your request>' on an issue or pull request to trigger Kit.")
}
-498
View File
@@ -1,498 +0,0 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/charmbracelet/log"
"github.com/spf13/cobra"
)
// commandToken is the mention that triggers Kit from a comment, mirroring the
// `if:` guard in the generated workflow (.github/workflows/kit.yml).
const commandToken = "/kit"
// subprocessTimeout bounds each git/gh invocation so a stalled network call or
// an unexpected auth prompt cannot hang the Actions job indefinitely.
const subprocessTimeout = 30 * time.Second
// agentTimeout bounds the headless agent run so a runaway turn cannot block the
// job forever. GitHub Actions jobs have their own ceiling, but a tighter bound
// keeps feedback fast and costs predictable.
const agentTimeout = 20 * time.Minute
// botName / botEmail are the dedicated identity commits are attributed to, so
// Kit's changes are clearly distinguishable from human authors in history.
const (
botName = "kit-agent[bot]"
botEmail = "kit-agent[bot]@users.noreply.github.com"
)
// writeAssociations are the GitHub author_association values that imply
// write/admin access. Only these may trigger the handler.
var writeAssociations = map[string]bool{
"OWNER": true,
"MEMBER": true,
"COLLABORATOR": true,
}
var (
githubRunModel string
githubRunDryRun bool
)
// githubRunCmd is the runtime half of the GitHub integration. It is invoked by
// the bundled composite action (action.yml) inside a GitHub Actions runner once
// a collaborator comments '/kit <request>' on an issue or pull request. It reads
// the triggering event, enforces permissions, runs the agent headlessly against
// the comment/PR context, and responds by posting a comment and — when the agent
// leaves changes — opening a pull request.
var githubRunCmd = &cobra.Command{
Use: "run",
Short: "Run Kit against the current GitHub Actions event (used by the kit action)",
Long: `Run Kit against the current GitHub Actions event.
This command is normally invoked by the bundled composite action inside a
GitHub Actions runner; you rarely run it by hand. It reads the triggering
event from GITHUB_EVENT_PATH, verifies the commenter has write/admin access,
reacts with an emoji while it works, runs the agent non-interactively against
the issue thread or pull request, posts the response as a comment, and — if the
agent modified files — pushes a kit-agent[bot] branch and opens a pull request.
Set --dry-run (or KIT_GITHUB_DRY_RUN=1) to log every git/gh side effect and
skip the agent run instead of executing them.`,
Args: cobra.NoArgs,
RunE: runGitHubRun,
}
func init() {
githubRunCmd.Flags().StringVarP(&githubRunModel, "model", "m", "", "provider/model the agent should use (falls back to $MODEL, then a default)")
githubRunCmd.Flags().BoolVar(&githubRunDryRun, "dry-run", false, "log git/gh side effects and skip the agent run instead of executing them")
githubCmd.AddCommand(githubRunCmd)
}
// --- GitHub event types ------------------------------------------------------
type ghUser struct {
Login string `json:"login"`
}
type ghComment struct {
ID int64 `json:"id"`
Body string `json:"body"`
AuthorAssociation string `json:"author_association"`
User ghUser `json:"user"`
}
type ghIssue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
PullRequest json.RawMessage `json:"pull_request"`
}
type ghPull struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
}
type ghRepo struct {
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
}
type ghEvent struct {
Action string `json:"action"`
Comment *ghComment `json:"comment"`
Issue *ghIssue `json:"issue"`
PullRequest *ghPull `json:"pull_request"`
Repository ghRepo `json:"repository"`
}
// trigger normalises a single invocation across issue_comment and
// pull_request_review_comment events.
type trigger struct {
repo string
defaultBranch string
number int // issue or PR number
isPR bool // true when the target is a pull request
commentID int64 // triggering comment id (for reactions)
commentKind string // "issues" or "pulls" — reaction API path segment
author string
association string
request string // the user's instruction (comment body minus the token)
title string
body string
}
// runGitHubRun is the entry point wired to `kit github run`.
func runGitHubRun(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
if !inGitHubActions() && !githubDryRun() {
return fmt.Errorf("kit github run is meant to run inside GitHub Actions (set GITHUB_ACTIONS=true or pass --dry-run)")
}
event, err := loadGitHubEvent()
if err != nil {
return err
}
tr, err := buildTrigger(event)
if err != nil {
// Not an actionable trigger (the workflow `if:` normally prevents this).
log.Info("github run: nothing to do", "reason", err)
return nil
}
if !writeAssociations[strings.ToUpper(tr.association)] {
log.Warn("github run: ignoring /kit from unauthorized author",
"author", tr.author, "association", tr.association)
return nil
}
model := resolveRunModel()
log.Info("github run: handling trigger",
"repo", tr.repo, "number", tr.number, "pr", tr.isPR, "author", tr.author, "model", model)
// React with 👀 so the human sees Kit picked up the request.
addReaction(ctx, tr, "eyes")
gathered := gatherContext(ctx, tr)
prompt := buildPrompt(tr, gathered)
response, runErr := runAgent(ctx, model, prompt)
if runErr != nil {
postComment(ctx, tr, "⚠️ Kit hit an error while processing this request:\n\n```\n"+runErr.Error()+"\n```")
addReaction(ctx, tr, "confused")
return runErr
}
response = strings.TrimSpace(response)
if response == "" {
response = "Kit finished without a textual response."
}
prURL := ""
if hasUncommittedChanges(ctx) {
prURL = openPullRequest(ctx, tr, response)
}
comment := response
if prURL != "" {
comment += "\n\n---\nOpened a pull request with the changes: " + prURL
}
postComment(ctx, tr, comment)
addReaction(ctx, tr, "rocket")
return nil
}
// resolveRunModel picks the model: --model flag, then $MODEL, then the default.
func resolveRunModel() string {
if m := strings.TrimSpace(githubRunModel); m != "" {
return m
}
if m := strings.TrimSpace(os.Getenv("MODEL")); m != "" {
return m
}
return defaultGitHubModel
}
func inGitHubActions() bool {
return os.Getenv("GITHUB_ACTIONS") == "true"
}
// githubDryRun reports whether side effects should be logged instead of run.
func githubDryRun() bool {
return githubRunDryRun || os.Getenv("KIT_GITHUB_DRY_RUN") != ""
}
// loadGitHubEvent reads and decodes the GitHub Actions event payload.
func loadGitHubEvent() (*ghEvent, error) {
path := os.Getenv("GITHUB_EVENT_PATH")
if path == "" {
return nil, fmt.Errorf("GITHUB_EVENT_PATH is not set")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading event payload: %w", err)
}
var event ghEvent
if err := json.Unmarshal(data, &event); err != nil {
return nil, fmt.Errorf("parsing event payload: %w", err)
}
return &event, nil
}
// buildTrigger normalises an event into a trigger, or returns an error when the
// event is not an actionable `/kit` comment.
func buildTrigger(event *ghEvent) (*trigger, error) {
if event.Comment == nil {
return nil, fmt.Errorf("event has no comment; nothing to do")
}
request, ok := extractRequest(event.Comment.Body)
if !ok {
return nil, fmt.Errorf("comment does not contain the %q command", commandToken)
}
tr := &trigger{
repo: event.Repository.FullName,
defaultBranch: event.Repository.DefaultBranch,
commentID: event.Comment.ID,
author: event.Comment.User.Login,
association: event.Comment.AuthorAssociation,
request: request,
}
if tr.defaultBranch == "" {
tr.defaultBranch = "main"
}
switch {
case event.Issue != nil:
tr.number = event.Issue.Number
tr.title = event.Issue.Title
tr.body = event.Issue.Body
tr.isPR = len(event.Issue.PullRequest) > 0
tr.commentKind = "issues"
case event.PullRequest != nil:
tr.number = event.PullRequest.Number
tr.title = event.PullRequest.Title
tr.body = event.PullRequest.Body
tr.isPR = true
tr.commentKind = "pulls"
default:
return nil, fmt.Errorf("event has no issue or pull_request target")
}
if tr.repo == "" {
return nil, fmt.Errorf("event is missing repository.full_name")
}
return tr, nil
}
// extractRequest pulls the instruction text out of a comment body that mentions
// the command token. It only recognizes the token at the start of a line
// (mirroring the workflow guard) or at the very end, so incidental mid-sentence
// mentions like "please review /kit behavior" do not trigger the handler. It
// returns the remainder of the matching line as the request.
func extractRequest(body string) (string, bool) {
for line := range strings.SplitSeq(body, "\n") {
trimmed := strings.TrimSpace(line)
var rest string
switch {
case trimmed == commandToken:
return "", true
case strings.HasPrefix(trimmed, commandToken+" "):
rest = trimmed[len(commandToken):]
case strings.HasSuffix(trimmed, " "+commandToken):
return "", true
default:
continue
}
return strings.TrimSpace(rest), true
}
return "", false
}
// gatherContext assembles the issue thread or PR diff to give the agent. It
// always includes the title/body from the event payload, and — outside dry-run,
// when `gh` is available — enriches with the comment thread and PR diff.
func gatherContext(ctx context.Context, tr *trigger) string {
var b strings.Builder
target := "Issue"
if tr.isPR {
target = "Pull request"
}
fmt.Fprintf(&b, "%s #%d: %s\n", target, tr.number, tr.title)
if strings.TrimSpace(tr.body) != "" {
fmt.Fprintf(&b, "\n%s\n", strings.TrimSpace(tr.body))
}
if githubDryRun() || !commandExists("gh") {
return b.String()
}
num := fmt.Sprint(tr.number)
if tr.isPR {
if diff := ghOutput(ctx, "pr", "diff", num, "--repo", tr.repo); diff != "" {
fmt.Fprintf(&b, "\n## Diff\n```diff\n%s\n```\n", strings.TrimSpace(diff))
}
if comments := ghOutput(ctx, "pr", "view", num, "--repo", tr.repo, "--json", "comments", "--jq", ".comments[] | \"@\\(.author.login): \\(.body)\""); comments != "" {
fmt.Fprintf(&b, "\n## Comments\n%s\n", strings.TrimSpace(comments))
}
} else {
if comments := ghOutput(ctx, "issue", "view", num, "--repo", tr.repo, "--json", "comments", "--jq", ".comments[] | \"@\\(.author.login): \\(.body)\""); comments != "" {
fmt.Fprintf(&b, "\n## Comments\n%s\n", strings.TrimSpace(comments))
}
}
return b.String()
}
// buildPrompt constructs the instruction sent to the agent.
func buildPrompt(tr *trigger, gathered string) string {
target := "issue"
if tr.isPR {
target = "pull request"
}
request := tr.request
if request == "" {
request = "(no explicit instruction — review the " + target + " and respond helpfully)"
}
var b strings.Builder
fmt.Fprintf(&b, "You are Kit, operating as an automated collaborator on the GitHub repository %s.\n\n", tr.repo)
fmt.Fprintf(&b, "@%s (access: %s) triggered you on %s #%d with this request:\n\n", tr.author, tr.association, target, tr.number)
fmt.Fprintf(&b, "%s\n\n", request)
fmt.Fprintf(&b, "## Context\n%s\n\n", strings.TrimSpace(gathered))
b.WriteString("Carry out the request. If you modify files, they will be committed to a new ")
b.WriteString("branch and a pull request will be opened automatically, so you do not need to ")
b.WriteString("commit or push yourself. Finish with a concise summary of what you did.")
return b.String()
}
// runAgent drives the agent headlessly by invoking this same binary in quiet,
// ephemeral mode against the constructed prompt, and returns its response. In
// dry-run it returns a canned response without spawning anything.
func runAgent(ctx context.Context, model, prompt string) (string, error) {
if githubDryRun() {
log.Info("github run: [dry-run] would run agent", "model", model, "promptChars", len(prompt))
return "[dry-run] agent response", nil
}
exe, err := os.Executable()
if err != nil || exe == "" {
exe = "kit"
}
runCtx, cancel := context.WithTimeout(ctx, agentTimeout)
defer cancel()
args := []string{"--quiet", "--no-session", "--no-extensions"}
if model != "" {
args = append(args, "--model", model)
}
args = append(args, prompt)
cmd := exec.CommandContext(runCtx, exe, args...)
cmd.Stderr = os.Stderr // surface agent progress/errors in the Actions log
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("agent run failed: %w", err)
}
return string(out), nil
}
// hasUncommittedChanges reports whether the agent produced working-tree changes.
func hasUncommittedChanges(ctx context.Context) bool {
if githubDryRun() {
return os.Getenv("KIT_GITHUB_FAKE_DIRTY") != ""
}
return strings.TrimSpace(gitOutput(ctx, "status", "--porcelain")) != ""
}
// openPullRequest commits the working tree as kit-agent[bot], pushes a branch,
// and opens a PR. It returns the PR URL, or "" on failure / dry-run.
func openPullRequest(ctx context.Context, tr *trigger, summary string) string {
branch := fmt.Sprintf("kit/issue-%d-%d", tr.number, time.Now().Unix())
runGit(ctx, "checkout", "-b", branch)
runGit(ctx, "add", "-A")
runGit(ctx, "-c", "user.name="+botName, "-c", "user.email="+botEmail,
"commit", "-m", fmt.Sprintf("kit: address #%d", tr.number))
// `persist-credentials: false` in the workflow means the checkout left no
// push credentials behind. Re-establish them from GITHUB_TOKEN via gh's git
// credential helper, then push over the existing origin remote.
if !githubDryRun() {
runCmd(ctx, "gh", "auth", "setup-git")
}
runGit(ctx, "push", "origin", "HEAD:"+branch)
title := fmt.Sprintf("kit: changes for #%d", tr.number)
body := fmt.Sprintf("Automated changes from Kit in response to #%d.\n\n%s", tr.number, summary)
if githubDryRun() {
log.Info("github run: [dry-run] would open PR", "branch", branch, "base", tr.defaultBranch)
return ""
}
return strings.TrimSpace(ghOutput(ctx, "pr", "create", "--repo", tr.repo,
"--head", branch, "--base", tr.defaultBranch, "--title", title, "--body", body))
}
// addReaction adds an emoji reaction to the trigger comment.
func addReaction(ctx context.Context, tr *trigger, content string) {
path := fmt.Sprintf("/repos/%s/%s/comments/%d/reactions", tr.repo, tr.commentKind, tr.commentID)
if githubDryRun() || !commandExists("gh") {
log.Info("github run: [dry-run] react", "content", content, "path", path)
return
}
runCmd(ctx, "gh", "api", "-X", "POST", path, "-f", "content="+content)
}
// postComment posts a comment back on the triggering issue or pull request.
func postComment(ctx context.Context, tr *trigger, body string) {
sub := "issue"
if tr.isPR {
sub = "pr"
}
if githubDryRun() || !commandExists("gh") {
log.Info("github run: [dry-run] comment", "sub", sub, "number", tr.number, "chars", len(body))
return
}
runCmd(ctx, "gh", sub, "comment", fmt.Sprint(tr.number), "--repo", tr.repo, "--body", body)
}
// --- thin subprocess helpers -------------------------------------------------
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
// runGit runs a mutating git command, logging instead of executing in dry-run.
func runGit(ctx context.Context, args ...string) {
if githubDryRun() {
log.Info("github run: [dry-run] git", "args", strings.Join(args, " "))
return
}
runCmd(ctx, "git", args...)
}
// gitOutput runs a read-only git command and returns its stdout.
func gitOutput(ctx context.Context, args ...string) string {
cmdCtx, cancel := context.WithTimeout(ctx, subprocessTimeout)
defer cancel()
out, err := exec.CommandContext(cmdCtx, "git", args...).Output()
if err != nil {
log.Error("github run: git failed", "args", strings.Join(args, " "), "err", err)
return ""
}
return string(out)
}
// ghOutput runs a gh command and returns its stdout.
func ghOutput(ctx context.Context, args ...string) string {
cmdCtx, cancel := context.WithTimeout(ctx, subprocessTimeout)
defer cancel()
out, err := exec.CommandContext(cmdCtx, "gh", args...).Output()
if err != nil {
log.Error("github run: gh failed", "args", strings.Join(args, " "), "err", err)
return ""
}
return string(out)
}
// runCmd runs a command for its side effects, surfacing failures in the log.
func runCmd(ctx context.Context, name string, args ...string) {
cmdCtx, cancel := context.WithTimeout(ctx, subprocessTimeout)
defer cancel()
if out, err := exec.CommandContext(cmdCtx, name, args...).CombinedOutput(); err != nil {
log.Error("github run: command failed", "cmd", name, "err", err, "output", strings.TrimSpace(string(out)))
}
}
-190
View File
@@ -1,190 +0,0 @@
package cmd
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// setupEvent writes a GitHub event payload to a temp file, points
// GITHUB_EVENT_PATH at it, and forces dry-run + Actions mode. It also resets
// the run command's package-level flag state so tests are independent.
func setupEvent(t *testing.T, payload string) {
t.Helper()
path := filepath.Join(t.TempDir(), "event.json")
if err := os.WriteFile(path, []byte(payload), 0o644); err != nil {
t.Fatalf("write event: %v", err)
}
t.Setenv("GITHUB_ACTIONS", "true")
t.Setenv("KIT_GITHUB_DRY_RUN", "1")
t.Setenv("GITHUB_EVENT_PATH", path)
t.Cleanup(func() {
githubRunModel = ""
githubRunDryRun = false
})
}
const issueCommentEvent = `{
"action": "created",
"comment": {
"id": 555,
"body": "/kit fix the broken parser",
"author_association": "OWNER",
"user": {"login": "alice"}
},
"issue": {"number": 42, "title": "Parser crashes on empty input", "body": "It panics."},
"repository": {"full_name": "acme/widgets", "default_branch": "main"}
}`
func TestExtractRequest(t *testing.T) {
tests := []struct {
name string
body string
want string
wantHit bool
}{
{"start with request", "/kit fix the bug", "fix the bug", true},
{"bare token", "/kit", "", true},
{"trailing token", "hey /kit", "", true},
{"mid-sentence ignored", "please review /kit behavior in the docs", "", false},
{"no token", "just a normal comment", "", false},
{"token in second line", "thanks!\n/kit add tests", "add tests", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, hit := extractRequest(tt.body)
if hit != tt.wantHit || got != tt.want {
t.Errorf("extractRequest(%q) = (%q, %v), want (%q, %v)", tt.body, got, hit, tt.want, tt.wantHit)
}
})
}
}
func TestBuildTrigger_IssueComment(t *testing.T) {
event, err := func() (*ghEvent, error) {
setupEvent(t, issueCommentEvent)
return loadGitHubEvent()
}()
if err != nil {
t.Fatalf("loadGitHubEvent: %v", err)
}
tr, err := buildTrigger(event)
if err != nil {
t.Fatalf("buildTrigger: %v", err)
}
if tr.repo != "acme/widgets" || tr.number != 42 || tr.isPR || tr.request != "fix the broken parser" {
t.Errorf("unexpected trigger: %+v", tr)
}
if tr.commentKind != "issues" {
t.Errorf("commentKind = %q, want issues", tr.commentKind)
}
}
func TestBuildPrompt_ContainsContext(t *testing.T) {
setupEvent(t, issueCommentEvent)
event, _ := loadGitHubEvent()
tr, _ := buildTrigger(event)
prompt := buildPrompt(tr, gatherContext(context.Background(), tr))
for _, want := range []string{
"fix the broken parser", // the request
"acme/widgets", // the repo
"issue #42", // the target
"@alice", // the author
"Parser crashes on empty input", // context: title
"It panics.", // context: body
} {
if !strings.Contains(prompt, want) {
t.Errorf("prompt missing %q\n---\n%s", want, prompt)
}
}
}
func TestRunGitHub_AuthorizedIssueComment(t *testing.T) {
setupEvent(t, issueCommentEvent)
if err := runGitHubRun(githubRunCmd, nil); err != nil {
t.Fatalf("runGitHubRun: %v", err)
}
}
func TestRunGitHub_UnauthorizedAssociation(t *testing.T) {
setupEvent(t, strings.Replace(issueCommentEvent, `"OWNER"`, `"NONE"`, 1))
// Should return nil (no-op) without attempting the agent run.
if err := runGitHubRun(githubRunCmd, nil); err != nil {
t.Fatalf("runGitHubRun should be a no-op for unauthorized authors, got: %v", err)
}
}
func TestRunGitHub_CommentWithoutToken(t *testing.T) {
setupEvent(t, strings.Replace(issueCommentEvent,
`"/kit fix the broken parser"`, `"just a normal comment"`, 1))
if err := runGitHubRun(githubRunCmd, nil); err != nil {
t.Fatalf("runGitHubRun should be a no-op without /kit, got: %v", err)
}
}
func TestRunGitHub_MidSentenceMentionIgnored(t *testing.T) {
setupEvent(t, strings.Replace(issueCommentEvent,
`"/kit fix the broken parser"`, `"please review /kit behavior in the docs"`, 1))
if err := runGitHubRun(githubRunCmd, nil); err != nil {
t.Fatalf("runGitHubRun should ignore mid-sentence mentions, got: %v", err)
}
}
func TestRunGitHub_PullRequestReviewComment(t *testing.T) {
setupEvent(t, `{
"action": "created",
"comment": {
"id": 999,
"body": "/kit review this change",
"author_association": "COLLABORATOR",
"user": {"login": "bob"}
},
"pull_request": {"number": 7, "title": "Add caching", "body": "Speeds things up."},
"repository": {"full_name": "acme/widgets", "default_branch": "main"}
}`)
event, _ := loadGitHubEvent()
tr, err := buildTrigger(event)
if err != nil {
t.Fatalf("buildTrigger: %v", err)
}
if !tr.isPR || tr.number != 7 || tr.commentKind != "pulls" {
t.Errorf("unexpected PR trigger: %+v", tr)
}
if err := runGitHubRun(githubRunCmd, nil); err != nil {
t.Fatalf("runGitHubRun (PR): %v", err)
}
}
func TestRunGitHub_RequiresActionsOrDryRun(t *testing.T) {
// Neither GITHUB_ACTIONS nor dry-run set → must error rather than act.
t.Setenv("GITHUB_ACTIONS", "")
t.Setenv("KIT_GITHUB_DRY_RUN", "")
githubRunDryRun = false
t.Cleanup(func() { githubRunDryRun = false })
if err := runGitHubRun(githubRunCmd, nil); err == nil {
t.Fatal("expected an error when run outside Actions without --dry-run")
}
}
func TestResolveRunModel(t *testing.T) {
t.Cleanup(func() { githubRunModel = "" })
t.Setenv("MODEL", "")
githubRunModel = ""
if got := resolveRunModel(); got != defaultGitHubModel {
t.Errorf("default model = %q, want %q", got, defaultGitHubModel)
}
t.Setenv("MODEL", "openai/gpt-5")
if got := resolveRunModel(); got != "openai/gpt-5" {
t.Errorf("MODEL env model = %q, want openai/gpt-5", got)
}
githubRunModel = "anthropic/claude-sonnet-4-5"
if got := resolveRunModel(); got != "anthropic/claude-sonnet-4-5" {
t.Errorf("flag model = %q, want anthropic/claude-sonnet-4-5", got)
}
}
-102
View File
@@ -1,102 +0,0 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestProviderSecretEnvVar(t *testing.T) {
tests := []struct {
provider string
want string
}{
{"anthropic", "ANTHROPIC_API_KEY"},
{"openai", "OPENAI_API_KEY"},
// Unknown provider falls back to "<PROVIDER>_API_KEY" with sanitization.
{"my-custom.provider", "MY_CUSTOM_PROVIDER_API_KEY"},
}
for _, tt := range tests {
t.Run(tt.provider, func(t *testing.T) {
got := providerSecretEnvVar(tt.provider)
if got != tt.want {
t.Errorf("providerSecretEnvVar(%q) = %q, want %q", tt.provider, got, tt.want)
}
})
}
}
func TestRenderGitHubWorkflow(t *testing.T) {
out := renderGitHubWorkflow("anthropic/claude-sonnet-4-5-20250929", "ANTHROPIC_API_KEY")
wantSubstrings := []string{
"name: kit",
"issue_comment:",
"pull_request_review_comment:",
"startsWith(github.event.comment.body, '/kit ')",
"github.event.comment.body == '/kit'",
"github.event.comment.author_association == 'OWNER'",
"github.event.comment.author_association == 'COLLABORATOR'",
"persist-credentials: false",
"uses: mark3labs/kit@v0",
"model: anthropic/claude-sonnet-4-5-20250929",
"GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}",
"ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}",
"contents: write",
"pull-requests: write",
"issues: write",
}
for _, want := range wantSubstrings {
if !strings.Contains(out, want) {
t.Errorf("rendered workflow missing %q\n---\n%s", want, out)
}
}
}
func TestWriteGitHubWorkflow(t *testing.T) {
dir := t.TempDir()
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
// First write succeeds and creates nested directories.
if err := writeGitHubWorkflow("anthropic/claude-sonnet-4-5", "ANTHROPIC_API_KEY", false); err != nil {
t.Fatalf("writeGitHubWorkflow: %v", err)
}
data, err := os.ReadFile(githubWorkflowPath)
if err != nil {
t.Fatalf("reading workflow: %v", err)
}
if !strings.Contains(string(data), "model: anthropic/claude-sonnet-4-5") {
t.Errorf("workflow missing model line:\n%s", data)
}
// Second write without force must refuse to clobber.
if err := writeGitHubWorkflow("anthropic/claude-sonnet-4-5", "ANTHROPIC_API_KEY", false); err == nil {
t.Error("expected error when overwriting without --force, got nil")
}
// With force it overwrites.
if err := writeGitHubWorkflow("openai/gpt-5", "OPENAI_API_KEY", true); err != nil {
t.Fatalf("writeGitHubWorkflow with force: %v", err)
}
data, err = os.ReadFile(githubWorkflowPath)
if err != nil {
t.Fatalf("reading workflow: %v", err)
}
if !strings.Contains(string(data), "OPENAI_API_KEY") {
t.Errorf("forced overwrite did not update content:\n%s", data)
}
// Sanity: the file lives at the expected nested path.
if _, err := os.Stat(filepath.Join(dir, githubWorkflowPath)); err != nil {
t.Errorf("workflow not at expected path: %v", err)
}
}
-19
View File
@@ -73,11 +73,6 @@ var (
noCoreToolsFlag bool
extensionPaths []string
// Skills control
noSkillsFlag bool
skillsPaths []string
skillsDir string
// TLS configuration
tlsSkipVerify bool
@@ -288,14 +283,6 @@ func init() {
rootCmd.PersistentFlags().
StringSliceVarP(&extensionPaths, "extension", "e", nil, "load additional extension file(s)")
// Skills flags
rootCmd.PersistentFlags().
BoolVar(&noSkillsFlag, "no-skills", false, "disable skill loading (auto-discovery and explicit)")
rootCmd.PersistentFlags().
StringSliceVar(&skillsPaths, "skill", nil, "load skill file or directory (repeatable)")
rootCmd.PersistentFlags().
StringVar(&skillsDir, "skills-dir", "", "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)")
@@ -346,9 +333,6 @@ func init() {
_ = viper.BindPFlag("extension", rootCmd.PersistentFlags().Lookup("extension"))
_ = viper.BindPFlag("prompt-template", rootCmd.PersistentFlags().Lookup("prompt-template"))
_ = viper.BindPFlag("no-prompt-templates", rootCmd.PersistentFlags().Lookup("no-prompt-templates"))
_ = viper.BindPFlag("no-skills", rootCmd.PersistentFlags().Lookup("no-skills"))
_ = viper.BindPFlag("skill", rootCmd.PersistentFlags().Lookup("skill"))
_ = viper.BindPFlag("skills-dir", rootCmd.PersistentFlags().Lookup("skills-dir"))
// Defaults are already set in flag definitions, no need to duplicate in viper
@@ -836,9 +820,6 @@ 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.
-6
View File
@@ -493,12 +493,6 @@ mcpServers:
# maxTokens: 16384
# systemPrompt: "You are a deep reasoning assistant." # or a file path
# Skills configuration (all optional)
# no-skills: false # Set to true to disable all skill loading
# skill: # Explicit skill files/dirs (disables auto-discovery)
# - "/path/to/skill.md"
# skills-dir: "/path/to/skills" # 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,9 +205,6 @@ func TestEnsureConfigExists(t *testing.T) {
"type: \"local\"",
"type: \"remote\"",
"Core tools",
"# Skills configuration",
"no-skills:",
"skills-dir:",
}
for _, expected := range expectedSections {
+2 -6
View File
@@ -372,12 +372,8 @@ func loadSingleExtension(path string) (*LoadedExtension, error) {
Handlers: make(map[EventType][]HandlerFunc),
}
// Create a fresh interpreter. Yaegi runs extensions in restricted mode,
// where os.Getenv/os.LookupEnv/os.Environ read from a virtualized
// environment rather than the real one. Seed it with the process
// environment so extensions can read variables (e.g. CI-provided ones
// like GITHUB_EVENT_PATH) without being able to mutate the host's env.
i := interp.New(interp.Options{Env: os.Environ()})
// Create a fresh interpreter.
i := interp.New(interp.Options{})
// Expose the Go stdlib. The base set covers most packages; the
// unrestricted set adds os/exec so extensions can spawn processes.
File diff suppressed because one or more lines are too long
+2 -4
View File
@@ -91,10 +91,8 @@ func (h *Harness) LoadString(src string, path string) *extensions.LoadedExtensio
func (h *Harness) loadSource(src string, path string) *extensions.LoadedExtension {
h.t.Helper()
// Create a fresh interpreter. Seed the virtualized environment with the
// process environment so extensions can read env vars via os.Getenv,
// mirroring the production loader (see internal/extensions/loader.go).
i := interp.New(interp.Options{Env: os.Environ()})
// Create a fresh interpreter
i := interp.New(interp.Options{})
// Expose Go stdlib
if err := i.Use(stdlib.Symbols); err != nil {
-6
View File
@@ -20,9 +20,3 @@ func (m *Kit) ConfigFloatForTest(key string) float64 { return m.v.GetFloat64(key
// ConfigBoolForTest returns the bool value of key from this Kit's isolated
// configuration store.
func (m *Kit) ConfigBoolForTest(key string) bool { return m.v.GetBool(key) }
// ConfigStringSliceForTest returns the string slice value of key from this
// Kit's isolated configuration store.
func (m *Kit) ConfigStringSliceForTest(key string) []string {
return m.v.GetStringSlice(key)
}
+2 -18
View File
@@ -1330,25 +1330,9 @@ func New(ctx context.Context, opts *Options) (*Kit, error) {
}
// Load skills — either from explicit paths or via auto-discovery.
// Merge viper config with opts: CLI flag / config file values are
// already bound to viper by cmd/root.go, so v.GetBool("no-skills"),
// v.GetStringSlice("skill"), and v.GetString("skills-dir") capture
// both --flag and .kit.yml keys transparently.
noSkills := opts.NoSkills || v.GetBool("no-skills")
skillPaths := opts.Skills
if len(skillPaths) == 0 {
skillPaths = v.GetStringSlice("skill")
}
skillsDir := opts.SkillsDir
if skillsDir == "" {
skillsDir = v.GetString("skills-dir")
}
if !noSkills {
mergedOpts := *opts
mergedOpts.Skills = skillPaths
mergedOpts.SkillsDir = skillsDir
if !opts.NoSkills {
var err error
loadedSkills, err = loadSkills(&mergedOpts)
loadedSkills, err = loadSkills(opts)
if err != nil {
return fmt.Errorf("failed to load skills: %w", err)
}
-75
View File
@@ -365,81 +365,6 @@ func TestNewSystemPromptFilePath(t *testing.T) {
}
}
// TestNewWithSkillsOptions verifies that the three skills-related Options
// fields (NoSkills, Skills, SkillsDir) are wired correctly into kit.New().
func TestNewWithSkillsOptions(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
ctx := context.Background()
t.Run("NoSkills disables skill loading", func(t *testing.T) {
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
NoSkills: true,
})
if err != nil {
t.Fatalf("kit.New failed: %v", err)
}
defer func() { _ = host.Close() }()
if got := host.GetSkills(); len(got) != 0 {
t.Errorf("NoSkills=true: expected 0 skills, got %d", len(got))
}
})
t.Run("SkillsDir propagates", func(t *testing.T) {
// Use a non-existent dir — no skills will load but the option must be
// accepted without error and result in zero skills.
dir := t.TempDir()
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
SkillsDir: dir,
})
if err != nil {
t.Fatalf("kit.New failed: %v", err)
}
defer func() { _ = host.Close() }()
// Empty dir → no skills; the important thing is no error.
_ = host.GetSkills()
})
t.Run("explicit Skills paths load correctly", func(t *testing.T) {
// Write a minimal skill file to a temp dir.
dir := t.TempDir()
skillFile := dir + "/my-skill.md"
content := "---\nname: test-skill\ndescription: A test skill\n---\nDo the thing.\n"
if err := os.WriteFile(skillFile, []byte(content), 0o644); err != nil {
t.Fatalf("failed to write skill file: %v", err)
}
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
Skills: []string{skillFile},
})
if err != nil {
t.Fatalf("kit.New failed: %v", err)
}
defer func() { _ = host.Close() }()
skills := host.GetSkills()
if len(skills) != 1 {
t.Fatalf("expected 1 skill, got %d", len(skills))
}
if skills[0].Name != "test-skill" {
t.Errorf("skill name = %q; want %q", skills[0].Name, "test-skill")
}
})
}
// TestNewSystemPromptInline confirms that inline system-prompt strings still
// flow through unchanged after the file-path resolution change.
func TestNewSystemPromptInline(t *testing.T) {
-125
View File
@@ -205,131 +205,6 @@ func TestNewZeroOptionsKeepsStreamingDefault(t *testing.T) {
}
}
// TestSkillsViperKeys verifies that the three skills config keys (no-skills,
// skill, skills-dir) flow through viper when set via a config file, matching
// the pattern used by no-extensions and no-core-tools. This test does not
// require an API key because it only exercises Options struct plumbing.
func TestSkillsViperKeys(t *testing.T) {
t.Run("NoSkills option disables skill loading", func(t *testing.T) {
o := &kit.Options{}
o.NoSkills = true
if !o.NoSkills {
t.Error("Options.NoSkills = true not reflected on struct")
}
})
t.Run("Skills paths set on Options", func(t *testing.T) {
o := &kit.Options{
Skills: []string{"/a/skill.md", "/b/skill.md"},
}
if len(o.Skills) != 2 {
t.Errorf("Options.Skills: got %d paths, want 2", len(o.Skills))
}
if o.Skills[0] != "/a/skill.md" {
t.Errorf("Options.Skills[0] = %q; want %q", o.Skills[0], "/a/skill.md")
}
})
t.Run("SkillsDir set on Options", func(t *testing.T) {
o := &kit.Options{
SkillsDir: "/custom/skills",
}
if o.SkillsDir != "/custom/skills" {
t.Errorf("Options.SkillsDir = %q; want %q", o.SkillsDir, "/custom/skills")
}
})
}
// TestSkillsConfigFileKeys verifies that no-skills, skill, and skills-dir
// config file keys are read via viper and applied correctly. Requires an API
// key because kit.New() is called to exercise the full config-load path.
func TestSkillsConfigFileKeys(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("Skipping test: ANTHROPIC_API_KEY not set")
}
ctx := context.Background()
t.Run("no-skills config key disables skill loading", func(t *testing.T) {
// Write a config file with no-skills: true.
cfgFile := t.TempDir() + "/.kit.yml"
if err := os.WriteFile(cfgFile, []byte("no-skills: true\n"), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
ConfigFile: cfgFile,
})
if err != nil {
t.Fatalf("kit.New failed: %v", err)
}
defer func() { _ = host.Close() }()
if got := host.GetSkills(); len(got) != 0 {
t.Errorf("no-skills:true in config: expected 0 skills, got %d", len(got))
}
})
t.Run("skill config key loads explicit skill files", func(t *testing.T) {
dir := t.TempDir()
skillFile := dir + "/cfg-skill.md"
if err := os.WriteFile(skillFile, []byte("---\nname: cfg-skill\ndescription: from config\n---\nContent.\n"), 0o644); err != nil {
t.Fatalf("failed to write skill file: %v", err)
}
cfgContent := "skill:\n - " + skillFile + "\n"
cfgFile := dir + "/.kit.yml"
if err := os.WriteFile(cfgFile, []byte(cfgContent), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
ConfigFile: cfgFile,
})
if err != nil {
t.Fatalf("kit.New failed: %v", err)
}
defer func() { _ = host.Close() }()
skills := host.GetSkills()
if len(skills) != 1 {
t.Fatalf("expected 1 skill from config, got %d", len(skills))
}
if skills[0].Name != "cfg-skill" {
t.Errorf("skill name = %q; want %q", skills[0].Name, "cfg-skill")
}
})
t.Run("skills-dir config key overrides auto-discovery root", func(t *testing.T) {
dir := t.TempDir()
cfgContent := "skills-dir: " + dir + "\n"
cfgFile := dir + "/.kit.yml"
if err := os.WriteFile(cfgFile, []byte(cfgContent), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
host, err := kit.New(ctx, &kit.Options{
Model: "anthropic/claude-sonnet-4-5-20250929",
Quiet: true,
NoSession: true,
ConfigFile: cfgFile,
})
if err != nil {
t.Fatalf("kit.New failed: %v", err)
}
defer func() { _ = host.Close() }()
// Empty dir → 0 skills; the key point is no error during init.
_ = host.GetSkills()
})
}
// TestNewStreamingExplicitOptOut verifies that a raw Options can still disable
// streaming by setting Streaming to a pointer to false.
func TestNewStreamingExplicitOptOut(t *testing.T) {
-51
View File
@@ -56,57 +56,6 @@ kit install --all # Install all extensions without prompting
kit skill # Install the Kit extensions skill via skills.sh
```
### Skills CLI flags
Control which skills are loaded at startup:
```bash
# Load a specific skill file
kit --skill path/to/skill.md "prompt"
# Load multiple skill files or directories (flag is repeatable)
kit --skill ./skill1.md --skill ./skill2.md "prompt"
# 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,14 +48,6 @@ These flags control Kit's behavior. When a prompt is passed as a positional argu
| `--prompt-template` | — | — | Load a specific prompt template by name |
| `--no-prompt-templates` | — | `false` | Disable prompt template loading |
## Skills
| Flag | Short | Default | Description |
|------|-------|---------|-------------|
| `--skill` | — | — | Load skill file or directory (repeatable) |
| `--skills-dir` | — | — | 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 |
-3
View File
@@ -47,9 +47,6 @@ stream: true
| `theme` | object or string | — | UI theme ([inline overrides or file path](/themes)) |
| `prompt-templates` | bool | `true` | Enable prompt template loading |
| `prompt-template` | string | — | Specific template to load by name |
| `no-skills` | bool | `false` | Disable skill loading (auto-discovery and explicit) |
| `skill` | list | — | Explicit skill files or directories to load (disables auto-discovery) |
| `skills-dir` | string | — | Override the project-local directory used for skill auto-discovery |
## Environment variables
-31
View File
@@ -117,34 +117,3 @@ func Init(api ext.API) {
})
}
```
### Standard library access
Extensions can import the full Go standard library, plus `os/exec` for spawning
subprocesses. Environment variables are also readable: `os.Getenv`,
`os.LookupEnv`, and `os.Environ` return Kit's process environment, so extensions
can pick up CI-provided variables (for example `GITHUB_EVENT_PATH` or a provider
API key) and any vars the user exported before launching Kit.
```go
package main
import (
"os"
"kit/ext"
)
func Init(api ext.API) {
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
if eventPath := os.Getenv("GITHUB_EVENT_PATH"); eventPath != "" {
ctx.PrintInfo("Running in GitHub Actions: " + eventPath)
}
})
}
```
Environment access is read-only from the host's perspective: the environment is
snapshotted when the extension loads, and calls to `os.Setenv` mutate only the
extension's sandboxed copy — they never change Kit's process environment or the
host. This keeps extensions from leaking state into Kit or other extensions
while still letting them read the configuration they need.
-19
View File
@@ -392,25 +392,6 @@ harness2.LoadFile("ext2.go")
// Events to one don't affect the other
```
### Testing extensions that read environment variables
The harness seeds the interpreter with the process environment, mirroring the
production loader, so an extension's `os.Getenv` / `os.LookupEnv` / `os.Environ`
calls work in tests. Set test-specific variables with `t.Setenv` **before**
loading the extension, since the environment is snapshotted at load time:
```go
func TestReadsEnv(t *testing.T) {
t.Setenv("MY_API_KEY", "test-value")
harness := test.New(t)
harness.LoadFile("my-ext.go") // snapshots the env, incl. MY_API_KEY
harness.Emit(extensions.SessionStartEvent{SessionID: "s1"})
// assert on behavior that depends on MY_API_KEY
}
```
### Running Tests
Run all tests in your extension directory:
-1
View File
@@ -20,7 +20,6 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
- **Interactive TUI** — Rich terminal interface powered by Bubble Tea with streaming, syntax highlighting, and custom rendering
- **Session Management** — Tree-based conversation history with branching support
- **Non-Interactive Mode** — Script-friendly positional args with JSON output
- **GitHub Integration** — Scaffold a GitHub Actions workflow with `kit github install` to run Kit as a collaborator/reviewer on `/kit` comments
- **ACP Server** — Run Kit as an [Agent Client Protocol](https://agentclientprotocol.com) agent over stdio
- **Go SDK** — Embed Kit in your own applications