Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,8 @@ Skills are copied (not symlinked) into each agent's skills directory; agents tha

`lk agent init` offers to install skills into new agent projects for the coding agents on your machine; pass `--skills=false` to skip.

If a coding agent is on your machine but no LiveKit skills are installed, `lk` offers once, in a terminal, to install them globally (`lk skills install --global`). Runs without a terminal or with `--yes`, such as a coding agent calling `lk`, print a one-line tip instead and never wait for input. Neither appears in CI, with `--quiet` or `--json`, or when `LK_NO_SKILLS_PROMPT=1` is set.

## Additional notes

### Parameter precedence
Expand Down
4 changes: 4 additions & 0 deletions cmd/lk/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ func initLogger(ctx context.Context, cmd *cli.Command) (context.Context, error)
// Nudge users still on API-key auth toward the new account-based flow.
maybeShowUpgradeNotice(cmd, conf)

// Offer LiveKit's coding agent skills once, before any sign-in, so API-key
// and self-hosted users get it too.
maybeOfferSkills(ctx, cmd, conf)

return nil, nil
}

Expand Down
193 changes: 193 additions & 0 deletions cmd/lk/skills_prompt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"context"
"os"
"slices"

"charm.land/huh/v2"
"github.com/mattn/go-isatty"
"github.com/urfave/cli/v3"

"github.com/livekit/livekit-cli/v2/pkg/config"
"github.com/livekit/livekit-cli/v2/pkg/skills"
"github.com/livekit/livekit-cli/v2/pkg/util"
)

// skillsPromptOptOutEnv hides the first-run skills offer for good, for
// machines that never want it (shared build hosts, images).
const skillsPromptOptOutEnv = "LK_NO_SKILLS_PROMPT"

// skillsOfferSkipCommands are top-level commands that never offer skills:
// the skills commands themselves, and ones whose output a tool reads.
var skillsOfferSkipCommands = []string{"skills", "help", "h", "completion", "generate-fish-completion"}

// skillsOfferSkipLeaves are commands with their own skills offer: lk agent init
// asks to install skills into the project it creates.
var skillsOfferSkipLeaves = []string{"lk agent init"}

type skillsOffer int

const (
skillsOfferNone skillsOffer = iota
skillsOfferHint // one status line; for runs that can't prompt
skillsOfferPrompt // ask once, and install on yes
)

// skillsOfferInput is everything skillsOfferFor decides on, gathered by
// maybeOfferSkills so the decision can be tested without a terminal.
type skillsOfferInput struct {
command string // top-level command name; empty for bare `lk`
leaf string // full name of the command that runs, such as "lk agent init"
optedOut bool // LK_NO_SKILLS_PROMPT is set
ci bool // CI is set
alreadyAsked bool // the user answered the prompt before
installed bool // LiveKit skills are installed globally or in this project
agentsDetected bool // at least one coding agent is on this machine
interactive bool // stdin, stdout and stderr are terminals, and no --yes
quiet bool
json bool
helpOrVersion bool
}

// skillsOfferFor decides whether a run offers to install LiveKit's skills.
// It only asks with a terminal, and never blocks a script, CI, or a coding
// agent running lk: those get a one-line hint instead, so an agent can act on
// it. The hint repeats until skills are installed, the prompt is answered, or
// the opt-out is set; an answer of no is final.
func skillsOfferFor(in skillsOfferInput) skillsOffer {
switch {
case in.optedOut, in.ci, in.alreadyAsked, in.installed, !in.agentsDetected:
return skillsOfferNone
case in.command == "", in.helpOrVersion, slices.Contains(skillsOfferSkipCommands, in.command),
slices.Contains(skillsOfferSkipLeaves, in.leaf):
return skillsOfferNone
case in.json, in.quiet:
return skillsOfferNone
case in.interactive:
return skillsOfferPrompt
}
return skillsOfferHint
}

// maybeOfferSkills offers, on the first interactive run, to install
// LiveKit's skills and Docs MCP server into the coding agents on this machine
// (lk skills install --global). It runs before every command, ahead of any
// sign-in, so API-key and self-hosted users see it too. It never fails the
// command it runs before.
func maybeOfferSkills(ctx context.Context, cmd *cli.Command, conf *config.CLIConfig) {
env, err := skills.DefaultEnv()
if err != nil {
return
}
agents := skills.DetectAgents(env)
leaf := leafCommand(cmd)
in := skillsOfferInput{
command: cmd.Args().First(),
leaf: leaf.FullName(),
optedOut: os.Getenv(skillsPromptOptOutEnv) != "",
ci: os.Getenv("CI") != "",
alreadyAsked: conf != nil && conf.SkillsPrompted,
agentsDetected: len(agents) > 0,
interactive: !SkipPrompts(cmd) && out.Interactive() && isatty.IsTerminal(os.Stdout.Fd()),
quiet: cmd.Bool("quiet"),
json: leaf.Bool("json"),
helpOrVersion: cmd.Bool("help") || leaf.Bool("help") || cmd.Bool("version"),
}
// Reading the lock files is cheap but not free; skip it when nothing
// else would show the offer.
if skillsOfferFor(in) == skillsOfferNone {
return
}
in.installed = liveKitSkillsInstalled(env)

switch skillsOfferFor(in) {
case skillsOfferHint:
out.Statusf("Tip: run %s to teach your coding agents (%s) to build LiveKit agents. Set %s=1 to hide this.",
util.Accented("lk skills install --global --yes"), skills.AgentNames(agents), skillsPromptOptOutEnv)
case skillsOfferPrompt:
promptSkillsInstall(ctx, cmd, conf, env, agents)
}
}

// promptSkillsInstall asks once whether to install skills globally, records
// that it asked, and installs on yes. Any failure is reported and the command
// carries on.
func promptSkillsInstall(ctx context.Context, cmd *cli.Command, conf *config.CLIConfig, env *skills.Env, agents []*skills.Agent) {
ok := true
err := huh.NewForm(huh.NewGroup(util.Confirm().
Title("Install LiveKit skills for your coding agents?").
Description("Teaches " + skills.AgentNames(agents) + " to build, test, and debug LiveKit agents,\n" +
"and adds the LiveKit Docs MCP server. Installs to your user-level agent\n" +
"directories. lk asks once; run lk skills install any time.").
Value(&ok).
WithTheme(util.FormTheme()))).
Run()
if conf != nil {
conf.SkillsPrompted = true
if perr := conf.PersistQuietly(); perr != nil {
out.Warnf("Couldn't save your answer to the CLI config: %v", perr)
}
}
if err != nil || !ok {
out.Statusf("Skipped. Run %s any time to add them.", util.Accented("lk skills install --global"))
return
}
s := &skillsSession{env: env, scope: skills.ScopeGlobal}
o, err := s.install(ctx, installRequest{
agents: agents,
ref: skills.DefaultRef,
confirm: func(copies []skills.Copy) (bool, error) { return confirmUntracked(cmd, s, copies) },
})
if err != nil {
out.Warnf("Couldn't install LiveKit skills: %v. Run %s to try again.", err, util.Accented("lk skills install --global"))
return
}
_ = s.printChange(o)
}

// liveKitSkillsInstalled reports whether any LiveKit skill is installed for
// any agent, globally or in the current project. It reads only lock files
// and skills directories; nothing is downloaded.
func liveKitSkillsInstalled(env *skills.Env) bool {
for _, scope := range []skills.Scope{skills.ScopeGlobal, skills.ScopeProject} {
in, err := skills.NewInstaller(env, scope, nil)
if err != nil {
continue
}
names, err := in.InstalledLiveKitSkills(skills.GroupSkillsDirs(env, scope, skills.Agents))
if err == nil && len(names) > 0 {
return true
}
}
return false
}

// leafCommand is the command a run resolves to, found by following cmd's
// arguments down the command tree. Before hooks get the root, but flags such
// as --json belong to the leaf.
func leafCommand(cmd *cli.Command) *cli.Command {
leaf := cmd
for _, arg := range cmd.Args().Slice() {
sub := leaf.Command(arg)
if sub == nil {
break
}
leaf = sub
}
return leaf
}
113 changes: 113 additions & 0 deletions cmd/lk/skills_prompt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"

"github.com/livekit/livekit-cli/v2/pkg/skills"
)

func TestSkillsOfferFor(t *testing.T) {
// base is an interactive first run of an ordinary command on a machine with
// a coding agent and no LiveKit skills.
base := skillsOfferInput{command: "room", leaf: "lk room list", agentsDetected: true, interactive: true}
cases := []struct {
name string
modify func(*skillsOfferInput)
want skillsOffer
}{
{"asks on the first interactive run", func(*skillsOfferInput) {}, skillsOfferPrompt},
{"asks once", func(in *skillsOfferInput) { in.alreadyAsked = true }, skillsOfferNone},
{"hints without a terminal or with --yes", func(in *skillsOfferInput) { in.interactive = false }, skillsOfferHint},
{"stops hinting once the prompt was answered", func(in *skillsOfferInput) { in.interactive, in.alreadyAsked = false, true }, skillsOfferNone},
{"stays quiet once installed", func(in *skillsOfferInput) { in.installed = true }, skillsOfferNone},
{"stays quiet with no coding agents", func(in *skillsOfferInput) { in.agentsDetected = false }, skillsOfferNone},
{"honors the opt-out", func(in *skillsOfferInput) { in.optedOut, in.interactive = true, false }, skillsOfferNone},
{"stays quiet in CI", func(in *skillsOfferInput) { in.ci, in.interactive = true, false }, skillsOfferNone},
{"stays quiet with --quiet", func(in *skillsOfferInput) { in.quiet = true }, skillsOfferNone},
{"stays quiet with --json", func(in *skillsOfferInput) { in.json, in.interactive = true, false }, skillsOfferNone},
{"skips bare lk", func(in *skillsOfferInput) { in.command, in.leaf = "", "lk" }, skillsOfferNone},
{"skips help and version", func(in *skillsOfferInput) { in.helpOrVersion = true }, skillsOfferNone},
{"skips the skills commands", func(in *skillsOfferInput) { in.command, in.leaf = "skills", "lk skills install" }, skillsOfferNone},
{"skips completion", func(in *skillsOfferInput) { in.command = "generate-fish-completion" }, skillsOfferNone},
{"leaves agent init to its own offer", func(in *skillsOfferInput) { in.command, in.leaf = "agent", "lk agent init" }, skillsOfferNone},
{"offers before other agent commands", func(in *skillsOfferInput) { in.command, in.leaf = "agent", "lk agent deploy" }, skillsOfferPrompt},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
in := base
c.modify(&in)
assert.Equal(t, c.want, skillsOfferFor(in))
})
}
}

func TestLiveKitSkillsInstalled(t *testing.T) {
writeSkill := func(t *testing.T, dir, name, author string) {
t.Helper()
p := filepath.Join(dir, name, "SKILL.md")
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
body := "---\nname: " + name + "\ndescription: x\nmetadata:\n author: " + author + "\n---\n"
require.NoError(t, os.WriteFile(p, []byte(body), 0o644))
}
newEnv := func(t *testing.T) *skills.Env {
return &skills.Env{Home: t.TempDir(), Root: t.TempDir(), Getenv: func(string) string { return "" }}
}

t.Run("none", func(t *testing.T) {
assert.False(t, liveKitSkillsInstalled(newEnv(t)))
})
t.Run("someone else's skill", func(t *testing.T) {
env := newEnv(t)
writeSkill(t, filepath.Join(env.Home, ".claude", "skills"), "other-skill", "someone")
assert.False(t, liveKitSkillsInstalled(env))
})
t.Run("global", func(t *testing.T) {
env := newEnv(t)
writeSkill(t, filepath.Join(env.Home, ".claude", "skills"), "building-livekit-agents", "livekit")
assert.True(t, liveKitSkillsInstalled(env))
})
t.Run("project", func(t *testing.T) {
env := newEnv(t)
writeSkill(t, filepath.Join(env.Root, ".agents", "skills"), "building-livekit-agents", "livekit")
assert.True(t, liveKitSkillsInstalled(env))
})
}

func TestLeafCommand(t *testing.T) {
var got string
app := &cli.Command{
Name: "lk",
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
got = leafCommand(cmd).FullName()
return ctx, nil
},
Commands: []*cli.Command{{
Name: "agent",
Aliases: []string{"a"},
Commands: []*cli.Command{{Name: "init", Flags: []cli.Flag{&cli.BoolFlag{Name: "json"}}, Action: func(context.Context, *cli.Command) error { return nil }}},
}},
}
require.NoError(t, app.Run(context.Background(), []string{"lk", "a", "init", "--json", "my-agent"}))
assert.Equal(t, "lk agent init", got)
}
5 changes: 4 additions & 1 deletion pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ type CLIConfig struct {
Users []UserConfig `yaml:"users"`
DeviceName string `yaml:"device_name"`
Theme string `yaml:"theme"`
// SkillsPrompted records that the first-run offer to install LiveKit's
// coding agent skills was answered, so it's asked only once.
SkillsPrompted bool `yaml:"skills_prompted,omitempty"`
// absent from YAML
hasPersisted bool
}
Expand Down Expand Up @@ -395,7 +398,7 @@ func (c *CLIConfig) PersistQuietly() error {
}

func (c *CLIConfig) persist(announce bool) error {
if len(c.Projects) == 0 && len(c.Users) == 0 && c.Theme == "" && !c.hasPersisted {
if len(c.Projects) == 0 && len(c.Users) == 0 && c.Theme == "" && !c.SkillsPrompted && !c.hasPersisted {
// nothing worth persisting yet
return nil
}
Expand Down
Loading