mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-15 20:16:12 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41ab071f14 | |||
| feaec4268e | |||
| 7f366eab84 |
@@ -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,40 @@ 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]`).
|
||||
- Gates execution on comments starting with (or containing ` `) `/kit`.
|
||||
- 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.
|
||||
|
||||
| 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.
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"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(_ *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(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: |
|
||||
startsWith(github.event.comment.body, '/kit') ||
|
||||
contains(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(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
|
||||
}
|
||||
|
||||
cmd := exec.Command("gh", "secret", "set", secretName, "--body", 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.")
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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')",
|
||||
"contains(github.event.comment.body, ' /kit')",
|
||||
"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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -56,6 +56,54 @@ 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]`).
|
||||
- Gates execution on comments starting with (or containing ` `) `/kit`.
|
||||
- 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.
|
||||
|
||||
| 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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user