From c16f232777071551f3d4a9af1426acfd639361aa Mon Sep 17 00:00:00 2001 From: Jack Dwyer Date: Mon, 19 Oct 2026 15:14:34 -0700 Subject: [PATCH] feat(skills): offer to install skills on first run When a coding agent is on the machine and no LiveKit skills are installed, lk asks once, in a terminal, whether to install them globally (lk skills install --global). It runs before any sign-in, so API-key and self-hosted users see it too. Runs without a terminal or with --yes print a one-line tip instead and never wait for input; CI, --quiet, --json and LK_NO_SKILLS_PROMPT=1 skip it entirely. The answer is saved in the CLI config so it's asked only once. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + cmd/lk/main.go | 4 + cmd/lk/skills_prompt.go | 193 +++++++++++++++++++++++++++++++++++ cmd/lk/skills_prompt_test.go | 113 ++++++++++++++++++++ pkg/config/config.go | 5 +- 5 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 cmd/lk/skills_prompt.go create mode 100644 cmd/lk/skills_prompt_test.go diff --git a/README.md b/README.md index b998f52c..2bc00e9a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/lk/main.go b/cmd/lk/main.go index 395d553a..c5a192d8 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -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 } diff --git a/cmd/lk/skills_prompt.go b/cmd/lk/skills_prompt.go new file mode 100644 index 00000000..109b8ded --- /dev/null +++ b/cmd/lk/skills_prompt.go @@ -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 +} diff --git a/cmd/lk/skills_prompt_test.go b/cmd/lk/skills_prompt_test.go new file mode 100644 index 00000000..c0a70ad8 --- /dev/null +++ b/cmd/lk/skills_prompt_test.go @@ -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) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index cfc7d156..9e76f84a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 } @@ -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 }