Compare commits

...

5 Commits

Author SHA1 Message Date
Ed Zynda 0575424789 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:26:06 +03:00
Ed Zynda 0cc3cc0bc2 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
2026-06-16 00:08:31 +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
24 changed files with 1568 additions and 7 deletions
+57
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,45 @@ 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 runtime that reads the event context, enforces permissions, drives the agent,
and posts the response back is the
[`github-handler`](examples/extensions/github-handler) example extension.
| 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.
+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-action@v1
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.")
}
+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-action@v1",
"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,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
@@ -820,6 +836,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.
+10
View File
@@ -83,6 +83,7 @@ kit install github.com/mark3labs/kit/examples/extensions --local
| Extension | Description | Key API |
|-----------|-------------|---------|
| `kit-telegram/` | Telegram relay for remote monitoring & control | `RegisterCommand`, `OnAgentStart/End`, `SetStatus`, `SendMessage` |
| `github-handler/` | Run Kit as a GitHub collaborator inside Actions (`/kit` comments → reviews & PRs) | `OnSessionStart`, `OnAgentEnd`, `SendMessage`, `RegisterOption` |
### Themes
@@ -146,6 +147,15 @@ Full-featured Telegram integration:
- Status bar and widget updates
- Config persistence with atomic writes
### github-handler/
Run Kit as a GitHub collaborator inside GitHub Actions:
- Parses the triggering event from `GITHUB_EVENT_PATH`
- Permission gate on `author_association` (write/admin only)
- 👀 reaction lifecycle on the trigger comment
- Issue-thread / PR-diff context extraction via `gh`
- Drives the agent, posts the response, and opens a PR for any changes
- `KIT_GITHUB_DRY_RUN` mode for safe, deterministic testing
## Multi-File Extension Example
The `kit-kit-agents/` directory demonstrates the multi-file pattern:
@@ -0,0 +1,75 @@
# GitHub Handler Extension
The GitHub handler is the runtime half of Kit's GitHub integration (issue
[#60](https://github.com/mark3labs/kit/issues/60), Phase 2b). It is designed to
run **inside a GitHub Actions runner**, driven by the workflow that
`kit github install` scaffolds.
When a collaborator comments `/kit <request>` on an issue or pull request, the
workflow boots Kit headlessly with this extension loaded. The extension then:
1. **Parses** the triggering event from `GITHUB_EVENT_PATH`.
2. **Enforces permissions** — only comments whose `author_association` is
`OWNER`, `MEMBER`, or `COLLABORATOR` are acted on.
3. **Reacts** with 👀 on the trigger comment so the human knows Kit is working.
4. **Gathers context** — the issue thread, or the pull request diff and
comments (via the `gh` CLI).
5. **Drives the agent** with the request plus context.
6. **Posts the response** back as a comment, and — if the agent left
uncommitted changes — pushes a branch as the `kit-agent[bot]` identity and
opens a pull request.
7. **Swaps the reaction** to 🚀 (or 😕 on error) when finished.
Outside of GitHub Actions (i.e. when `GITHUB_ACTIONS != "true"`) the extension
is inert, so it is safe to keep loaded during normal local use.
## Requirements
- The [`gh` CLI](https://cli.github.com/) on `PATH`, authenticated via
`GITHUB_TOKEN` (GitHub Actions provides this automatically).
- `git` on `PATH` with push access for opening pull requests.
- A provider API key for the model Kit runs (e.g. `ANTHROPIC_API_KEY`).
## Usage in a workflow
`kit github install` writes `.github/workflows/kit.yml`. To wire in this
handler, load it when invoking Kit headlessly inside the action, for example:
```bash
kit --quiet --no-session \
-e path/to/github-handler/main.go \
--model "$KIT_MODEL"
```
The extension reads the GitHub event itself, so no prompt argument is required —
it constructs the prompt from the comment and repository context and drives the
agent via the session lifecycle.
## Environment variables
| Variable | Purpose |
|----------------------|---------------------------------------------------------------|
| `GITHUB_ACTIONS` | Must be `true` for the handler to activate. |
| `GITHUB_EVENT_PATH` | Path to the JSON event payload (set by Actions). |
| `GITHUB_TOKEN` | Used by `gh`/`git` for API and push operations. |
| `KIT_GITHUB_DRY_RUN` | When set, log `gh`/`git` side effects instead of running them. |
## Options
| Option | Default | Description |
|-------------------|---------|------------------------------------------------------|
| `github.dry-run` | `false` | Log GitHub/git side effects instead of executing. |
## Dry-run mode
Set `KIT_GITHUB_DRY_RUN=1` (or the `github.dry-run` option) to exercise the
parsing, permission, and prompt-building logic without touching the network or
the working tree. Every `gh`/`git` mutation is printed instead of executed.
This is what the unit tests (`main_test.go`) use to stay deterministic.
## Security
- Triggers are gated to write/admin collaborators only.
- The workflow uses `persist-credentials: false` and least-privilege
`permissions`, mirroring established practice.
- Commits are attributed to a dedicated `kit-agent[bot]` identity.
+483
View File
@@ -0,0 +1,483 @@
//go:build ignore
// Package main implements the Kit GitHub handler extension.
//
// This is the Phase 2b "GitHub handler" piece of Kit's GitHub integration
// (issue #60). It is designed to run *inside a GitHub Actions runner*, driven
// by the workflow scaffolded by `kit github install`. When a collaborator
// comments `/kit <request>` on an issue or pull request, the workflow boots Kit
// headlessly with this extension loaded; the extension then:
//
// - parses the triggering GitHub event from GITHUB_EVENT_PATH,
// - enforces that the comment author has write/admin access
// (author_association in OWNER / MEMBER / COLLABORATOR),
// - reacts with 👀 on the trigger comment while it works,
// - gathers context (issue thread or PR diff) and drives the agent with it,
// - posts the agent's response back as a comment, and
// - if the agent left uncommitted changes, pushes a branch as the
// `kit-agent[bot]` identity and opens a pull request.
//
// Outside of GitHub Actions (i.e. when GITHUB_ACTIONS != "true") the extension
// is inert, so it is safe to keep loaded during normal local use.
//
// Set KIT_GITHUB_DRY_RUN=1 (or the `github.dry-run` option) to exercise the
// parsing / permission / prompt-building logic without shelling out to `gh` or
// `git` — every side effect is logged instead of executed. This is what the
// unit tests use.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"time"
"kit/ext"
)
// 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
// 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,
}
// ghUser is a GitHub user as embedded in event payloads.
type ghUser struct {
Login string `json:"login"`
}
// ghComment is the triggering comment.
type ghComment struct {
ID int64 `json:"id"`
Body string `json:"body"`
AuthorAssociation string `json:"author_association"`
User ghUser `json:"user"`
}
// ghIssue is the issue (or PR, since GitHub models PRs as issues) the comment
// was posted on. PullRequest is non-nil when the issue is actually a PR.
type ghIssue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
PullRequest json.RawMessage `json:"pull_request"`
}
// ghPull is the pull request for pull_request_review_comment events.
type ghPull struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
}
// ghRepo identifies the repository the event originated from.
type ghRepo struct {
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
}
// ghEvent is the subset of the GitHub Actions event payload the handler reads.
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 captures everything the handler needs about a single invocation,
// normalised 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
}
func Init(api ext.API) {
api.RegisterOption(ext.OptionDef{
Name: "github.dry-run",
Description: "Log GitHub/git side effects instead of executing them",
Default: "false",
})
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
if !inGitHubActions() {
return
}
handleSessionStart(ctx)
})
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
if !inGitHubActions() || activeTrigger == nil {
return
}
handleAgentEnd(e, ctx)
})
}
// activeTrigger holds the parsed trigger between OnSessionStart and OnAgentEnd.
// Yaegi supports package-level state captured by the handler closures.
var activeTrigger *trigger
func inGitHubActions() bool {
return os.Getenv("GITHUB_ACTIONS") == "true"
}
// dryRun reports whether side effects should be logged instead of executed.
func dryRun(ctx ext.Context) bool {
if os.Getenv("KIT_GITHUB_DRY_RUN") != "" {
return true
}
return strings.EqualFold(ctx.GetOption("github.dry-run"), "true")
}
// handleSessionStart parses the event, enforces permissions, reacts on the
// trigger comment, builds the prompt, and drives the agent.
func handleSessionStart(ctx ext.Context) {
event, err := loadEvent()
if err != nil {
ctx.PrintError("kit-github: " + err.Error())
ctx.Exit()
return
}
tr, err := buildTrigger(event)
if err != nil {
// Not an actionable trigger (e.g. a comment without /kit). Stay quiet
// and let the run finish; the workflow `if:` normally prevents this.
ctx.PrintInfo("kit-github: " + err.Error())
ctx.Exit()
return
}
if !writeAssociations[strings.ToUpper(tr.association)] {
ctx.PrintError(fmt.Sprintf(
"kit-github: ignoring /kit from @%s — author_association %q lacks write access",
tr.author, tr.association))
ctx.Exit()
return
}
activeTrigger = tr
// React with 👀 so the human sees Kit picked up the request.
addReaction(ctx, tr, "eyes")
context := gatherContext(ctx, tr)
prompt := buildPrompt(tr, context)
ctx.SendMessage(prompt)
}
// loadEvent reads and decodes the GitHub Actions event payload.
func loadEvent() (*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
// issue_comment fires for both issues and PRs; reactions for PR
// comments posted on the conversation tab still use the issues path.
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
// pull_request_review_comment reactions use the pulls path.
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.Split(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 full comment thread and PR diff.
func gatherContext(ctx ext.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 dryRun(ctx) || !commandExists("gh") {
return b.String()
}
if tr.isPR {
if diff := ghOutput(ctx, "pr", "diff", fmt.Sprint(tr.number), "--repo", tr.repo); diff != "" {
fmt.Fprintf(&b, "\n## Diff\n```diff\n%s\n```\n", strings.TrimSpace(diff))
}
if comments := ghOutput(ctx, "pr", "view", fmt.Sprint(tr.number), "--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", fmt.Sprint(tr.number), "--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, context 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(context))
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()
}
// handleAgentEnd posts the agent's response, opens a PR for any uncommitted
// changes, and swaps the reaction to signal completion.
func handleAgentEnd(e ext.AgentEndEvent, ctx ext.Context) {
tr := activeTrigger
response := strings.TrimSpace(e.Response)
if response == "" {
response = "Kit finished without a textual response."
}
if e.StopReason == "error" {
comment := "⚠️ Kit hit an error while processing this request:\n\n" + response
postComment(ctx, tr, comment)
addReaction(ctx, tr, "confused")
ctx.Exit()
return
}
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")
ctx.Exit()
}
// hasUncommittedChanges reports whether the working tree has changes the agent
// produced. In dry-run it reports the value of KIT_GITHUB_FAKE_DIRTY so tests
// stay deterministic.
func hasUncommittedChanges(ctx ext.Context) bool {
if dryRun(ctx) {
return os.Getenv("KIT_GITHUB_FAKE_DIRTY") != ""
}
out := gitOutput(ctx, "status", "--porcelain")
return strings.TrimSpace(out) != ""
}
// 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 ext.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))
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 dryRun(ctx) {
ctx.Print(fmt.Sprintf("[dry-run] gh pr create --head %s --base %s", branch, tr.defaultBranch))
return ""
}
url := ghOutput(ctx, "pr", "create", "--repo", tr.repo,
"--head", branch, "--base", tr.defaultBranch,
"--title", title, "--body", body)
return strings.TrimSpace(url)
}
// addReaction adds an emoji reaction to the trigger comment.
func addReaction(ctx ext.Context, tr *trigger, content string) {
path := fmt.Sprintf("/repos/%s/%s/comments/%d/reactions", tr.repo, tr.commentKind, tr.commentID)
if dryRun(ctx) || !commandExists("gh") {
ctx.Print(fmt.Sprintf("[dry-run] react %q on %s", content, 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 ext.Context, tr *trigger, body string) {
sub := "issue"
if tr.isPR {
sub = "pr"
}
if dryRun(ctx) || !commandExists("gh") {
ctx.Print(fmt.Sprintf("[dry-run] gh %s comment %d --body <%d chars>", sub, tr.number, 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 ext.Context, args ...string) {
if dryRun(ctx) {
ctx.Print("[dry-run] git " + strings.Join(args, " "))
return
}
runCmd(ctx, "git", args...)
}
// gitOutput runs a read-only git command and returns its stdout.
func gitOutput(ctx ext.Context, args ...string) string {
cmdCtx, cancel := context.WithTimeout(context.Background(), subprocessTimeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "git", args...)
out, err := cmd.Output()
if err != nil {
ctx.PrintError(fmt.Sprintf("kit-github: git %s failed: %v", strings.Join(args, " "), err))
return ""
}
return string(out)
}
// ghOutput runs a gh command and returns its stdout.
func ghOutput(ctx ext.Context, args ...string) string {
cmdCtx, cancel := context.WithTimeout(context.Background(), subprocessTimeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "gh", args...)
out, err := cmd.Output()
if err != nil {
ctx.PrintError(fmt.Sprintf("kit-github: gh %s failed: %v", strings.Join(args, " "), err))
return ""
}
return string(out)
}
// runCmd runs a command for its side effects, surfacing failures via PrintError.
func runCmd(ctx ext.Context, name string, args ...string) {
cmdCtx, cancel := context.WithTimeout(context.Background(), subprocessTimeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, name, args...)
if out, err := cmd.CombinedOutput(); err != nil {
ctx.PrintError(fmt.Sprintf("kit-github: %s failed: %v\n%s", name, err, strings.TrimSpace(string(out))))
}
}
@@ -0,0 +1,209 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/pkg/extensions/test"
)
// writeEvent writes a GitHub event payload to a temp file and points
// GITHUB_EVENT_PATH at it. It also forces the extension into dry-run and
// pretends we are running inside GitHub Actions.
func writeEvent(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)
}
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 TestGitHubHandler_RegistersHandlers(t *testing.T) {
harness := test.New(t)
ext := harness.LoadFile("main.go")
if ext == nil {
t.Fatal("extension should not be nil")
}
test.AssertHasHandlers(t, harness, extensions.SessionStart)
test.AssertHasHandlers(t, harness, extensions.AgentEnd)
}
func TestGitHubHandler_InertOutsideActions(t *testing.T) {
// No GITHUB_ACTIONS env → the handler must do nothing.
t.Setenv("GITHUB_ACTIONS", "")
harness := test.New(t)
harness.LoadFile("main.go")
if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil {
t.Fatalf("emit: %v", err)
}
if msgs := harness.Context().Messages; len(msgs) != 0 {
t.Errorf("expected no messages outside Actions, got %v", msgs)
}
}
func TestGitHubHandler_AuthorizedIssueComment(t *testing.T) {
writeEvent(t, issueCommentEvent)
harness := test.New(t)
harness.LoadFile("main.go")
if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil {
t.Fatalf("emit: %v", err)
}
msgs := harness.Context().Messages
if len(msgs) != 1 {
t.Fatalf("expected exactly one driven prompt, got %d: %v", len(msgs), msgs)
}
prompt := msgs[0]
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 TestGitHubHandler_UnauthorizedAssociation(t *testing.T) {
writeEvent(t, strings.Replace(issueCommentEvent, `"OWNER"`, `"NONE"`, 1))
harness := test.New(t)
harness.LoadFile("main.go")
if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil {
t.Fatalf("emit: %v", err)
}
if msgs := harness.Context().Messages; len(msgs) != 0 {
t.Fatalf("unauthorized author must not drive the agent, got %v", msgs)
}
if errs := harness.Context().GetPrintErrors(); len(errs) == 0 ||
!strings.Contains(strings.Join(errs, "\n"), "lacks write access") {
t.Errorf("expected a write-access error, got %v", errs)
}
}
func TestGitHubHandler_CommentWithoutToken(t *testing.T) {
writeEvent(t, strings.Replace(issueCommentEvent,
`"/kit fix the broken parser"`, `"just a normal comment"`, 1))
harness := test.New(t)
harness.LoadFile("main.go")
if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil {
t.Fatalf("emit: %v", err)
}
if msgs := harness.Context().Messages; len(msgs) != 0 {
t.Fatalf("non-/kit comment must not drive the agent, got %v", msgs)
}
}
func TestGitHubHandler_MidSentenceMentionIgnored(t *testing.T) {
// An incidental mid-sentence mention of the token must not trigger Kit.
writeEvent(t, strings.Replace(issueCommentEvent,
`"/kit fix the broken parser"`, `"please review /kit behavior in the docs"`, 1))
harness := test.New(t)
harness.LoadFile("main.go")
if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil {
t.Fatalf("emit: %v", err)
}
if msgs := harness.Context().Messages; len(msgs) != 0 {
t.Fatalf("mid-sentence /kit mention must not drive the agent, got %v", msgs)
}
}
func TestGitHubHandler_PullRequestReviewComment(t *testing.T) {
writeEvent(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"}
}`)
harness := test.New(t)
harness.LoadFile("main.go")
if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil {
t.Fatalf("emit: %v", err)
}
msgs := harness.Context().Messages
if len(msgs) != 1 {
t.Fatalf("expected one driven prompt, got %v", msgs)
}
if !strings.Contains(msgs[0], "pull request #7") {
t.Errorf("expected PR target in prompt:\n%s", msgs[0])
}
}
func TestGitHubHandler_AgentEndPostsComment(t *testing.T) {
writeEvent(t, issueCommentEvent)
harness := test.New(t)
harness.LoadFile("main.go")
if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil {
t.Fatalf("emit session start: %v", err)
}
if _, err := harness.Emit(extensions.AgentEndEvent{
Response: "Fixed the parser by guarding empty input.",
StopReason: "completed",
}); err != nil {
t.Fatalf("emit agent end: %v", err)
}
prints := strings.Join(harness.Context().GetPrints(), "\n")
if !strings.Contains(prints, "gh issue comment 42") {
t.Errorf("expected a dry-run comment post, got prints:\n%s", prints)
}
}
func TestGitHubHandler_AgentEndOpensPRWhenDirty(t *testing.T) {
writeEvent(t, issueCommentEvent)
t.Setenv("KIT_GITHUB_FAKE_DIRTY", "1")
harness := test.New(t)
harness.LoadFile("main.go")
if _, err := harness.Emit(extensions.SessionStartEvent{SessionID: "s1"}); err != nil {
t.Fatalf("emit session start: %v", err)
}
if _, err := harness.Emit(extensions.AgentEndEvent{
Response: "Made changes.",
StopReason: "completed",
}); err != nil {
t.Fatalf("emit agent end: %v", err)
}
prints := strings.Join(harness.Context().GetPrints(), "\n")
if !strings.Contains(prints, "gh pr create") {
t.Errorf("expected a dry-run PR creation, got prints:\n%s", prints)
}
if !strings.Contains(prints, "git checkout -b kit/issue-42-") {
t.Errorf("expected a dry-run branch checkout, got prints:\n%s", prints)
}
}
+6
View File
@@ -493,6 +493,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 {
+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.
File diff suppressed because one or more lines are too long
+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)
}
+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 runtime that reads the event context, enforces permissions, drives the agent, and posts the response back is the [`github-handler`](/extensions/examples) example extension.
| 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 |
+3
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
+1
View File
@@ -90,6 +90,7 @@ These examples demonstrate the new bridged SDK APIs that give extensions access
|-----------|-------------|
| [`kit-kit-agents/`](https://github.com/mark3labs/kit/tree/master/examples/extensions/kit-kit-agents) | Multi-agent orchestration example |
| [`kit-telegram/`](https://github.com/mark3labs/kit/tree/master/examples/extensions/kit-telegram) | Telegram bot integration |
| [`github-handler/`](https://github.com/mark3labs/kit/tree/master/examples/extensions/github-handler) | Run Kit as a GitHub collaborator inside Actions — parses the event, gates on `author_association`, drives the agent, posts comments, and opens PRs |
| [`status-tools/`](https://github.com/mark3labs/kit/tree/master/examples/extensions/status-tools) | Status bar tool examples |
## Project-local example
+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