Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- uses: actions/setup-go@v5
with:
Expand All @@ -24,9 +26,29 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- uses: actions/setup-go@v5
with:
go-version: "1.26"

- run: go test -race ./...

rust-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable

- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
workspaces: contrib/kit-tunnel

- name: Test kit-tunnel
run: cargo test --release --manifest-path contrib/kit-tunnel/Cargo.toml
28 changes: 27 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,37 @@ jobs:
with:
go-version: "1.26"

# The kit binary embeds the Rust kit-tunnel sidecar; goreleaser's
# per-target build hooks cross-compile it with cargo-zigbuild.
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: >-
x86_64-unknown-linux-gnu,
aarch64-unknown-linux-gnu,
x86_64-apple-darwin,
aarch64-apple-darwin,
x86_64-pc-windows-gnu

- name: Set up zig (cross toolchain)
uses: mlugg/setup-zig@v1
with:
version: 0.13.0
use-cache: false

- name: Install cargo-zigbuild
uses: taiki-e/install-action@v2
with:
tool: cargo-zigbuild

# --parallelism 1 keeps target builds sequential: the per-target pre
# hook stages the sidecar into the shared go:embed directory, and
# parallel builds could embed a sibling's sidecar mid-staging.
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
args: release --clean --parallelism 1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand Down
9 changes: 9 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ builds:
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
# Each build embeds the kit-tunnel sidecar compiled for its own target
# (go:embed reads internal/daemon/embedded/ at compile time). goreleaser
# runs target builds sequentially, and the post hook removes the staged
# file, so every binary carries exactly its own sidecar.
hooks:
pre:
- ./scripts/stage-tunnel.sh {{ .Os }} {{ .Arch }}
post:
- rm -f internal/daemon/embedded/kit-tunnel-{{ .Os }}-{{ .Arch }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

archives:
- formats: [tar.gz]
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ A powerful, extensible AI coding agent CLI with multi-provider support, built-in
- **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
- **Remote Sessions**: `kit daemon` on one machine, `kit --remote CODE` from another — end-to-end encrypted iroh transport, per-client sessions, pairing-code security, and systemd service support
- **Go SDK**: Embed Kit in your own applications with full agent lifecycle events (30+ event types) and behavior-modifying hooks

## Installation
Expand Down
32 changes: 31 additions & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,46 @@ tasks:
# -----------------------------------------------------------------------

build:
desc: Build the kit binary
desc: Build the kit binary and the kit-tunnel sidecar
deps:
- tunnel
cmds:
- go build {{.BUILD_FLAGS}} -ldflags "{{.LDFLAGS}}" ./cmd/kit
sources:
- "**/*.go"
- go.mod
- go.sum
- internal/daemon/embedded/kit-tunnel-*
generates:
- "{{.OUTPUT_DIR}}/{{.BINARY}}"

tunnel:
desc: Build the kit-tunnel sidecar (Rust; required for daemon/remote)
cmds:
- |
if ! command -v cargo >/dev/null 2>&1; then
echo "warn: cargo not found — skipping kit-tunnel build; 'kit daemon' and '--remote' need it"
exit 0
fi
./scripts/stage-tunnel.sh "$(go env GOOS)" "$(go env GOARCH)" "internal/daemon/embedded/kit-tunnel-$(go env GOOS)-$(go env GOARCH)"
mkdir -p {{.OUTPUT_DIR}}
cp contrib/kit-tunnel/target/release/kit-tunnel{{.exe}} {{.OUTPUT_DIR}}/kit-tunnel{{.exe}}
vars:
exe:
sh: 'if [ "$(go env GOOS)" = "windows" ]; then echo .exe; fi'
sources:
- contrib/kit-tunnel/src/**/*.rs
- contrib/kit-tunnel/Cargo.toml
- contrib/kit-tunnel/Cargo.lock
generates:
- "{{.OUTPUT_DIR}}/kit-tunnel"
- "internal/daemon/embedded/kit-tunnel-*"

# NOTE: these Go builds do NOT stage per-target kit-tunnel sidecars, so
# the artifacts fall back to an external sidecar at runtime. For
# distributable binaries with the sidecar embedded, use goreleaser
# (which runs scripts/stage-tunnel.sh per target), or stage each target
# first: GOOS=x GOARCH=y ./scripts/stage-tunnel.sh x y out
build-all:
desc: Build for all platforms (linux, darwin, windows)
cmds:
Expand Down
116 changes: 116 additions & 0 deletions cmd/daemon.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package cmd

import (
"context"
"fmt"
"os"
"os/signal"
"time"

"github.com/spf13/cobra"

"github.com/mark3labs/kit/internal/daemon"
)

var daemonCode string

var daemonCmd = &cobra.Command{
Use: "daemon",
Short: "Run Kit as a remote daemon, waiting for a pairing connection",
Long: `Run Kit as a remote daemon.

Generates a pairing code and waits for remote peers to connect with
"kit --remote CODE". Each verified peer picks a working directory
(starting in this user's home directory) and gets its own session:
the session runs entirely on this machine, rendered inside the peer's
terminal. Multiple clients can hold sessions at the same time, and
exiting a session only disconnects that client.

The pairing code stays valid while the daemon runs; press Ctrl+C to
stop. Only one daemon may run per user; use "kit daemon status" to
inspect a running instance and "kit daemon service install" to manage
it via systemd (user service).`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
return daemon.Serve(ctx, daemon.ServeOptions{Code: daemonCode})
},
}

var daemonStatusCmd = &cobra.Command{
Use: "status",
Short: "Show the pairing code and state of a running daemon",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
st := daemon.ReadStatus()
if !st.Running {
fmt.Println("kit daemon is not running.")
fmt.Println("Start one with: kit daemon (or: kit daemon service install)")
if st.State != nil {
fmt.Printf("(stale state on disk from pid %d, started %s, code %s)\n",
st.State.PID, st.State.StartedAt.Format("2006-01-02 15:04"), st.State.Code)
}
return nil
}
s := st.State
if s == nil {
fmt.Printf("kit daemon is running (pid unknown — state file not written yet)\n")
return nil
}
uptime := time.Since(s.StartedAt).Round(time.Second)
fmt.Printf("kit daemon is running (pid %d, up %s)\n", s.PID, uptime)
fmt.Printf(" Pairing code: %s\n", s.Code)
fmt.Printf(" Connect with: kit --remote %s\n", normalizeForHint(s.Code))
if s.Endpoint != "" {
fmt.Printf(" Endpoint: %s\n", s.Endpoint)
}
fmt.Printf(" Active sessions: %d\n", s.SessionsActive)
return nil
},
}

// normalizeForHint strips the display dash so the connect hint is
// copy-pasteable.
func normalizeForHint(displayCode string) string {
out := make([]byte, 0, len(displayCode))
for i := 0; i < len(displayCode); i++ {
if displayCode[i] != '-' {
out = append(out, displayCode[i])
}
}
return string(out)
}

var daemonServiceCmd = &cobra.Command{
Use: "service",
Short: "Manage the kit daemon systemd user service",
Args: cobra.NoArgs,
}

var daemonServiceInstallCmd = &cobra.Command{
Use: "install",
Short: "Install and start the systemd user service",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return daemon.InstallSystemService(cmd.Context())
},
}

var daemonServiceRemoveCmd = &cobra.Command{
Use: "remove",
Short: "Stop and uninstall the systemd user service",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return daemon.RemoveSystemService(cmd.Context())
},
}

func init() {
daemonCmd.Flags().StringVar(&daemonCode, "code", "", "use a fixed pairing code instead of a random one (testing)")
_ = daemonCmd.Flags().MarkHidden("code")
daemonCmd.AddCommand(daemonStatusCmd)
daemonServiceCmd.AddCommand(daemonServiceInstallCmd)
daemonServiceCmd.AddCommand(daemonServiceRemoveCmd)
daemonCmd.AddCommand(daemonServiceCmd)
rootCmd.AddCommand(daemonCmd)
}
59 changes: 59 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
charmlog "github.com/charmbracelet/log"
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/config"
"github.com/mark3labs/kit/internal/daemon"
"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/prompts"
Expand Down Expand Up @@ -97,6 +98,11 @@ var (
promptTemplatePaths []string
noPromptTemplates bool

// Remote sessions (--remote) and the daemon's directory picker
// (--pick-dir, hidden — spawned by `kit daemon`).
remoteCodeFlag string
pickDirFlag bool

// Preference restoration flags — set in RunE after cobra parses, used
// in runNormalMode to decide whether to apply saved preferences.
modelFlagChanged bool
Expand Down Expand Up @@ -252,6 +258,9 @@ func kitBanner() string {
}

func init() {
// Registered before InitConfig: remote attachment and directory
// selection must not depend on (or race) local configuration loading.
cobra.OnInitialize(preInitDispatch)
cobra.OnInitialize(InitConfig)

rootCmd.Long = kitBanner() + "\n\n" + rootCmd.Long
Expand Down Expand Up @@ -324,6 +333,11 @@ func init() {
StringVar(&skillsDir, "skills-dir", "", "scan this directory directly for skills (overrides auto-discovery)")
rootCmd.PersistentFlags().
StringSliceVar(&skillsDisable, "skill-disable", nil, "hide a skill from the model catalog by name (repeatable); still usable via /skill:")
rootCmd.Flags().
StringVar(&remoteCodeFlag, "remote", "", "connect to a kit daemon using a pairing code (e.g. kit --remote A1B2C3D4)")
rootCmd.Flags().
BoolVar(&pickDirFlag, "pick-dir", false, "choose a working directory with a picker before starting")
_ = rootCmd.Flags().MarkHidden("pick-dir")

flags := rootCmd.PersistentFlags()
flags.StringVar(&providerURL, "provider-url", "", "base URL for the provider API (applies to OpenAI, Anthropic, Ollama, and Google)")
Expand Down Expand Up @@ -453,6 +467,51 @@ func processPositionalArgs(args []string) {
}
}

// preInitDispatch handles the remote-session entry points before any
// configuration is loaded. Cobra runs initializers in registration order,
// ahead of RunE, so:
//
// - `--remote` dispatches without touching local config: a broken
// ~/.config/kit or project config must not block attaching to a daemon,
// and a remote attachment must never load project settings.
// - `--pick-dir` changes the working directory before config discovery,
// so project-level configuration (.kit.* in the chosen directory) is
// honored instead of the directory kit happened to start in.
//
// The dispatcher exits the process directly; neither path returns into the
// normal startup flow.
func preInitDispatch() {
// Remote mode: attach this terminal to a kit daemon session. The client
// performs no local-session work itself.
if remoteCodeFlag != "" {
if args := rootCmd.Flags().Args(); len(args) > 0 {
fmt.Fprintln(os.Stderr, "prompt arguments cannot be combined with --remote")
os.Exit(1)
}
if err := daemon.RunRemote(context.Background(), remoteCodeFlag); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
os.Exit(0)
}

if pickDirFlag {
home, _ := os.UserHomeDir()
chosen, err := ui.RunDirPicker(home)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if chosen == "" {
os.Exit(0) // cancelled
}
if err := os.Chdir(chosen); err != nil {
fmt.Fprintf(os.Stderr, "change to %s: %v\n", chosen, err)
os.Exit(1)
}
}
}

func runKit(ctx context.Context) error {
return runNormalMode(ctx)
}
Expand Down
1 change: 1 addition & 0 deletions contrib/kit-tunnel/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/target
Loading
Loading