Compare commits

..

3 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
14 changed files with 1273 additions and 4 deletions
+46
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
@@ -260,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
@@ -478,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)
}
}
+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 -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.
+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 {
+31
View File
@@ -76,6 +76,37 @@ 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:
+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