diff --git a/README.md b/README.md index 4bce89ad..fdd9c487 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,32 @@ lk docs --server-url https://docs-staging.example.com/mcp/ search "agents" lk docs --server-url https://docs-abc123.vercel.app/mcp/ --vercel-header overview ``` +## Coding agent skills + +`lk skills` installs LiveKit's [agent skills](https://github.com/livekit/agent-skills) into your coding agents (Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, OpenCode, Windsurf, Amp, Cline, Goose), and adds the [LiveKit Docs MCP server](https://docs.livekit.io/intro/mcp-server/) to their MCP config. + +```shell +# Install every skill and the Docs MCP server for the agents detected on this machine +lk skills install + +# Only some agents, or user-wide instead of the current project +lk skills install --agent claude-code --agent codex +lk skills install --global + +# See what's installed, whether it's current, and Docs MCP setup +lk skills list + +# Pull the latest skills +lk skills update + +# Uninstall +lk skills remove +``` + +Skills are copied (not symlinked) into each agent's skills directory; agents that read the shared `.agents/skills` directory get one copy. Installs are recorded in `skills-lock.json` (commit it with the skills), or `~/.agents/.skill-lock.json` with `--global`. These are the same lock files [`npx skills`](https://github.com/vercel-labs/skills) and `gh skill` use, so any of the three tools can update what another installed. Skills you've edited locally are never overwritten unless you pass `--force`. + +`lk agent init` offers to install skills into new agent projects for the coding agents on your machine; pass `--skills=false` to skip. + ## Additional notes ### Parameter precedence diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index 1efb41a9..1215774a 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -275,6 +275,7 @@ On LiveKit Cloud: "create" and "deploy" ship it, then "status", "logs", Flags: []cli.Flag{ regionFlag, installFlag, + skillsSetupFlag, }, ArgsUsage: "[AGENT-NAME]", DisableSliceFlagSeparator: true, @@ -634,7 +635,13 @@ func initAgent(ctx context.Context, cmd *cli.Command) error { if shouldDeploy { cmd.Set("install", "true") } - if err := setupTemplate(ctx, cmd); err != nil { + if err := setupTemplateWith(ctx, cmd, func(ctx context.Context, cmd *cli.Command, dir string) error { + // Best-effort, like dependency install: the project is still usable. + if err := setupProjectSkills(ctx, cmd, dir); err != nil { + out.Warnf("Couldn't install coding agent skills: %v\nRun %s in ./%s to try again.", err, "lk skills install", dir) + } + return nil + }); err != nil { return err } // Deploy if requested diff --git a/cmd/lk/app.go b/cmd/lk/app.go index f62e817c..cf7d87a4 100644 --- a/cmd/lk/app.go +++ b/cmd/lk/app.go @@ -276,6 +276,12 @@ func listTemplates(ctx context.Context, cmd *cli.Command) error { } func setupTemplate(ctx context.Context, cmd *cli.Command) error { + return setupTemplateWith(ctx, cmd, nil) +} + +// setupTemplateWith is setupTemplate with a step that runs after dependencies +// are installed, before the template's post-create output. +func setupTemplateWith(ctx context.Context, cmd *cli.Command, afterInstall func(ctx context.Context, cmd *cli.Command, dir string) error) error { verbose := cmd.Bool("verbose") install := cmd.Bool("install") isSandbox := sandboxID != "" @@ -468,6 +474,11 @@ func setupTemplate(ctx context.Context, cmd *cli.Command) error { os.Setenv("LIVEKIT_DEPS_INSTALLED", "1") } } + if afterInstall != nil { + if err := afterInstall(ctx, cmd, appName); err != nil { + return err + } + } if err := doPostCreate(ctx, cmd, appName, verbose); err != nil { return err } diff --git a/cmd/lk/main.go b/cmd/lk/main.go index ce791987..aed56094 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -98,6 +98,7 @@ Docs: https://docs.livekit.io/intro/basics/cli/`, app.Commands = append(app.Commands, AnalyticsCommands...) app.Commands = append(app.Commands, CloudCommands...) app.Commands = append(app.Commands, DocsCommands...) + app.Commands = append(app.Commands, SkillsCommands...) app.Commands = append(app.Commands, ProjectCommands...) app.Commands = append(app.Commands, WorkspaceCommands...) app.Commands = append(app.Commands, UserCommands...) @@ -279,7 +280,7 @@ var rootHelpGroups = []struct { {"PROJECTS", []string{"project", "cloud", "app"}}, {"ROOMS AND MEDIA", []string{"room", "token", "dispatch", "egress", "ingress"}}, {"TELEPHONY", []string{"sip", "number"}}, - {"TOOLS", []string{"docs", "perf"}}, + {"TOOLS", []string{"docs", "skills", "perf"}}, } // agentHelpSections groups a command's visible subcommands by Category, in diff --git a/cmd/lk/skills.go b/cmd/lk/skills.go new file mode 100644 index 00000000..3d237c89 --- /dev/null +++ b/cmd/lk/skills.go @@ -0,0 +1,985 @@ +// 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" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "charm.land/huh/v2" + "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/skills" + "github.com/livekit/livekit-cli/v2/pkg/util" +) + +var ( + skillsGlobalFlag = &cli.BoolFlag{ + Name: "global", + Aliases: []string{"g"}, + Usage: "Use your user-level agent directories instead of the current project", + } + skillsAgentFlag = &cli.StringSliceFlag{ + Name: "agent", + Aliases: []string{"a"}, + Usage: "Coding `AGENT` to target; repeat for several (" + strings.Join(skills.AgentIDs(), ", ") + ")", + } + skillsForceFlag = &cli.BoolFlag{ + Name: "force", + Usage: "Overwrite skills you've edited locally", + } + // --ref installs from another branch of livekit/agent-skills, for trying + // skill changes before they merge. + skillsRefFlag = &cli.StringFlag{ + Name: "ref", + Value: skills.DefaultRef, + Hidden: true, + } + + SkillsCommands = []*cli.Command{ + { + Name: "skills", + Usage: "Install LiveKit's skills and Docs MCP server into your coding agents", + Description: `Skills teach coding agents (Claude Code, Codex, Cursor, and others) how to +build, test, and debug LiveKit agents. The LiveKit Docs MCP server gives them +current API reference. Skills come from github.com/livekit/agent-skills. + +Skills are copied into each agent's skills directory; agents that share +.agents/skills get one copy. Installs are recorded in skills-lock.json (or +~/.agents/.skill-lock.json with --global), the same lock file "npx skills" and +"gh skill" use, so commit it along with the skills. + + lk skills install # all skills + Docs MCP, for detected agents + lk skills list # what's installed and whether it's current + lk skills update # pull the latest skills + lk skills remove # uninstall LiveKit's skills`, + Commands: []*cli.Command{ + { + Name: "install", + Aliases: []string{"add"}, + Usage: "Install skills and the Docs MCP server", + ArgsUsage: "[SKILL ...]", + Description: `Installs every LiveKit skill, or just the ones named, for the coding agents +detected on this machine (or those passed with --agent), and adds the LiveKit +Docs MCP server to each agent's MCP config. + +Skills you've edited locally are left alone unless you pass --force. Skills +LiveKit no longer publishes are removed. LiveKit skills that lk didn't install +(such as copies committed to a starter template) are replaced once you confirm.`, + Flags: []cli.Flag{ + skillsAgentFlag, + skillsGlobalFlag, + &cli.BoolFlag{ + Name: "skip-mcp", + Usage: "Don't add the Docs MCP server to agent configs", + }, + skillsForceFlag, + jsonFlag, + skillsRefFlag, + }, + Action: skillsInstall, + }, + { + Name: "list", + Aliases: []string{"ls"}, + Usage: "Show available skills, where they're installed, and Docs MCP setup", + Flags: []cli.Flag{ + skillsGlobalFlag, + jsonFlag, + skillsRefFlag, + }, + Action: skillsList, + }, + { + Name: "update", + Aliases: []string{"upgrade"}, + Usage: "Update installed skills to the latest version", + ArgsUsage: "[SKILL ...]", + Description: `Brings every skills directory that has LiveKit skills in line with the +published set: outdated skills are replaced, new skills are added, and skills +LiveKit no longer publishes are removed. Name skills to update only those. + +Skills you've edited locally are left alone unless you pass --force. LiveKit +skills that lk didn't install (such as copies committed to a starter template) +are replaced once you confirm.`, + Flags: []cli.Flag{ + skillsGlobalFlag, + skillsForceFlag, + jsonFlag, + skillsRefFlag, + }, + Action: skillsUpdate, + }, + { + Name: "remove", + Aliases: []string{"rm", "uninstall"}, + Usage: "Remove LiveKit skills", + ArgsUsage: "[SKILL ...]", + Description: `Removes every LiveKit skill, or just the ones named, from all agents' +skills directories (or only those of --agent). MCP config is left as is. + +Skills you've edited locally are kept unless you pass --force.`, + Flags: []cli.Flag{ + skillsAgentFlag, + skillsGlobalFlag, + &cli.BoolFlag{ + Name: "force", + Usage: "Also remove skills you've edited locally", + }, + jsonFlag, + }, + Action: skillsRemove, + }, + }, + }, + } +) + +// skillsSession is the context shared by every skills subcommand. +type skillsSession struct { + env *skills.Env + scope skills.Scope + json bool +} + +func newSkillsSession(cmd *cli.Command) (*skillsSession, error) { + env, err := skills.DefaultEnv() + if err != nil { + return nil, err + } + s := &skillsSession{env: env, json: cmd.Bool("json")} + if cmd.Bool("global") { + s.scope = skills.ScopeGlobal + } + return s, nil +} + +func (s *skillsSession) fetch(ctx context.Context, ref string) (*skills.Bundle, error) { + var b *skills.Bundle + err := out.Await("Fetching skills from "+skills.SourceRepo+"...", ctx, func(ctx context.Context) error { + var err error + b, err = skills.Fetch(ctx, &http.Client{Timeout: 30 * time.Second}, ref) + return err + }) + if err != nil { + return nil, err + } + for _, msg := range b.Skipped { + out.Warnf("Skipping invalid upstream skill: %s", msg) + } + return b, nil +} + +// display shortens p for messages: relative to the project, or ~-prefixed. +func (s *skillsSession) display(p string) string { + if s.scope == skills.ScopeProject { + if rel, err := filepath.Rel(s.env.Root, p); err == nil && !strings.HasPrefix(rel, "..") { + return rel + } + } + if rel, err := filepath.Rel(s.env.Home, p); err == nil && !strings.HasPrefix(rel, "..") { + return filepath.Join("~", rel) + } + return p +} + +// allDirs is every skills directory any known agent reads in this scope. +func (s *skillsSession) allDirs() []skills.SkillsDir { + return skills.GroupSkillsDirs(s.env, s.scope, skills.Agents) +} + +// agentsFromFlag resolves --agent, or returns nil when it wasn't passed. +func agentsFromFlag(cmd *cli.Command) ([]*skills.Agent, error) { + var agents []*skills.Agent + for _, v := range cmd.StringSlice("agent") { + for id := range strings.SplitSeq(v, ",") { + id = strings.TrimSpace(id) + a := skills.FindAgent(id) + if a == nil { + return nil, fmt.Errorf("unknown agent %q; choose from %s", id, strings.Join(skills.AgentIDs(), ", ")) + } + if !slices.Contains(agents, a) { + agents = append(agents, a) + } + } + } + return agents, nil +} + +// chooseAgents resolves --agent, else asks (interactive) or uses the detected +// agents. +func (s *skillsSession) chooseAgents(cmd *cli.Command) ([]*skills.Agent, error) { + agents, err := agentsFromFlag(cmd) + if err != nil || len(agents) > 0 { + return agents, err + } + detected := skills.DetectAgents(s.env) + if SkipPrompts(cmd) { + if len(detected) == 0 { + return nil, fmt.Errorf("no coding agents detected; pass --agent (%s)", strings.Join(skills.AgentIDs(), ", ")) + } + out.Statusf("Detected %s", skills.AgentNames(detected)) + return detected, nil + } + agents, err = pickAgents("Install for which coding agents?", "Detected agents are preselected", detected) + if err != nil { + return nil, err + } + if len(agents) == 0 { + return nil, errors.New("no agents selected") + } + return agents, nil +} + +// pickAgents asks which agents to install for, with preselected checked. It +// may return none. +func pickAgents(title, description string, preselected []*skills.Agent) ([]*skills.Agent, error) { + var ids []string + options := make([]huh.Option[string], len(skills.Agents)) + for i, a := range skills.Agents { + options[i] = huh.NewOption(a.Name, a.ID).Selected(slices.Contains(preselected, a)) + } + if err := huh.NewForm(huh.NewGroup(huh.NewMultiSelect[string](). + Title(title). + Description(description). + Options(options...). + // Leave room for the title and description rows (see token.go). + Height(len(options) + 2). + Value(&ids). + WithTheme(util.FormTheme()))). + Run(); err != nil { + return nil, err + } + var agents []*skills.Agent + for _, id := range ids { + agents = append(agents, skills.FindAgent(id)) + } + return agents, nil +} + +// skillResult is one change (or non-change) to one copy of a skill. +type skillResult struct { + Skill string `json:"skill"` + Path string `json:"path"` + Agents []string `json:"agents"` + // Action is installed, updated, unchanged, removed, or skipped (edited + // locally; see --force). Skills LiveKit no longer publishes are removed, + // or skipped if edited. + Action string `json:"action"` +} + +type mcpResult struct { + Agent string `json:"agent"` + State string `json:"state"` + Path string `json:"path,omitempty"` + Added bool `json:"added,omitempty"` + Error string `json:"error,omitempty"` +} + +type skillsChangeOutput struct { + Scope string `json:"scope"` + Ref string `json:"ref,omitempty"` + Commit string `json:"commit,omitempty"` + Skills []skillResult `json:"skills"` + MCP []mcpResult `json:"mcp,omitempty"` + LockFile string `json:"lock_file,omitempty"` +} + +func agentIDs(agents []*skills.Agent) []string { + ids := make([]string, len(agents)) + for i, a := range agents { + ids[i] = a.ID + } + return ids +} + +// confirmUntracked asks before replacing LiveKit skills that no lock file +// records (typically copies committed to a starter template), since lk can't +// tell whether they were edited. Without a terminal, or with --force or +// --yes, it proceeds. +func confirmUntracked(cmd *cli.Command, s *skillsSession, copies []skills.Copy) (bool, error) { + var paths []string + for _, c := range copies { + if c.State == skills.StateUntracked { + paths = append(paths, s.display(c.Path())) + } + } + if len(paths) == 0 || cmd.Bool("force") || SkipPrompts(cmd) { + return true, nil + } + ok := true + if err := huh.NewForm(huh.NewGroup(util.Confirm(). + Title("Replace LiveKit skills that lk didn't install?"). + Description("These may be older copies (e.g. from a starter template), or have local edits:\n" + strings.Join(paths, "\n")). + Value(&ok). + WithTheme(util.FormTheme()))). + Run(); err != nil { + return false, err + } + return ok, nil +} + +// sync applies the upstream bundle to copies: writes missing and outdated +// ones (and edited ones with force, untracked ones with replaceUntracked) and +// deletes skills LiveKit no longer publishes. It records results in the lock +// but doesn't save it. +func (s *skillsSession) sync(in *skills.Installer, copies []skills.Copy, force, replaceUntracked bool) ([]skillResult, error) { + replaceUntracked = replaceUntracked || force + results := []skillResult{} + installed := map[string]bool{} + var removed []string + for _, c := range copies { + r := skillResult{Skill: c.Skill, Path: s.display(c.Path()), Agents: agentIDs(c.Dir.Agents)} + switch { + case c.State == skills.StateCurrent: + r.Action = "unchanged" + installed[c.Skill] = true + case c.State == skills.StateUntracked && !replaceUntracked: + r.Action = "skipped" + case c.State == skills.StateRemoved || (c.Upstream == nil && (force || c.State == skills.StateUntracked)): + if err := in.Delete(c); err != nil { + return nil, err + } + r.Action = "removed" + removed = append(removed, c.Skill) + case c.State == skills.StateModified && !force: + r.Action = "skipped" + default: + if err := in.Write(c); err != nil { + return nil, fmt.Errorf("installing %s: %w", c.Skill, err) + } + r.Action = "installed" + if c.State != skills.StateMissing { + r.Action = "updated" + } + installed[c.Skill] = true + } + results = append(results, r) + } + for name := range installed { + in.Record(in.Bundle.Find(name)) + } + for _, name := range removed { + if err := in.Forget(name); err != nil { + return nil, err + } + } + return results, nil +} + +// printResults reports changes; skipHint says what --force would do to the +// skipped (edited) copies. +func (s *skillsSession) printResults(results []skillResult, skipHint string) { + // One line per skill and action, listing the directories it applied to. + type group struct { + skill, action string + dirs []string + } + var groups []*group + var skipped []string + for _, r := range results { + switch r.Action { + case "unchanged": + continue + case "skipped": + skipped = append(skipped, r.Path) + continue + } + i := slices.IndexFunc(groups, func(g *group) bool { return g.skill == r.Skill && g.action == r.Action }) + if i < 0 { + groups = append(groups, &group{skill: r.Skill, action: r.Action}) + i = len(groups) - 1 + } + groups[i].dirs = append(groups[i].dirs, filepath.Dir(r.Path)) + } + for _, g := range groups { + verb := strings.ToUpper(g.action[:1]) + g.action[1:] + arrow := "→ " + if g.action == "removed" { + arrow = "from " + } + out.Statusf("%s %s %s", verb, util.Accented(g.skill), util.Dimmed(arrow+strings.Join(g.dirs, ", "))) + } + if len(skipped) > 0 { + out.Warnf("Left skills you've edited alone (pass --force to %s them): %s", skipHint, strings.Join(skipped, ", ")) + } +} + +func (s *skillsSession) printChange(o *skillsChangeOutput) error { + if s.json { + util.PrintJSON(o) + return nil + } + s.printResults(o.Skills, "overwrite") + if !slices.ContainsFunc(o.Skills, func(r skillResult) bool { return r.Action != "unchanged" && r.Action != "skipped" }) { + out.Statusf("Skills are up to date") + } + // One line for all agents: paths are in --json and lk skills list. + var added, manual, already []string + for _, m := range o.MCP { + switch skills.MCPState(m.State) { + case skills.MCPConfigured: + if m.Added { + added = append(added, m.Agent) + } else { + already = append(already, m.Agent) + } + case skills.MCPCustom: + out.Warnf("%s already has an MCP server named %q pointing elsewhere; left it alone (%s)", m.Agent, skills.MCPServerName, m.Path) + case skills.MCPUnsupported: + manual = append(manual, m.Agent) + default: + out.Warnf("Couldn't configure the Docs MCP server for %s: %s", m.Agent, m.Error) + } + } + if len(added) > 0 { + out.Statusf("Added the Docs MCP server for %s", strings.Join(added, ", ")) + } + if len(already) > 0 { + out.Statusf("Docs MCP server already set up for %s", strings.Join(already, ", ")) + } + if len(manual) > 0 { + out.Statusf("Add the Docs MCP server (%s) to %s by hand: %s", skills.MCPServerURL, strings.Join(manual, ", "), skills.MCPDocsURL) + } + return nil +} + +func skillsInstall(ctx context.Context, cmd *cli.Command) error { + s, err := newSkillsSession(cmd) + if err != nil { + return err + } + agents, err := s.chooseAgents(cmd) + if err != nil { + return err + } + o, err := s.install(ctx, installRequest{ + agents: agents, + names: cmd.Args().Slice(), + ref: cmd.String("ref"), + force: cmd.Bool("force"), + skipMCP: cmd.Bool("skip-mcp"), + confirm: func(copies []skills.Copy) (bool, error) { return confirmUntracked(cmd, s, copies) }, + }) + if err != nil { + return err + } + return s.printChange(o) +} + +type installRequest struct { + agents []*skills.Agent + names []string // empty for every published skill + ref string + force bool + skipMCP bool + // confirm approves replacing untracked LiveKit skills; nil approves. + confirm func([]skills.Copy) (bool, error) +} + +// install writes skills for req.agents and adds the Docs MCP server to them. +func (s *skillsSession) install(ctx context.Context, req installRequest) (*skillsChangeOutput, error) { + bundle, err := s.fetch(ctx, req.ref) + if err != nil { + return nil, err + } + names := req.names + for _, n := range names { + if bundle.Find(n) == nil { + return nil, fmt.Errorf("no skill named %q; available: %s", n, strings.Join(bundle.Names(), ", ")) + } + } + if len(names) == 0 { + names = bundle.Names() + } + + in, err := skills.NewInstaller(s.env, s.scope, bundle) + if err != nil { + return nil, err + } + targets := skills.GroupSkillsDirs(s.env, s.scope, req.agents) + all, err := in.Inspect(names, s.allDirs()) + if err != nil { + return nil, err + } + // Install into the chosen agents' directories. Unedited copies elsewhere + // are refreshed too: the lock holds one hash per skill, so leaving them + // stale would make them look edited from now on. + var copies []skills.Copy + for _, c := range all { + if slices.ContainsFunc(targets, func(d skills.SkillsDir) bool { return d.Path == c.Dir.Path }) || c.State == skills.StateOutdated { + copies = append(copies, c) + } + } + // Clean up skills LiveKit has since removed or renamed, wherever they are. + orphanNames, err := in.Orphans(s.allDirs()) + if err != nil { + return nil, err + } + orphans, err := in.Inspect(orphanNames, s.allDirs()) + if err != nil { + return nil, err + } + for _, c := range orphans { + if c.State != skills.StateMissing { + copies = append(copies, c) + } + } + replace := true + if req.confirm != nil { + if replace, err = req.confirm(copies); err != nil { + return nil, err + } + } + results, err := s.sync(in, copies, req.force, replace) + if err != nil { + return nil, err + } + if err := in.Save(); err != nil { + return nil, fmt.Errorf("writing %s: %w", in.Lock.Path, err) + } + + o := &skillsChangeOutput{ + Scope: s.scope.String(), Ref: bundle.Ref, Commit: bundle.Commit, + Skills: results, LockFile: s.display(in.Lock.Path), + } + if !req.skipMCP { + for _, a := range req.agents { + st, err := skills.ConfigureMCP(ctx, s.env, s.scope, a) + m := mcpResult{Agent: a.ID, State: string(st.State), Added: st.Added} + if st.Path != "" { + m.Path = s.display(st.Path) + } + if err != nil { + m.State, m.Error = "error", err.Error() + } + if !s.json { + m.Agent = a.Name + } + o.MCP = append(o.MCP, m) + } + } + return o, nil +} + +func skillsUpdate(ctx context.Context, cmd *cli.Command) error { + s, err := newSkillsSession(cmd) + if err != nil { + return err + } + bundle, err := s.fetch(ctx, cmd.String("ref")) + if err != nil { + return err + } + in, err := skills.NewInstaller(s.env, s.scope, bundle) + if err != nil { + return err + } + dirs := s.allDirs() + installed, err := in.InstalledLiveKitSkills(dirs) + if err != nil { + return err + } + names := cmd.Args().Slice() + for _, n := range names { + if !slices.Contains(installed, n) { + return fmt.Errorf("%s isn't installed; see lk skills list", n) + } + } + targeted := len(names) > 0 + if !targeted { + names = installed + } + current, err := in.Inspect(names, dirs) + if err != nil { + return err + } + // Directories that hold any LiveKit skill get the whole published set, + // so renamed and newly published skills arrive with the update. + var withSkills []skills.SkillsDir + var copies []skills.Copy + for _, c := range current { + if c.State == skills.StateMissing { + continue + } + copies = append(copies, c) + if !slices.ContainsFunc(withSkills, func(d skills.SkillsDir) bool { return d.Path == c.Dir.Path }) { + withSkills = append(withSkills, c.Dir) + } + } + if len(copies) == 0 { + return errors.New("no LiveKit skills are installed here; run lk skills install") + } + if !targeted { + var added []string + for _, n := range bundle.Names() { + if !slices.Contains(names, n) { + added = append(added, n) + } + } + fresh, err := in.Inspect(added, withSkills) + if err != nil { + return err + } + copies = append(copies, fresh...) + } + replace, err := confirmUntracked(cmd, s, copies) + if err != nil { + return err + } + results, err := s.sync(in, copies, cmd.Bool("force"), replace) + if err != nil { + return err + } + if err := in.Save(); err != nil { + return fmt.Errorf("writing %s: %w", in.Lock.Path, err) + } + return s.printChange(&skillsChangeOutput{ + Scope: s.scope.String(), Ref: bundle.Ref, Commit: bundle.Commit, + Skills: results, LockFile: s.display(in.Lock.Path), + }) +} + +type skillsListCopy struct { + Path string `json:"path"` + Agents []string `json:"agents"` + State string `json:"state"` +} + +type skillsListSkill struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Published bool `json:"published"` + Installed []skillsListCopy `json:"installed"` +} + +type skillsListOutput struct { + Scope string `json:"scope"` + Ref string `json:"ref"` + Commit string `json:"commit,omitempty"` + Skills []skillsListSkill `json:"skills"` + MCP []mcpResult `json:"mcp"` +} + +func skillsList(ctx context.Context, cmd *cli.Command) error { + s, err := newSkillsSession(cmd) + if err != nil { + return err + } + bundle, err := s.fetch(ctx, cmd.String("ref")) + if err != nil { + return err + } + in, err := skills.NewInstaller(s.env, s.scope, bundle) + if err != nil { + return err + } + dirs := s.allDirs() + installed, err := in.InstalledLiveKitSkills(dirs) + if err != nil { + return err + } + names := bundle.Names() + for _, n := range installed { + if !slices.Contains(names, n) { + names = append(names, n) + } + } + copies, err := in.Inspect(names, dirs) + if err != nil { + return err + } + + o := &skillsListOutput{Scope: s.scope.String(), Ref: bundle.Ref, Commit: bundle.Commit, Skills: []skillsListSkill{}} + for _, n := range names { + sk := skillsListSkill{Name: n, Installed: []skillsListCopy{}} + if up := bundle.Find(n); up != nil { + sk.Description, sk.Published = up.Description, true + } + for _, c := range copies { + if c.Skill == n && c.State != skills.StateMissing { + sk.Installed = append(sk.Installed, skillsListCopy{ + Path: s.display(c.Path()), Agents: agentIDs(c.Dir.Agents), State: string(c.State), + }) + } + } + o.Skills = append(o.Skills, sk) + } + // MCP status for agents that are installed or already configured. + detected := skills.DetectAgents(s.env) + for _, a := range skills.Agents { + st := skills.CheckMCP(s.env, s.scope, a) + if !slices.Contains(detected, a) && st.State != skills.MCPConfigured { + continue + } + m := mcpResult{Agent: a.ID, State: string(st.State)} + if st.Path != "" { + m.Path = s.display(st.Path) + } + if st.Err != nil { + m.State, m.Error = "error", st.Err.Error() + } + o.MCP = append(o.MCP, m) + } + + if s.json { + util.PrintJSON(o) + return nil + } + return printSkillsList(o) +} + +func printSkillsList(o *skillsListOutput) error { + t := util.CreateTable().Headers("Skill", "Installed") + var outdated, missing bool + for _, sk := range o.Skills { + var where []string + for _, c := range sk.Installed { + label := filepath.Dir(c.Path) + if c.State != string(skills.StateCurrent) { + label += " (" + c.State + ")" + } + if c.State == string(skills.StateOutdated) || c.State == string(skills.StateRemoved) || c.State == string(skills.StateUntracked) { + outdated = true + } + where = append(where, label) + } + if len(where) == 0 { + where = []string{"-"} + missing = true + } + name := sk.Name + if !sk.Published { + name += " (no longer published)" + } + t.Row(name, strings.Join(where, "\n")) + } + out.Result(t) + + if len(o.MCP) > 0 { + mt := util.CreateTable().Headers("Agent", "Docs MCP", "Config") + for _, m := range o.MCP { + name := m.Agent + if a := skills.FindAgent(m.Agent); a != nil { + name = a.Name + } + state := m.State + if m.Error != "" { + state = "unreadable: " + m.Error + } + if m.State == string(skills.MCPUnsupported) { + state = "set up by hand" + m.Path = skills.MCPDocsURL + } + mt.Row(name, state, m.Path) + } + out.Result(mt) + } + switch { + case outdated: + out.Statusf("Run %s to get the latest skills", util.Accented("lk skills update")) + case missing: + out.Statusf("Run %s to install skills", util.Accented("lk skills install")) + } + return nil +} + +func skillsRemove(ctx context.Context, cmd *cli.Command) error { + s, err := newSkillsSession(cmd) + if err != nil { + return err + } + agents, err := agentsFromFlag(cmd) + if err != nil { + return err + } + dirs := s.allDirs() + if len(agents) > 0 { + dirs = skills.GroupSkillsDirs(s.env, s.scope, agents) + } + // No download: what's installed is known from the lock and the skills' + // own frontmatter. + in, err := skills.NewInstaller(s.env, s.scope, nil) + if err != nil { + return err + } + installed, err := in.InstalledLiveKitSkills(dirs) + if err != nil { + return err + } + names := cmd.Args().Slice() + for _, n := range names { + if !slices.Contains(installed, n) { + return fmt.Errorf("%s isn't installed; see lk skills list", n) + } + } + if len(names) == 0 { + names = installed + } + copies, err := in.Inspect(names, dirs) + if err != nil { + return err + } + force := cmd.Bool("force") + var remove []skills.Copy + results := []skillResult{} + for _, c := range copies { + switch { + case c.State == skills.StateMissing: + case c.State == skills.StateModified && !force: + // Deleting edits can't be undone; treat them as update does. + results = append(results, skillResult{ + Skill: c.Skill, Path: s.display(c.Path()), Agents: agentIDs(c.Dir.Agents), Action: "skipped", + }) + default: + remove = append(remove, c) + } + } + if len(remove) == 0 && len(results) == 0 { + if s.json { + util.PrintJSON(&skillsChangeOutput{Scope: s.scope.String(), Skills: results}) + } else { + out.Statusf("No LiveKit skills installed") + } + return nil + } + + if len(remove) > 0 && !SkipPrompts(cmd) { + var paths []string + for _, c := range remove { + label := s.display(c.Path()) + switch c.State { + case skills.StateModified: + label += " (edited)" + case skills.StateUntracked: + label += " (not installed by lk)" + } + paths = append(paths, label) + } + ok := false + if err := huh.NewForm(huh.NewGroup(util.Confirm(). + Title(fmt.Sprintf("Remove %d skill directories?", len(remove))). + Description(strings.Join(paths, "\n")). + Value(&ok). + WithTheme(util.FormTheme()))). + Run(); err != nil { + return err + } + if !ok { + return errors.New("cancelled") + } + } + + for _, c := range remove { + if err := in.Delete(c); err != nil { + return err + } + results = append(results, skillResult{ + Skill: c.Skill, Path: s.display(c.Path()), Agents: agentIDs(c.Dir.Agents), Action: "removed", + }) + } + // Forget keeps the lock entry of a skill with an edited copy left behind. + for _, n := range names { + if err := in.Forget(n); err != nil { + return err + } + } + if err := in.Save(); err != nil { + return fmt.Errorf("writing %s: %w", in.Lock.Path, err) + } + o := &skillsChangeOutput{Scope: s.scope.String(), Skills: results} + if s.json { + util.PrintJSON(o) + return nil + } + s.printResults(results, "remove") + return nil +} + +// setupProjectSkills installs skills and the Docs MCP server into a project +// fresh from lk agent init, for the coding agents on this machine. It asks first +// unless --skills was passed or there's no terminal; projects that aren't +// LiveKit agents are left alone. +func setupProjectSkills(ctx context.Context, cmd *cli.Command, dir string) error { + if cmd.IsSet("skills") && !cmd.Bool("skills") { + return nil + } + root, err := filepath.Abs(dir) + if err != nil { + return err + } + if !isAgentProject(root) { + return nil + } + env, err := skills.DefaultEnv() + if err != nil { + return err + } + env.Root = root + s := &skillsSession{env: env} + + agents := skills.DetectAgents(env) + if !cmd.IsSet("skills") && !SkipPrompts(cmd) { + // Detection only means an agent's config directory exists, which a + // tool tried once also leaves behind; let the user trim the list. + if agents, err = pickAgents( + "Install LiveKit skills for which coding agents?", + "Teaches them to build, test, and debug LiveKit agents, and adds the\nLiveKit Docs MCP server. Detected agents are preselected; select none to skip.", + agents, + ); err != nil { + return err + } + if len(agents) == 0 { + return nil + } + } else if len(agents) == 0 { + out.Statusf("No coding agents detected; run %s to add LiveKit skills later", util.Accented("lk skills install --agent AGENT")) + return nil + } + // A new project has no edits to lose, so skills the template shipped are + // replaced without asking. + o, err := s.install(ctx, installRequest{agents: agents, ref: skills.DefaultRef}) + if err != nil { + return err + } + return s.printChange(o) +} + +// isAgentProject reports whether dir depends on the LiveKit Agents SDK, or +// already ships LiveKit skills. +func isAgentProject(dir string) bool { + for file, dep := range map[string]string{ + "pyproject.toml": "livekit-agents", + "requirements.txt": "livekit-agents", + "package.json": "@livekit/agents", + } { + if data, err := os.ReadFile(filepath.Join(dir, file)); err == nil && strings.Contains(string(data), dep) { + return true + } + } + for _, p := range []string{".agents/skills", ".claude/skills"} { + if entries, err := os.ReadDir(filepath.Join(dir, p)); err == nil && len(entries) > 0 { + return true + } + } + return false +} diff --git a/cmd/lk/skills_test.go b/cmd/lk/skills_test.go new file mode 100644 index 00000000..e3ea2d6d --- /dev/null +++ b/cmd/lk/skills_test.go @@ -0,0 +1,241 @@ +// 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 ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/skills" +) + +// setHome points os.UserHomeDir at dir: $HOME, or %USERPROFILE% on Windows. +func setHome(t *testing.T, dir string) { + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) +} + +// skillsArchive builds a GitHub-style tarball of the named skills. +func skillsArchive(t *testing.T, names ...string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for _, name := range names { + body := "---\nname: " + name + "\ndescription: " + name + "\nmetadata:\n author: livekit\n version: \"1.0.0\"\n---\n" + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "agent-skills-main/skills/" + name + "/SKILL.md", Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg, + })) + _, err := tw.Write([]byte(body)) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + return buf.Bytes() +} + +// TestSkillsCommands runs install, update and remove end to end against a fake +// skills repo, in a scratch project and home directory. +func TestSkillsCommands(t *testing.T) { + archive := skillsArchive(t, "old-skill", "kept-skill") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(archive) + })) + defer srv.Close() + orig := skills.ArchiveURL + skills.ArchiveURL = func(ref string) string { return srv.URL + "/" + ref } + defer func() { skills.ArchiveURL = orig }() + + root := t.TempDir() + setHome(t, t.TempDir()) + t.Setenv("PATH", t.TempDir()) + t.Chdir(root) + + run := func(args ...string) { + t.Helper() + app := &cli.Command{Name: "lk", Flags: globalFlags, Commands: SkillsCommands, Writer: &bytes.Buffer{}, ErrWriter: &bytes.Buffer{}} + require.NoError(t, app.Run(context.Background(), append([]string{"lk"}, args...))) + } + + run("skills", "install", "-y", "--agent", "claude-code", "--agent", "codex") + for _, p := range []string{ + ".claude/skills/old-skill/SKILL.md", + ".agents/skills/kept-skill/SKILL.md", + "skills-lock.json", + ".mcp.json", + ".codex/config.toml", + } { + assert.FileExists(t, filepath.Join(root, p)) + } + + // Upstream renames old-skill to new-skill: update swaps them wherever + // LiveKit skills are installed. + archive = skillsArchive(t, "kept-skill", "new-skill") + run("skills", "update") + assert.NoDirExists(t, filepath.Join(root, ".claude/skills/old-skill")) + assert.NoDirExists(t, filepath.Join(root, ".agents/skills/old-skill")) + assert.FileExists(t, filepath.Join(root, ".claude/skills/new-skill/SKILL.md")) + assert.FileExists(t, filepath.Join(root, ".agents/skills/new-skill/SKILL.md")) + lock, err := os.ReadFile(filepath.Join(root, "skills-lock.json")) + require.NoError(t, err) + assert.NotContains(t, string(lock), "old-skill") + assert.Contains(t, string(lock), "new-skill") + + run("skills", "remove", "-y") + assert.NoDirExists(t, filepath.Join(root, ".claude/skills/kept-skill")) + assert.NoDirExists(t, filepath.Join(root, ".agents/skills/new-skill")) + assert.NoFileExists(t, filepath.Join(root, "skills-lock.json")) + // MCP config stays. + assert.FileExists(t, filepath.Join(root, ".mcp.json")) +} + +func TestSkillsInstallUnknownAgent(t *testing.T) { + app := &cli.Command{Name: "lk", Flags: globalFlags, Commands: SkillsCommands, Writer: &bytes.Buffer{}, ErrWriter: &bytes.Buffer{}} + err := app.Run(context.Background(), []string{"lk", "skills", "install", "--agent", "vim"}) + require.ErrorContains(t, err, `unknown agent "vim"`) +} + +// fakeSkillsRepo serves archive as livekit/agent-skills for the test. +func fakeSkillsRepo(t *testing.T, archive []byte) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(archive) + })) + t.Cleanup(srv.Close) + orig := skills.ArchiveURL + skills.ArchiveURL = func(ref string) string { return srv.URL + "/" + ref } + t.Cleanup(func() { skills.ArchiveURL = orig }) +} + +// writeTemplateSkill commits a LiveKit skill into dir the way starter +// templates used to: copied in, with no lock file. +func writeTemplateSkill(t *testing.T, dir, name string) { + t.Helper() + for _, sub := range []string{".agents/skills", ".claude/skills"} { + p := filepath.Join(dir, sub, name) + require.NoError(t, os.MkdirAll(p, 0o755)) + body := "---\nname: " + name + "\ndescription: old\nmetadata:\n author: livekit\n version: \"0.3.0\"\n---\n" + require.NoError(t, os.WriteFile(filepath.Join(p, "SKILL.md"), []byte(body), 0o644)) + } +} + +// Projects created from the old starters carry untracked copies of skills +// LiveKit has since renamed; update replaces them. +func TestSkillsUpdateReplacesTemplateCopies(t *testing.T) { + fakeSkillsRepo(t, skillsArchive(t, "new-skill")) + root := t.TempDir() + setHome(t, t.TempDir()) + t.Setenv("PATH", t.TempDir()) + t.Chdir(root) + writeTemplateSkill(t, root, "livekit-agents") + + app := &cli.Command{Name: "lk", Flags: globalFlags, Commands: SkillsCommands, Writer: &bytes.Buffer{}, ErrWriter: &bytes.Buffer{}} + require.NoError(t, app.Run(context.Background(), []string{"lk", "skills", "update"})) + assert.NoDirExists(t, filepath.Join(root, ".agents/skills/livekit-agents")) + assert.NoDirExists(t, filepath.Join(root, ".claude/skills/livekit-agents")) + assert.FileExists(t, filepath.Join(root, ".agents/skills/new-skill/SKILL.md")) + assert.FileExists(t, filepath.Join(root, ".claude/skills/new-skill/SKILL.md")) + assert.FileExists(t, filepath.Join(root, "skills-lock.json")) +} + +func TestSetupProjectSkills(t *testing.T) { + fakeSkillsRepo(t, skillsArchive(t, "new-skill")) + home := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o755)) + setHome(t, home) + t.Setenv("PATH", t.TempDir()) + t.Chdir(t.TempDir()) + + newProject := func(t *testing.T, deps string) string { + require.NoError(t, os.MkdirAll("app", 0o755)) + require.NoError(t, os.WriteFile(filepath.Join("app", "pyproject.toml"), []byte(deps), 0o644)) + return "app" + } + run := func(t *testing.T, dir string, args ...string) { + t.Helper() + app := &cli.Command{ + Name: "lk", + Flags: slices.Concat(globalFlags, []cli.Flag{skillsSetupFlag}), + Action: func(ctx context.Context, cmd *cli.Command) error { + return setupProjectSkills(ctx, cmd, dir) + }, + Writer: &bytes.Buffer{}, ErrWriter: &bytes.Buffer{}, + } + require.NoError(t, app.Run(context.Background(), append([]string{"lk"}, args...))) + } + + t.Run("agent project", func(t *testing.T) { + dir := newProject(t, `dependencies = ["livekit-agents[silero]~=1.3"]`) + defer os.RemoveAll(dir) + writeTemplateSkill(t, dir, "livekit-agents") + run(t, dir) + assert.FileExists(t, filepath.Join(dir, ".claude/skills/new-skill/SKILL.md")) + assert.NoDirExists(t, filepath.Join(dir, ".claude/skills/livekit-agents")) + assert.FileExists(t, filepath.Join(dir, ".mcp.json")) + }) + t.Run("opted out", func(t *testing.T) { + dir := newProject(t, `dependencies = ["livekit-agents"]`) + defer os.RemoveAll(dir) + run(t, dir, "--skills=false") + assert.NoDirExists(t, filepath.Join(dir, ".claude")) + }) + t.Run("not an agent project", func(t *testing.T) { + dir := newProject(t, `dependencies = ["fastapi"]`) + defer os.RemoveAll(dir) + run(t, dir) + assert.NoDirExists(t, filepath.Join(dir, ".claude")) + }) +} + +func TestSkillsRemoveKeepsEditedSkills(t *testing.T) { + fakeSkillsRepo(t, skillsArchive(t, "alpha", "beta")) + root := t.TempDir() + setHome(t, t.TempDir()) + t.Setenv("PATH", t.TempDir()) + t.Chdir(root) + run := func(args ...string) { + t.Helper() + app := &cli.Command{Name: "lk", Flags: globalFlags, Commands: SkillsCommands, Writer: &bytes.Buffer{}, ErrWriter: &bytes.Buffer{}} + require.NoError(t, app.Run(context.Background(), append([]string{"lk"}, args...))) + } + + run("skills", "install", "-y", "--skip-mcp", "--agent", "claude-code") + edited := filepath.Join(root, ".claude/skills/alpha/SKILL.md") + require.NoError(t, os.WriteFile(edited, []byte("---\nname: alpha\ndescription: mine now\nmetadata:\n author: livekit\n---\n"), 0o644)) + + run("skills", "remove", "-y") + assert.FileExists(t, edited) + assert.NoDirExists(t, filepath.Join(root, ".claude/skills/beta")) + lock, err := os.ReadFile(filepath.Join(root, "skills-lock.json")) + require.NoError(t, err) + assert.Contains(t, string(lock), `"alpha"`) + assert.NotContains(t, string(lock), `"beta"`) + + run("skills", "remove", "-y", "--force") + assert.NoDirExists(t, filepath.Join(root, ".claude/skills/alpha")) + assert.NoFileExists(t, filepath.Join(root, "skills-lock.json")) +} diff --git a/cmd/lk/utils.go b/cmd/lk/utils.go index 6e830fd7..4086e64f 100644 --- a/cmd/lk/utils.go +++ b/cmd/lk/utils.go @@ -110,6 +110,12 @@ var ( Name: "install", Usage: "Run installation after creating the application", } + // skillsSetupFlag controls whether lk agent init installs LiveKit's coding + // agent skills; unset, lk asks (or installs, without a terminal). + skillsSetupFlag = &cli.BoolFlag{ + Name: "skills", + Usage: "Install LiveKit skills and the Docs MCP server for your coding agents (see lk skills)", + } // roleFlag selects a member/invite access level for the Public API commands. roleFlag = &cli.StringFlag{ Name: "role", diff --git a/go.mod b/go.mod index 2ee88fd6..3d809f7f 100644 --- a/go.mod +++ b/go.mod @@ -33,10 +33,12 @@ require ( github.com/pion/webrtc/v4 v4.2.20 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/stretchr/testify v1.12.1 + github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f github.com/twitchtv/twirp v8.1.3+incompatible github.com/urfave/cli/v3 v3.9.0 go.uber.org/atomic v1.11.0 golang.org/x/sync v0.22.0 + golang.org/x/text v0.41.0 golang.org/x/time v0.15.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 @@ -251,7 +253,6 @@ require ( golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect - golang.org/x/text v0.41.0 // indirect golang.org/x/tools v0.49.0 // indirect google.golang.org/api v0.275.0 // indirect google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d // indirect diff --git a/go.sum b/go.sum index 35e1ab3c..e6e8aa92 100644 --- a/go.sum +++ b/go.sum @@ -573,6 +573,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f h1:9hiVElpCmKzsBKQHkBqZ8LGzt82iLfM8egxr4sew+Ys= +github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f/go.mod h1:8/zr1Tv0+cKpVtGCEB/7YfRXr2TszsMxMXLaT8YuBgU= github.com/tonistiigi/fsutil v0.0.0-20260717003753-6d9dc2ebad62 h1:uppBiK+tE8tYG6fc0N8VnsC7FMZcWxnXIyaQ9GcUIU8= github.com/tonistiigi/fsutil v0.0.0-20260717003753-6d9dc2ebad62/go.mod h1:K5zrLch9UaSGNiek5XHZeqZUf1zPWJHqDfLIcnpquQ4= github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 h1:2f304B10LaZdB8kkVEaoXvAMVan2tl9AiK4G0odjQtE= diff --git a/pkg/skills/agents.go b/pkg/skills/agents.go new file mode 100644 index 00000000..33477add --- /dev/null +++ b/pkg/skills/agents.go @@ -0,0 +1,296 @@ +// 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 skills installs LiveKit's agent skills (https://agentskills.io) and +// the LiveKit Docs MCP server into coding agents such as Claude Code, Codex and +// Cursor. +// +// Installs are plain copies, never symlinks, and are recorded in the same lock +// files the `skills` CLI (github.com/vercel-labs/skills) and `gh skill` use, so +// any of the three tools can manage what another installed. +package skills + +import ( + "os" + "os/exec" + "path/filepath" + "slices" + "strings" +) + +// Scope selects where skills and MCP config are written: the current project, +// or the user's home directory. +type Scope int + +const ( + ScopeProject Scope = iota + ScopeGlobal +) + +func (s Scope) String() string { + if s == ScopeGlobal { + return "global" + } + return "project" +} + +// Env is the filesystem context an install runs against. Tests point it at +// temporary directories. +type Env struct { + Home string // user home directory + Root string // project root (the working directory) + // Getenv and LookPath default to os.Getenv and exec.LookPath. + Getenv func(string) string + LookPath func(string) (string, error) +} + +// DefaultEnv returns an Env for the real user and working directory. +func DefaultEnv() (*Env, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + root, err := os.Getwd() + if err != nil { + return nil, err + } + return &Env{Home: home, Root: root}, nil +} + +func (e *Env) getenv(key string) string { + if e.Getenv != nil { + return e.Getenv(key) + } + return os.Getenv(key) +} + +func (e *Env) lookPath(file string) bool { + lookPath := e.LookPath + if lookPath == nil { + lookPath = exec.LookPath + } + _, err := lookPath(file) + return err == nil +} + +// homeDir returns $env if set, else ~/fallback. +func (e *Env) homeDir(env, fallback string) string { + if v := strings.TrimSpace(e.getenv(env)); v != "" { + return v + } + return filepath.Join(e.Home, fallback) +} + +func (e *Env) configHome() string { + return e.homeDir("XDG_CONFIG_HOME", ".config") +} + +func (e *Env) claudeHome() string { return e.homeDir("CLAUDE_CONFIG_DIR", ".claude") } +func (e *Env) codexHome() string { return e.homeDir("CODEX_HOME", ".codex") } + +// Agent describes one coding agent: where it reads skills from, how to tell it +// is installed, and how to register an MCP server with it. +type Agent struct { + ID string // stable identifier, matching the `skills` CLI's agent names + Name string // display name + + // projectSkills is relative to the project root. Many agents read the + // cross-agent .agents/skills directory, so they share one copy. + projectSkills string + globalSkills func(*Env) string + + // markers are paths whose existence means the agent is installed: in the + // home directory, or (for project dirs) in the project root. bins are + // executables looked up on PATH. + markers func(*Env) []string + bins []string + + mcp mcpTarget +} + +// SkillsDir returns the directory this agent reads skills from in scope. +func (a *Agent) SkillsDir(env *Env, scope Scope) string { + if scope == ScopeGlobal { + return a.globalSkills(env) + } + return filepath.Join(env.Root, a.projectSkills) +} + +// Detected reports whether the agent appears to be installed. +func (a *Agent) Detected(env *Env) bool { + for _, p := range a.markers(env) { + if _, err := os.Stat(p); err == nil { + return true + } + } + for _, b := range a.bins { + if env.lookPath(b) { + return true + } + } + return false +} + +func homePaths(rel ...string) func(*Env) []string { + return func(e *Env) []string { + out := make([]string, len(rel)) + for i, r := range rel { + out[i] = filepath.Join(e.Home, r) + } + return out + } +} + +// Agents is every agent lk knows how to install into, in display order. Skill +// paths follow the `skills` CLI's table so installs from either tool land in +// the same place. +var Agents = []*Agent{ + { + ID: "claude-code", Name: "Claude Code", + projectSkills: ".claude/skills", + globalSkills: func(e *Env) string { return filepath.Join(e.claudeHome(), "skills") }, + markers: func(e *Env) []string { return []string{e.claudeHome()} }, + bins: []string{"claude"}, + mcp: claudeMCP, + }, + { + ID: "codex", Name: "Codex", + projectSkills: ".agents/skills", + // Codex reads user skills from ~/.agents/skills (and the older + // $CODEX_HOME/skills); prefer the cross-agent directory. + globalSkills: func(e *Env) string { return filepath.Join(e.Home, ".agents", "skills") }, + markers: func(e *Env) []string { return []string{e.codexHome()} }, + bins: []string{"codex"}, + mcp: codexMCP, + }, + { + ID: "cursor", Name: "Cursor", + projectSkills: ".agents/skills", + globalSkills: func(e *Env) string { return filepath.Join(e.Home, ".cursor", "skills") }, + markers: homePaths(".cursor"), + bins: []string{"cursor-agent"}, + mcp: cursorMCP, + }, + { + ID: "github-copilot", Name: "GitHub Copilot", + projectSkills: ".agents/skills", + globalSkills: func(e *Env) string { return filepath.Join(e.Home, ".copilot", "skills") }, + markers: homePaths(".copilot"), + bins: []string{"copilot"}, + mcp: copilotMCP, + }, + { + ID: "gemini-cli", Name: "Gemini CLI", + projectSkills: ".agents/skills", + globalSkills: func(e *Env) string { return filepath.Join(e.Home, ".gemini", "skills") }, + markers: homePaths(".gemini"), + bins: []string{"gemini"}, + mcp: geminiMCP, + }, + { + ID: "opencode", Name: "OpenCode", + projectSkills: ".agents/skills", + globalSkills: func(e *Env) string { return filepath.Join(e.configHome(), "opencode", "skills") }, + markers: func(e *Env) []string { return []string{filepath.Join(e.configHome(), "opencode")} }, + bins: []string{"opencode"}, + mcp: opencodeMCP, + }, + { + ID: "windsurf", Name: "Windsurf", + projectSkills: ".windsurf/skills", + globalSkills: func(e *Env) string { return filepath.Join(e.Home, ".codeium", "windsurf", "skills") }, + markers: homePaths(filepath.Join(".codeium", "windsurf")), + mcp: windsurfMCP, + }, + { + ID: "amp", Name: "Amp", + projectSkills: ".agents/skills", + globalSkills: func(e *Env) string { return filepath.Join(e.configHome(), "agents", "skills") }, + markers: func(e *Env) []string { return []string{filepath.Join(e.configHome(), "amp")} }, + bins: []string{"amp"}, + }, + { + ID: "cline", Name: "Cline", + projectSkills: ".agents/skills", + globalSkills: func(e *Env) string { return filepath.Join(e.Home, ".agents", "skills") }, + markers: homePaths(".cline"), + }, + { + ID: "goose", Name: "Goose", + projectSkills: ".goose/skills", + globalSkills: func(e *Env) string { return filepath.Join(e.configHome(), "goose", "skills") }, + markers: func(e *Env) []string { return []string{filepath.Join(e.configHome(), "goose")} }, + bins: []string{"goose"}, + }, +} + +// AgentIDs lists every known agent ID. +func AgentIDs() []string { + ids := make([]string, len(Agents)) + for i, a := range Agents { + ids[i] = a.ID + } + return ids +} + +// FindAgent looks an agent up by ID. +func FindAgent(id string) *Agent { + for _, a := range Agents { + if a.ID == id { + return a + } + } + return nil +} + +// DetectAgents returns the agents that appear to be installed. +func DetectAgents(env *Env) []*Agent { + var out []*Agent + for _, a := range Agents { + if a.Detected(env) { + out = append(out, a) + } + } + return out +} + +// SkillsDirs groups agents by the directory they read skills from, so a shared +// directory like .agents/skills gets a single copy. Order follows agents. +type SkillsDir struct { + Path string + Agents []*Agent +} + +func GroupSkillsDirs(env *Env, scope Scope, agents []*Agent) []SkillsDir { + var dirs []SkillsDir + for _, a := range agents { + p := a.SkillsDir(env, scope) + i := slices.IndexFunc(dirs, func(d SkillsDir) bool { return d.Path == p }) + if i < 0 { + dirs = append(dirs, SkillsDir{Path: p}) + i = len(dirs) - 1 + } + dirs[i].Agents = append(dirs[i].Agents, a) + } + return dirs +} + +// AgentNames joins agents' display names for messages. +func AgentNames(agents []*Agent) string { + names := make([]string, len(agents)) + for i, a := range agents { + names[i] = a.Name + } + return strings.Join(names, ", ") +} diff --git a/pkg/skills/hash.go b/pkg/skills/hash.go new file mode 100644 index 00000000..e6ef7905 --- /dev/null +++ b/pkg/skills/hash.go @@ -0,0 +1,166 @@ +// 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 skills + +import ( + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "slices" + "sort" + "strings" + + "golang.org/x/text/collate" + "golang.org/x/text/language" +) + +// ContentHash is the `skills` CLI's project lock hash (computedHash): SHA-256 +// over each file's relative path followed by its contents, in the order +// JavaScript's String.prototype.localeCompare sorts the paths. Matching it +// exactly lets lk recognize skills that `npx skills` installed, and vice versa. +func ContentHash(files []File) string { + sorted := slices.Clone(files) + c := collate.New(language.Und) + sort.SliceStable(sorted, func(i, j int) bool { + return c.CompareString(sorted[i].Path, sorted[j].Path) < 0 + }) + h := sha256.New() + for _, f := range sorted { + h.Write([]byte(f.Path)) + h.Write(f.Data) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// TreeHash is the git tree SHA-1 of a skill directory, which the `skills` +// CLI's global lock records as skillFolderHash. +func TreeHash(files []File) string { + root := &treeNode{children: map[string]*treeNode{}} + for _, f := range files { + n := root + parts := strings.Split(f.Path, "/") + for _, p := range parts[:len(parts)-1] { + child := n.children[p] + if child == nil { + child = &treeNode{children: map[string]*treeNode{}} + n.children[p] = child + } + n = child + } + n.children[parts[len(parts)-1]] = &treeNode{file: &f} + } + return hex.EncodeToString(root.hash()) +} + +type treeNode struct { + file *File + children map[string]*treeNode +} + +func (n *treeNode) hash() []byte { + if n.file != nil { + return gitObject("blob", n.file.Data) + } + // git orders tree entries by name, comparing directories as if their + // name ended in "/". + names := make([]string, 0, len(n.children)) + for name := range n.children { + names = append(names, name) + } + key := func(name string) string { + if n.children[name].file == nil { + return name + "/" + } + return name + } + sort.Slice(names, func(i, j int) bool { return key(names[i]) < key(names[j]) }) + var body []byte + for _, name := range names { + child := n.children[name] + mode := "40000" + if child.file != nil { + mode = "100644" + if child.file.Mode&0o111 != 0 { + mode = "100755" + } + } + body = append(body, mode+" "+name+"\x00"...) + body = append(body, child.hash()...) + } + return gitObject("tree", body) +} + +func gitObject(kind string, data []byte) []byte { + h := sha1.New() + fmt.Fprintf(h, "%s %d\x00", kind, len(data)) + h.Write(data) + return h.Sum(nil) +} + +// readDir loads an installed skill directory from disk. dir itself may be a +// symlink (the `skills` CLI links agent directories to .agents/skills); .git +// and node_modules are skipped, as the `skills` CLI does when hashing. +// +// modes supplies file modes where the filesystem has none (Windows), keyed by +// relative path; it is usually the upstream skill's files. +func readDir(dir string, modes []File) ([]File, error) { + var files []File + root, err := filepath.EvalSymlinks(dir) + if err != nil { + return nil, err + } + err = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if p != root && (d.Name() == ".git" || d.Name() == "node_modules") { + return filepath.SkipDir + } + return nil + } + if !d.Type().IsRegular() { + return nil + } + rel, err := filepath.Rel(root, p) + if err != nil { + return err + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + info, err := d.Info() + if err != nil { + return err + } + f := File{Path: filepath.ToSlash(rel), Mode: 0o644, Data: data} + if runtime.GOOS == "windows" { + if i := slices.IndexFunc(modes, func(m File) bool { return m.Path == f.Path }); i >= 0 { + f.Mode = modes[i].Mode + } + } else if info.Mode()&0o111 != 0 { + f.Mode = 0o755 + } + files = append(files, f) + return nil + }) + return files, err +} diff --git a/pkg/skills/install.go b/pkg/skills/install.go new file mode 100644 index 00000000..4e343ce2 --- /dev/null +++ b/pkg/skills/install.go @@ -0,0 +1,257 @@ +// 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 skills + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "time" +) + +// State is how an installed copy of a skill compares to upstream. +type State string + +const ( + StateMissing State = "missing" // not installed in this directory + StateCurrent State = "current" // matches upstream + StateOutdated State = "outdated" // unchanged since install; upstream has moved on + StateModified State = "modified" // differs from both upstream and what was installed + StateRemoved State = "removed" // installed from LiveKit, but no longer published + // StateUntracked is a LiveKit skill (by its frontmatter) that no lock file + // records, e.g. one committed to a starter template. It can't be told + // apart from an edited copy, so replacing it asks first. + StateUntracked State = "untracked" +) + +// Copy is one skill in one skills directory. +type Copy struct { + Skill string + Dir SkillsDir + State State + Upstream *Skill // nil when the skill is no longer published +} + +// Path is the skill's directory. +func (c *Copy) Path() string { return filepath.Join(c.Dir.Path, c.Skill) } + +// Installer compares and applies skills for one scope. +type Installer struct { + Env *Env + Scope Scope + Bundle *Bundle + Lock *Lock + Now func() time.Time +} + +// NewInstaller loads the scope's lock file. bundle may be nil for operations +// that don't need upstream (remove). +func NewInstaller(env *Env, scope Scope, bundle *Bundle) (*Installer, error) { + lock, err := LoadLock(env, scope) + if err != nil { + return nil, err + } + return &Installer{Env: env, Scope: scope, Bundle: bundle, Lock: lock, Now: time.Now}, nil +} + +func (in *Installer) hash(files []File) string { + if in.Scope == ScopeGlobal { + return TreeHash(files) + } + return ContentHash(files) +} + +// Inspect reports the state of each named skill in each directory. +func (in *Installer) Inspect(names []string, dirs []SkillsDir) ([]Copy, error) { + var copies []Copy + for _, name := range names { + var up *Skill + if in.Bundle != nil { + up = in.Bundle.Find(name) + } + for _, d := range dirs { + c, err := in.inspect(name, up, d) + if err != nil { + return nil, err + } + copies = append(copies, c) + } + } + return copies, nil +} + +func (in *Installer) inspect(name string, up *Skill, d SkillsDir) (Copy, error) { + c := Copy{Skill: name, Dir: d, Upstream: up, State: StateMissing} + // Stat follows symlinks, so a dangling link counts as missing (and Write + // replaces it). + if _, err := os.Stat(c.Path()); errors.Is(err, os.ErrNotExist) { + return c, nil + } + var modes []File + if up != nil { + modes = up.Files + } + files, err := readDir(c.Path(), modes) + if err != nil { + return c, fmt.Errorf("reading %s: %w", c.Path(), err) + } + livekit := false + for _, f := range files { + if f.Path == "SKILL.md" { + if fm, err := ParseFrontmatter(f.Data); err == nil { + livekit = fm.Metadata["author"] == "livekit" && fm.Name == name + } + } + } + have := in.hash(files) + recorded := in.Lock.Hash(name) + switch { + case up != nil && have == in.hash(up.Files): + c.State = StateCurrent + case recorded == "" && livekit: + c.State = StateUntracked + case up == nil && have == recorded: + c.State = StateRemoved + case up != nil && have == recorded: + c.State = StateOutdated + default: + c.State = StateModified + } + return c, nil +} + +// Orphans lists LiveKit skills installed in dirs that upstream no longer +// publishes (removed or renamed). +func (in *Installer) Orphans(dirs []SkillsDir) ([]string, error) { + installed, err := in.InstalledLiveKitSkills(dirs) + if err != nil { + return nil, err + } + var out []string + for _, name := range installed { + if in.Bundle != nil && in.Bundle.Find(name) == nil { + out = append(out, name) + } + } + return out, nil +} + +// Write installs (or overwrites) the upstream skill into c's directory. +func (in *Installer) Write(c Copy) error { + if c.Upstream == nil { + return fmt.Errorf("%s is not published", c.Skill) + } + if err := os.MkdirAll(c.Dir.Path, 0o755); err != nil { + return err + } + // Stage next to the destination, then swap, so an interrupted install + // never leaves a half-written skill for an agent to load. + tmp, err := os.MkdirTemp(c.Dir.Path, "."+c.Skill+".lk-") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + for _, f := range c.Upstream.Files { + p := filepath.Join(tmp, filepath.FromSlash(f.Path)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + if err := os.WriteFile(p, f.Data, os.FileMode(f.Mode)); err != nil { + return err + } + } + if err := os.Chmod(tmp, 0o755); err != nil { + return err + } + // RemoveAll on a symlink (as `npx skills` creates) removes only the link. + if err := os.RemoveAll(c.Path()); err != nil { + return err + } + return os.Rename(tmp, c.Path()) +} + +// Delete removes c's directory. +func (in *Installer) Delete(c Copy) error { + return os.RemoveAll(c.Path()) +} + +// Record notes an installed skill in the lock. +func (in *Installer) Record(s *Skill) { + in.Lock.Record(s, in.Bundle.Ref, in.Now()) +} + +// Forget drops name from the lock unless a copy remains in one of the scope's +// known directories. +func (in *Installer) Forget(name string) error { + copies, err := in.Inspect([]string{name}, GroupSkillsDirs(in.Env, in.Scope, Agents)) + if err != nil { + return err + } + if !slices.ContainsFunc(copies, func(c Copy) bool { return c.State != StateMissing }) { + in.Lock.Remove(name) + } + return nil +} + +// Save writes the lock file. +func (in *Installer) Save() error { return in.Lock.Save() } + +// InstalledLiveKitSkills finds LiveKit skills present in dirs: those the lock +// attributes to LiveKit, those upstream publishes, and any SKILL.md whose +// frontmatter names LiveKit as its author. +func (in *Installer) InstalledLiveKitSkills(dirs []SkillsDir) ([]string, error) { + seen := map[string]bool{} + for _, n := range in.Lock.LiveKitSkills() { + seen[n] = true + } + if in.Bundle != nil { + for _, n := range in.Bundle.Names() { + seen[n] = true + } + } + for _, d := range dirs { + entries, err := os.ReadDir(d.Path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return nil, err + } + for _, e := range entries { + data, err := os.ReadFile(filepath.Join(d.Path, e.Name(), "SKILL.md")) + if err != nil { + continue + } + if fm, err := ParseFrontmatter(data); err == nil && fm.Metadata["author"] == "livekit" && fm.Name == e.Name() { + seen[e.Name()] = true + } + } + } + var names []string + for n := range seen { + copies, err := in.Inspect([]string{n}, dirs) + if err != nil { + return nil, err + } + if slices.ContainsFunc(copies, func(c Copy) bool { return c.State != StateMissing }) { + names = append(names, n) + } + } + sort.Strings(names) + return names, nil +} diff --git a/pkg/skills/lock.go b/pkg/skills/lock.go new file mode 100644 index 00000000..348fb569 --- /dev/null +++ b/pkg/skills/lock.go @@ -0,0 +1,248 @@ +// 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 skills + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "time" +) + +// Lock files are shared with the `skills` CLI and `gh skill`: +// +// - project: ./skills-lock.json (version 1), meant to be committed. Entries +// carry computedHash (ContentHash) and no timestamps, to merge cleanly. +// - global: ~/.agents/.skill-lock.json (version 3), or +// $XDG_STATE_HOME/skills/.skill-lock.json. Entries carry skillFolderHash +// (TreeHash) and timestamps. +// +// Only entries for LiveKit's skills are ever rewritten; everything else in the +// file, including other tools' top-level fields, is preserved as-is. +const ( + projectLockName = "skills-lock.json" + projectLockVersion = 1 + globalLockName = ".skill-lock.json" + globalLockVersion = 3 +) + +// LockEntry is one skill's record. Fields are the union of both lock formats. +type LockEntry struct { + Source string `json:"source"` + SourceType string `json:"sourceType"` + SourceURL string `json:"sourceUrl,omitempty"` + Ref string `json:"ref,omitempty"` + SkillPath string `json:"skillPath,omitempty"` + ComputedHash string `json:"computedHash,omitempty"` + SkillFolderHash string `json:"skillFolderHash,omitempty"` + InstalledAt string `json:"installedAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +// FromLiveKit reports whether the entry was installed from LiveKit's repo, by +// any tool. +func (e *LockEntry) FromLiveKit() bool { return e.Source == SourceRepo } + +// Lock is a lock file loaded for editing. +type Lock struct { + Path string + scope Scope + // top holds every top-level field; skills holds each skill entry verbatim. + top map[string]json.RawMessage + skills map[string]json.RawMessage +} + +func lockPath(env *Env, scope Scope) string { + if scope == ScopeGlobal { + if state := env.getenv("XDG_STATE_HOME"); state != "" { + return filepath.Join(state, "skills", globalLockName) + } + return filepath.Join(env.Home, ".agents", globalLockName) + } + return filepath.Join(env.Root, projectLockName) +} + +// LoadLock reads the lock file for scope. A missing file is an empty lock; a +// file that isn't valid JSON is an error rather than something to overwrite. +func LoadLock(env *Env, scope Scope) (*Lock, error) { + l := &Lock{Path: lockPath(env, scope), scope: scope, top: map[string]json.RawMessage{}, skills: map[string]json.RawMessage{}} + data, err := os.ReadFile(l.Path) + if errors.Is(err, os.ErrNotExist) { + return l, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &l.top); err != nil { + return nil, fmt.Errorf("%s is not valid JSON: %w", l.Path, err) + } + if raw, ok := l.top["skills"]; ok { + if err := json.Unmarshal(raw, &l.skills); err != nil { + return nil, fmt.Errorf("%s: invalid skills: %w", l.Path, err) + } + } + return l, nil +} + +// Get returns the entry for name, if any. +func (l *Lock) Get(name string) (*LockEntry, bool) { + raw, ok := l.skills[name] + if !ok { + return nil, false + } + var e LockEntry + if json.Unmarshal(raw, &e) != nil { + return nil, false + } + return &e, true +} + +// LiveKitSkills lists the names of entries installed from LiveKit's repo. +func (l *Lock) LiveKitSkills() []string { + var names []string + for name := range l.skills { + if e, ok := l.Get(name); ok && e.FromLiveKit() { + names = append(names, name) + } + } + return names +} + +// Hash is the recorded hash for name in this lock's format. +func (l *Lock) Hash(name string) string { + e, ok := l.Get(name) + if !ok { + return "" + } + if l.scope == ScopeGlobal { + return e.SkillFolderHash + } + return e.ComputedHash +} + +// Record writes the entry for a skill installed from bundle. +func (l *Lock) Record(s *Skill, ref string, now time.Time) { + e := LockEntry{ + Source: SourceRepo, + SourceType: "github", + SkillPath: s.SkillPath(), + } + if ref != DefaultRef { + e.Ref = ref + } + if l.scope == ScopeGlobal { + // The global format requires sourceUrl; the project one omits it for + // GitHub sources. + e.SourceURL = sourceURL() + e.SkillFolderHash = TreeHash(s.Files) + ts := now.UTC().Format(time.RFC3339Nano) + e.InstalledAt, e.UpdatedAt = ts, ts + if prev, ok := l.Get(s.Name); ok && prev.InstalledAt != "" { + e.InstalledAt = prev.InstalledAt + } + } else { + e.ComputedHash = ContentHash(s.Files) + } + raw, _ := json.Marshal(e) + l.skills[s.Name] = raw +} + +// Remove drops the entry for name. +func (l *Lock) Remove(name string) { delete(l.skills, name) } + +// Save writes the lock file, or deletes a project lock that has become empty +// and was only ever lk's. +func (l *Lock) Save() error { + version := projectLockVersion + if l.scope == ScopeGlobal { + version = globalLockVersion + } + var existing int + if raw, ok := l.top["version"]; ok && json.Unmarshal(raw, &existing) == nil && existing > version { + version = existing + } + if l.scope == ScopeProject && len(l.skills) == 0 && len(l.top) <= 2 { + if err := os.Remove(l.Path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil + } + l.top["version"], _ = json.Marshal(version) + l.top["skills"], _ = json.Marshal(l.skills) + data, err := marshalLock(l.top) + if err != nil { + return err + } + return writeFileAtomic(l.Path, data, 0o644) +} + +// marshalLock writes version and skills first, as the `skills` CLI does, then +// any other fields; keys within are sorted, keeping the file deterministic. +func marshalLock(top map[string]json.RawMessage) ([]byte, error) { + keys := []string{"version", "skills"} + var rest []string + for k := range top { + if k != "version" && k != "skills" { + rest = append(rest, k) + } + } + sort.Strings(rest) + var b bytes.Buffer + b.WriteString("{") + for i, k := range append(keys, rest...) { + if i > 0 { + b.WriteString(",") + } + var v bytes.Buffer + if err := json.Indent(&v, top[k], " ", " "); err != nil { + return nil, err + } + name, _ := json.Marshal(k) + fmt.Fprintf(&b, "\n %s: %s", name, v.Bytes()) + } + b.WriteString("\n}\n") + return b.Bytes(), nil +} + +// writeFileAtomic replaces path via a temp file in the same directory, so a +// crash never leaves a half-written file behind. +func writeFileAtomic(path string, data []byte, perm os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if info, err := os.Stat(path); err == nil { + perm = info.Mode().Perm() + } + if err := os.Chmod(tmp.Name(), perm); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} diff --git a/pkg/skills/mcp.go b/pkg/skills/mcp.go new file mode 100644 index 00000000..78a1e2b7 --- /dev/null +++ b/pkg/skills/mcp.go @@ -0,0 +1,377 @@ +// 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 skills + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + toml "github.com/pelletier/go-toml/v2" + "github.com/tailscale/hujson" +) + +const ( + // MCPServerName is the name the Docs MCP server is registered under, + // matching the setup instructions on docs.livekit.io. + MCPServerName = "livekit-docs" + MCPServerURL = "https://docs.livekit.io/mcp" + // MCPDocsURL covers manual setup for agents lk can't configure. + MCPDocsURL = "https://docs.livekit.io/intro/mcp-server/" +) + +// MCPState is whether an agent has the Docs MCP server configured. +type MCPState string + +const ( + MCPConfigured MCPState = "configured" + MCPMissing MCPState = "missing" + MCPCustom MCPState = "custom" // an entry with our name points somewhere else; left alone + MCPUnsupported MCPState = "unsupported" // lk can't configure this agent in this scope +) + +type configFormat int + +const ( + formatJSON configFormat = iota + formatTOML +) + +// mcpConfig is one agent's MCP config file. +type mcpConfig struct { + path string + format configFormat + key string // the servers table: mcpServers, servers, mcp, mcp_servers + entry map[string]any + // cli, when set and on PATH, is used to add the server instead of editing + // path directly: for files the agent itself rewrites while running. + cli []string +} + +type mcpTarget struct { + project func(*Env) *mcpConfig + global func(*Env) *mcpConfig +} + +func (a *Agent) mcpConfig(env *Env, scope Scope) *mcpConfig { + f := a.mcp.project + if scope == ScopeGlobal { + f = a.mcp.global + } + if f == nil { + return nil + } + return f(env) +} + +func jsonConfig(path, key string, entry map[string]any) *mcpConfig { + return &mcpConfig{path: path, format: formatJSON, key: key, entry: entry} +} + +var ( + httpEntry = map[string]any{"type": "http", "url": MCPServerURL} + + claudeMCP = mcpTarget{ + project: func(e *Env) *mcpConfig { + return jsonConfig(filepath.Join(e.Root, ".mcp.json"), "mcpServers", httpEntry) + }, + global: func(e *Env) *mcpConfig { + c := jsonConfig(filepath.Join(e.Home, ".claude.json"), "mcpServers", httpEntry) + c.cli = []string{"claude", "mcp", "add", "--scope", "user", "--transport", "http", MCPServerName, MCPServerURL} + return c + }, + } + codexMCP = mcpTarget{ + project: func(e *Env) *mcpConfig { + return &mcpConfig{path: filepath.Join(e.Root, ".codex", "config.toml"), format: formatTOML, key: "mcp_servers"} + }, + global: func(e *Env) *mcpConfig { + return &mcpConfig{path: filepath.Join(e.codexHome(), "config.toml"), format: formatTOML, key: "mcp_servers"} + }, + } + cursorMCP = mcpTarget{ + project: func(e *Env) *mcpConfig { + return jsonConfig(filepath.Join(e.Root, ".cursor", "mcp.json"), "mcpServers", map[string]any{"url": MCPServerURL}) + }, + global: func(e *Env) *mcpConfig { + return jsonConfig(filepath.Join(e.Home, ".cursor", "mcp.json"), "mcpServers", map[string]any{"url": MCPServerURL}) + }, + } + // Copilot: VS Code's workspace file for projects, the Copilot CLI's + // config for the user. + copilotMCP = mcpTarget{ + project: func(e *Env) *mcpConfig { + return jsonConfig(filepath.Join(e.Root, ".vscode", "mcp.json"), "servers", httpEntry) + }, + global: func(e *Env) *mcpConfig { + return jsonConfig(filepath.Join(e.Home, ".copilot", "mcp-config.json"), "mcpServers", + map[string]any{"type": "http", "url": MCPServerURL, "tools": []string{"*"}}) + }, + } + geminiMCP = mcpTarget{ + project: func(e *Env) *mcpConfig { + return jsonConfig(filepath.Join(e.Root, ".gemini", "settings.json"), "mcpServers", map[string]any{"httpUrl": MCPServerURL}) + }, + global: func(e *Env) *mcpConfig { + return jsonConfig(filepath.Join(e.Home, ".gemini", "settings.json"), "mcpServers", map[string]any{"httpUrl": MCPServerURL}) + }, + } + opencodeMCP = mcpTarget{ + project: func(e *Env) *mcpConfig { return opencodeConfig(e.Root) }, + global: func(e *Env) *mcpConfig { return opencodeConfig(filepath.Join(e.configHome(), "opencode")) }, + } + // Windsurf has no project-level MCP config. + windsurfMCP = mcpTarget{ + global: func(e *Env) *mcpConfig { + return jsonConfig(filepath.Join(e.Home, ".codeium", "windsurf", "mcp_config.json"), "mcpServers", map[string]any{"serverUrl": MCPServerURL}) + }, + } +) + +// opencodeConfig uses whichever of opencode.jsonc / opencode.json exists. +func opencodeConfig(dir string) *mcpConfig { + path := filepath.Join(dir, "opencode.json") + if _, err := os.Stat(filepath.Join(dir, "opencode.jsonc")); err == nil { + path = filepath.Join(dir, "opencode.jsonc") + } + return jsonConfig(path, "mcp", map[string]any{"type": "remote", "url": MCPServerURL, "enabled": true}) +} + +// MCPStatus reports an agent's Docs MCP setup and the file it lives in. +type MCPStatus struct { + Agent *Agent + State MCPState + Path string + Err error // the config exists but couldn't be read + // Added is set by ConfigureMCP when it added the server (rather than + // finding it already there). + Added bool +} + +// CheckMCP inspects an agent's MCP config without changing it. +func CheckMCP(env *Env, scope Scope, a *Agent) MCPStatus { + c := a.mcpConfig(env, scope) + if c == nil { + return MCPStatus{Agent: a, State: MCPUnsupported} + } + st := MCPStatus{Agent: a, Path: c.path} + entry, err := c.read() + switch { + case err != nil: + st.State, st.Err = MCPMissing, err + case entry == nil: + st.State = MCPMissing + case pointsAtDocs(entry): + st.State = MCPConfigured + default: + st.State = MCPCustom + } + return st +} + +// ConfigureMCP adds the Docs MCP server to an agent's config. It never +// replaces an existing entry of the same name. +func ConfigureMCP(ctx context.Context, env *Env, scope Scope, a *Agent) (MCPStatus, error) { + st := CheckMCP(env, scope, a) + if st.State != MCPMissing { + return st, nil + } + if st.Err != nil { + return st, st.Err + } + c := a.mcpConfig(env, scope) + if len(c.cli) > 0 && env.lookPath(c.cli[0]) { + cmd := exec.CommandContext(ctx, c.cli[0], c.cli[1:]...) + cmd.Dir = env.Root + if out, err := cmd.CombinedOutput(); err != nil { + return st, fmt.Errorf("%s: %w: %s", strings.Join(c.cli, " "), err, strings.TrimSpace(string(out))) + } + } else if err := c.add(); err != nil { + return st, err + } + st.State, st.Added = MCPConfigured, true + return st, nil +} + +// read returns our server's entry from the config file, or nil if absent. +func (c *mcpConfig) read() (map[string]any, error) { + data, err := os.ReadFile(c.path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + if len(bytes.TrimSpace(data)) == 0 { + return nil, nil + } + var doc map[string]any + if c.format == formatTOML { + if err := toml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("%s: %w", c.path, err) + } + } else { + std, err := hujson.Standardize(data) + if err != nil { + return nil, fmt.Errorf("%s: %w", c.path, err) + } + if err := json.Unmarshal(std, &doc); err != nil { + return nil, fmt.Errorf("%s: %w", c.path, err) + } + } + servers, _ := doc[c.key].(map[string]any) + entry, ok := servers[MCPServerName] + if !ok { + return nil, nil + } + m, _ := entry.(map[string]any) + if m == nil { + m = map[string]any{} + } + return m, nil +} + +func pointsAtDocs(entry map[string]any) bool { + for _, k := range []string{"url", "httpUrl", "serverUrl"} { + if u, ok := entry[k].(string); ok && strings.TrimRight(u, "/") == MCPServerURL { + return true + } + } + return false +} + +func (c *mcpConfig) add() error { + data, err := os.ReadFile(c.path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + var out []byte + if c.format == formatTOML { + out = addTOML(data) + // Appending a table is only wrong if the file already defines the + // servers table inline; re-parse (strictly, as Codex does) rather + // than guess. + var doc map[string]any + if err := toml.Unmarshal(out, &doc); err != nil { + return fmt.Errorf("%s: can't add %s automatically: %w", c.path, MCPServerName, err) + } + } else if out, err = addJSON(data, c.key, c.entry); err != nil { + return fmt.Errorf("%s: %w", c.path, err) + } + return writeFileAtomic(c.path, out, 0o644) +} + +func addTOML(data []byte) []byte { + var b bytes.Buffer + b.Write(data) + if len(bytes.TrimSpace(data)) > 0 { + if !bytes.HasSuffix(data, []byte("\n")) { + b.WriteString("\n") + } + if !bytes.HasSuffix(data, []byte("\n\n")) { + b.WriteString("\n") + } + } + fmt.Fprintf(&b, "[mcp_servers.%s]\nurl = %q\n", MCPServerName, MCPServerURL) + return b.Bytes() +} + +// addJSON inserts servers[MCPServerName] = entry into a JSON (or JSONC) file, +// leaving the rest of the file, comments and formatting included, untouched. +func addJSON(data []byte, key string, entry map[string]any) ([]byte, error) { + if len(bytes.TrimSpace(data)) == 0 { + out, _ := json.MarshalIndent(map[string]any{key: map[string]any{MCPServerName: entry}}, "", " ") + return append(out, '\n'), nil + } + v, err := hujson.Parse(data) + if err != nil { + return nil, err + } + root, ok := v.Value.(*hujson.Object) + if !ok { + return nil, errors.New("top level is not an object") + } + unit := indentUnit(root) + if servers := findMember(root, key); servers != nil { + obj, ok := servers.Value.(*hujson.Object) + if !ok { + return nil, fmt.Errorf("%q is not an object", key) + } + if err := appendMember(obj, MCPServerName, entry, 2, unit); err != nil { + return nil, err + } + } else if err := appendMember(root, key, map[string]any{MCPServerName: entry}, 1, unit); err != nil { + return nil, err + } + return v.Pack(), nil +} + +func findMember(obj *hujson.Object, name string) *hujson.Value { + for i := range obj.Members { + if lit, ok := obj.Members[i].Name.Value.(hujson.Literal); ok && lit.String() == name { + return &obj.Members[i].Value + } + } + return nil +} + +// indentUnit guesses the file's indent from its first member, defaulting to +// two spaces. +func indentUnit(root *hujson.Object) string { + if len(root.Members) > 0 { + before := string(root.Members[0].Name.BeforeExtra) + if i := strings.LastIndex(before, "\n"); i >= 0 { + if ws := before[i+1:]; ws != "" && strings.Trim(ws, " \t") == "" { + return ws + } + } + } + return " " +} + +// appendMember adds name: value as the object's last member, indented to +// depth, matching whether the object is laid out on one line or many. +func appendMember(obj *hujson.Object, name string, value any, depth int, unit string) error { + multiline := len(obj.Members) == 0 || + strings.Contains(string(obj.Members[len(obj.Members)-1].Name.BeforeExtra), "\n") + var text []byte + var before hujson.Extra + if multiline { + indent := strings.Repeat(unit, depth) + text, _ = json.MarshalIndent(value, indent, unit) + before = hujson.Extra("\n" + indent) + if len(obj.Members) == 0 { + obj.AfterExtra = hujson.Extra("\n" + strings.Repeat(unit, depth-1)) + } + } else { + text, _ = json.Marshal(value) + before = hujson.Extra(" ") + } + val, err := hujson.Parse(text) + if err != nil { + return err + } + obj.Members = append(obj.Members, hujson.ObjectMember{ + Name: hujson.Value{BeforeExtra: before, Value: hujson.String(name)}, + Value: hujson.Value{BeforeExtra: hujson.Extra(" "), Value: val.Value}, + }) + return nil +} diff --git a/pkg/skills/mcp_test.go b/pkg/skills/mcp_test.go new file mode 100644 index 00000000..87fe5784 --- /dev/null +++ b/pkg/skills/mcp_test.go @@ -0,0 +1,159 @@ +// 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 skills + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func timeNow() time.Time { return time.Date(2026, 9, 23, 0, 0, 0, 0, time.UTC) } + +var cursorEntry = map[string]any{"url": MCPServerURL} + +func TestAddJSON(t *testing.T) { + cases := []struct { + name, in, want string + }{ + { + name: "new file", + in: "", + want: "{\n \"mcpServers\": {\n \"livekit-docs\": {\n \"url\": \"https://docs.livekit.io/mcp\"\n }\n }\n}\n", + }, + { + name: "existing servers keep their formatting and comments", + in: "{\n // mine\n \"mcpServers\": {\n \"other\": { \"url\": \"https://x\" }\n }\n}\n", + want: "{\n // mine\n \"mcpServers\": {\n \"other\": { \"url\": \"https://x\" },\n \"livekit-docs\": {\n \"url\": \"https://docs.livekit.io/mcp\"\n }\n }\n}\n", + }, + { + name: "no servers key", + in: "{\n \"theme\": \"dark\"\n}\n", + want: "{\n \"theme\": \"dark\",\n \"mcpServers\": {\n \"livekit-docs\": {\n \"url\": \"https://docs.livekit.io/mcp\"\n }\n }\n}\n", + }, + { + name: "empty servers object", + in: "{\n \"mcpServers\": {}\n}\n", + want: "{\n \"mcpServers\": {\n \"livekit-docs\": {\n \"url\": \"https://docs.livekit.io/mcp\"\n }\n }\n}\n", + }, + { + name: "one-line file", + in: `{"numStartups": 3}`, + want: `{"numStartups": 3, "mcpServers": {"livekit-docs":{"url":"https://docs.livekit.io/mcp"}}}`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := addJSON([]byte(c.in), "mcpServers", cursorEntry) + require.NoError(t, err) + assert.Equal(t, c.want, string(got)) + }) + } + + _, err := addJSON([]byte(`{"mcpServers": []}`), "mcpServers", cursorEntry) + assert.Error(t, err) + _, err = addJSON([]byte(`[]`), "mcpServers", cursorEntry) + assert.Error(t, err) +} + +func TestConfigureMCP(t *testing.T) { + env := testEnv(t) + ctx := context.Background() + cursor := FindAgent("cursor") + + st := CheckMCP(env, ScopeProject, cursor) + assert.Equal(t, MCPMissing, st.State) + + st, err := ConfigureMCP(ctx, env, ScopeProject, cursor) + require.NoError(t, err) + assert.Equal(t, MCPConfigured, st.State) + assert.True(t, st.Added) + assert.Equal(t, filepath.Join(env.Root, ".cursor", "mcp.json"), st.Path) + + // Idempotent. + st, err = ConfigureMCP(ctx, env, ScopeProject, cursor) + require.NoError(t, err) + assert.Equal(t, MCPConfigured, st.State) + assert.False(t, st.Added) + + // Trailing slash counts as the same server. + vscode := filepath.Join(env.Root, ".vscode", "mcp.json") + require.NoError(t, os.MkdirAll(filepath.Dir(vscode), 0o755)) + require.NoError(t, os.WriteFile(vscode, []byte(`{"servers": {"livekit-docs": {"type": "http", "url": "https://docs.livekit.io/mcp/"}}}`), 0o644)) + assert.Equal(t, MCPConfigured, CheckMCP(env, ScopeProject, FindAgent("github-copilot")).State) + + // A same-named entry pointing elsewhere is left alone. + gemini := filepath.Join(env.Root, ".gemini", "settings.json") + require.NoError(t, os.MkdirAll(filepath.Dir(gemini), 0o755)) + custom := `{"mcpServers": {"livekit-docs": {"httpUrl": "http://localhost:3000/mcp"}}}` + require.NoError(t, os.WriteFile(gemini, []byte(custom), 0o644)) + st, err = ConfigureMCP(ctx, env, ScopeProject, FindAgent("gemini-cli")) + require.NoError(t, err) + assert.Equal(t, MCPCustom, st.State) + data, _ := os.ReadFile(gemini) + assert.Equal(t, custom, string(data)) + + // Unreadable config is an error, not something to overwrite. + bad := filepath.Join(env.Root, ".mcp.json") + require.NoError(t, os.WriteFile(bad, []byte("{not json"), 0o644)) + _, err = ConfigureMCP(ctx, env, ScopeProject, FindAgent("claude-code")) + require.Error(t, err) + + // Some agents can't be configured automatically. + assert.Equal(t, MCPUnsupported, CheckMCP(env, ScopeProject, FindAgent("windsurf")).State) + assert.Equal(t, MCPUnsupported, CheckMCP(env, ScopeGlobal, FindAgent("goose")).State) +} + +func TestConfigureMCPCodex(t *testing.T) { + env := testEnv(t) + codex := FindAgent("codex") + path := filepath.Join(env.Home, ".codex", "config.toml") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("model = \"gpt-5\"\n\n[mcp_servers.other]\ncommand = \"x\"\n"), 0o644)) + + st, err := ConfigureMCP(context.Background(), env, ScopeGlobal, codex) + require.NoError(t, err) + assert.Equal(t, MCPConfigured, st.State) + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "model = \"gpt-5\"\n\n[mcp_servers.other]\ncommand = \"x\"\n\n[mcp_servers.livekit-docs]\nurl = \"https://docs.livekit.io/mcp\"\n", string(data)) + assert.Equal(t, MCPConfigured, CheckMCP(env, ScopeGlobal, codex).State) + + // Servers defined as an inline table can't take an appended section. + require.NoError(t, os.WriteFile(path, []byte("mcp_servers = { other = { command = \"x\" } }\n"), 0o644)) + _, err = ConfigureMCP(context.Background(), env, ScopeGlobal, codex) + require.ErrorContains(t, err, "can't add livekit-docs automatically") +} + +func TestConfigureMCPUsesAgentCLI(t *testing.T) { + env := testEnv(t) + env.LookPath = func(bin string) (string, error) { + if bin == "claude" { + return "/bin/claude", nil + } + return "", os.ErrNotExist + } + // With `claude` on PATH, lk runs `claude mcp add` rather than editing + // ~/.claude.json. A fake PATH entry makes the command fail, proving it ran. + t.Setenv("PATH", t.TempDir()) + _, err := ConfigureMCP(context.Background(), env, ScopeGlobal, FindAgent("claude-code")) + require.ErrorContains(t, err, "claude mcp add --scope user --transport http livekit-docs https://docs.livekit.io/mcp") + assert.NoFileExists(t, filepath.Join(env.Home, ".claude.json")) +} diff --git a/pkg/skills/skills_test.go b/pkg/skills/skills_test.go new file mode 100644 index 00000000..a6edc761 --- /dev/null +++ b/pkg/skills/skills_test.go @@ -0,0 +1,428 @@ +// 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 skills + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func skillMD(name, version string) string { + return "---\nname: " + name + "\ndescription: " + name + " skill\nlicense: MIT\nmetadata:\n author: livekit\n version: \"" + version + "\"\n---\n\n# " + name + "\n" +} + +type entry struct { + name string + body string + mode int64 + typeflag byte + linkname string +} + +// archive builds a GitHub-style source tarball: a pax global header carrying +// the commit, then everything under one top-level directory. +func archive(t *testing.T, commit string, entries ...entry) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + require.NoError(t, tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeXGlobalHeader, + Name: "pax_global_header", + PAXRecords: map[string]string{"comment": commit}, + })) + for _, e := range entries { + hdr := &tar.Header{Name: "agent-skills-main/" + e.name, Mode: e.mode, Size: int64(len(e.body)), Typeflag: e.typeflag, Linkname: e.linkname} + if hdr.Typeflag == 0 { + hdr.Typeflag = tar.TypeReg + } + if hdr.Mode == 0 { + hdr.Mode = 0o644 + } + if hdr.Typeflag != tar.TypeReg { + hdr.Size = 0 + } + require.NoError(t, tw.WriteHeader(hdr)) + if hdr.Typeflag == tar.TypeReg { + _, err := tw.Write([]byte(e.body)) + require.NoError(t, err) + } + } + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + return buf.Bytes() +} + +func TestParseArchive(t *testing.T) { + data := archive(t, "abc123", + entry{name: "README.md", body: "readme"}, + entry{name: "skills/alpha/SKILL.md", body: skillMD("alpha", "1.2.0")}, + entry{name: "skills/alpha/references/guide.md", body: "guide"}, + entry{name: "skills/alpha/scripts/run.py", body: "print()", mode: 0o755}, + entry{name: "skills/alpha/link", typeflag: tar.TypeSymlink, linkname: "../../../etc/passwd"}, + entry{name: "skills/beta/SKILL.md", body: skillMD("not-beta", "1.0.0")}, + entry{name: "skills/notes/README.md", body: "not a skill"}, + ) + b, err := ParseArchive(bytes.NewReader(data)) + require.NoError(t, err) + assert.Equal(t, "abc123", b.Commit) + assert.Equal(t, []string{"alpha"}, b.Names()) + require.Len(t, b.Skipped, 1) + assert.Contains(t, b.Skipped[0], "does not match its directory") + + alpha := b.Find("alpha") + assert.Equal(t, "alpha skill", alpha.Description) + var paths []string + for _, f := range alpha.Files { + paths = append(paths, f.Path) + if f.Path == "scripts/run.py" { + assert.Equal(t, int64(0o755), f.Mode) + } + } + // The symlink is dropped. + assert.Equal(t, []string{"SKILL.md", "references/guide.md", "scripts/run.py"}, paths) +} + +func TestParseArchiveNoSkills(t *testing.T) { + _, err := ParseArchive(bytes.NewReader(archive(t, "abc", entry{name: "README.md", body: "x"}))) + require.Error(t, err) +} + +func TestSkillEntryRejectsTraversal(t *testing.T) { + for _, name := range []string{"repo/skills/../../x", "repo/skills/a/../../b", "repo/other/a/SKILL.md", "repo/skills/a"} { + _, _, ok := skillEntry(name) + assert.False(t, ok, name) + } + skill, rel, ok := skillEntry("repo/skills/a/references/b.md") + require.True(t, ok) + assert.Equal(t, "a", skill) + assert.Equal(t, "references/b.md", rel) +} + +func TestParseFrontmatter(t *testing.T) { + fm, err := ParseFrontmatter([]byte("---\r\nname: x\r\ndescription: 'a: b'\r\nmetadata:\r\n author: livekit\r\n version: 1.5\r\n---\r\nbody")) + require.NoError(t, err) + assert.Equal(t, "x", fm.Name) + assert.Equal(t, "a: b", fm.Description) + assert.Equal(t, "livekit", fm.Metadata["author"]) + + for _, bad := range []string{"no frontmatter", "---\nname: x\n", "---\ndescription: d\n---\n"} { + _, err := ParseFrontmatter([]byte(bad)) + assert.Error(t, err, bad) + } +} + +// vectorFiles matches a directory hashed by `npx skills` (Node's +// localeCompare ordering) and by `git write-tree`, to pin both hashes. +var vectorFiles = []File{ + {Path: "SKILL.md", Mode: 0o644, Data: []byte("---\nname: demo\ndescription: d\n---\nbody\n")}, + {Path: "Zeta.md", Mode: 0o644, Data: []byte("z\n")}, + {Path: "_notes.md", Mode: 0o644, Data: []byte("n\n")}, + {Path: "references/A.md", Mode: 0o644, Data: []byte("a\n")}, + {Path: "references/b.md", Mode: 0o644, Data: []byte("b\n")}, + {Path: "scripts/x-y.py", Mode: 0o755, Data: []byte("y\n")}, + {Path: "scripts/x_y.py", Mode: 0o644, Data: []byte("x\n")}, +} + +func TestContentHashMatchesSkillsCLI(t *testing.T) { + assert.Equal(t, "c8ccdac8ece3a90a8608cee2935cc3c732d5d899d5d854dcfce49a4f14cbd2cd", ContentHash(vectorFiles)) +} + +func TestTreeHashMatchesGit(t *testing.T) { + assert.Equal(t, "22991c4b92e2021a51e57b4ebc50edceb5e704c1", TreeHash(vectorFiles)) +} + +func TestReadDirRoundTrip(t *testing.T) { + dir := t.TempDir() + for _, f := range vectorFiles { + p := filepath.Join(dir, filepath.FromSlash(f.Path)) + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) + require.NoError(t, os.WriteFile(p, f.Data, os.FileMode(f.Mode))) + } + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".git", "HEAD"), []byte("x"), 0o644)) + link := filepath.Join(t.TempDir(), "link") + require.NoError(t, os.Symlink(dir, link)) + + files, err := readDir(link, vectorFiles) + require.NoError(t, err) + assert.Equal(t, ContentHash(vectorFiles), ContentHash(files)) + assert.Equal(t, TreeHash(vectorFiles), TreeHash(files)) +} + +func testEnv(t *testing.T) *Env { + t.Helper() + return &Env{ + Home: t.TempDir(), + Root: t.TempDir(), + Getenv: func(string) string { return "" }, + LookPath: func(string) (string, error) { return "", errors.New("not found") }, + } +} + +func TestDetectAgents(t *testing.T) { + env := testEnv(t) + assert.Empty(t, DetectAgents(env)) + + require.NoError(t, os.MkdirAll(filepath.Join(env.Home, ".claude"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(env.Home, ".config", "opencode"), 0o755)) + env.LookPath = func(bin string) (string, error) { + if bin == "codex" { + return "/usr/bin/codex", nil + } + return "", errors.New("not found") + } + assert.Equal(t, []string{"claude-code", "codex", "opencode"}, agentIDs(DetectAgents(env))) +} + +func agentIDs(agents []*Agent) []string { + var ids []string + for _, a := range agents { + ids = append(ids, a.ID) + } + return ids +} + +func TestGroupSkillsDirsSharesAgentsDir(t *testing.T) { + env := testEnv(t) + dirs := GroupSkillsDirs(env, ScopeProject, []*Agent{FindAgent("claude-code"), FindAgent("codex"), FindAgent("cursor")}) + require.Len(t, dirs, 2) + assert.Equal(t, filepath.Join(env.Root, ".claude", "skills"), dirs[0].Path) + assert.Equal(t, filepath.Join(env.Root, ".agents", "skills"), dirs[1].Path) + assert.Equal(t, []string{"codex", "cursor"}, agentIDs(dirs[1].Agents)) + + env.Getenv = func(k string) string { + if k == "CLAUDE_CONFIG_DIR" { + return "/custom/claude" + } + return "" + } + assert.Equal(t, filepath.Join("/custom/claude", "skills"), FindAgent("claude-code").SkillsDir(env, ScopeGlobal)) +} + +func bundle(t *testing.T, commit string, skills map[string]string) *Bundle { + t.Helper() + var entries []entry + for name, version := range skills { + entries = append(entries, + entry{name: "skills/" + name + "/SKILL.md", body: skillMD(name, version)}, + entry{name: "skills/" + name + "/references/notes.md", body: name + " " + version}, + ) + } + b, err := ParseArchive(bytes.NewReader(archive(t, commit, entries...))) + require.NoError(t, err) + b.Ref = DefaultRef + return b +} + +func state(t *testing.T, in *Installer, name string, dir SkillsDir) State { + t.Helper() + copies, err := in.Inspect([]string{name}, []SkillsDir{dir}) + require.NoError(t, err) + return copies[0].State +} + +func install(t *testing.T, in *Installer, names []string, dirs []SkillsDir) { + t.Helper() + copies, err := in.Inspect(names, dirs) + require.NoError(t, err) + for _, c := range copies { + require.NoError(t, in.Write(c)) + in.Record(c.Upstream) + } + require.NoError(t, in.Save()) +} + +func TestInstallerLifecycle(t *testing.T) { + for _, scope := range []Scope{ScopeProject, ScopeGlobal} { + t.Run(scope.String(), func(t *testing.T) { + env := testEnv(t) + dirs := GroupSkillsDirs(env, scope, []*Agent{FindAgent("claude-code"), FindAgent("codex")}) + claude := dirs[0] + + v1 := bundle(t, "c1", map[string]string{"alpha": "1.0.0", "beta": "1.0.0"}) + in, err := NewInstaller(env, scope, v1) + require.NoError(t, err) + assert.Equal(t, StateMissing, state(t, in, "alpha", claude)) + + install(t, in, v1.Names(), dirs) + assert.Equal(t, StateCurrent, state(t, in, "alpha", claude)) + assert.FileExists(t, filepath.Join(dirs[1].Path, "beta", "references", "notes.md")) + + // A new upstream version: unedited copies are outdated. + v2 := bundle(t, "c2", map[string]string{"alpha": "2.0.0", "gamma": "1.0.0"}) + in, err = NewInstaller(env, scope, v2) + require.NoError(t, err) + assert.Equal(t, StateOutdated, state(t, in, "alpha", claude)) + orphans, err := in.Orphans(dirs) + require.NoError(t, err) + assert.Equal(t, []string{"beta"}, orphans) + assert.Equal(t, StateRemoved, state(t, in, "beta", claude)) + + // Local edits are detected against both upstream and the lock. + require.NoError(t, os.WriteFile(filepath.Join(dirs[1].Path, "alpha", "SKILL.md"), []byte("edited"), 0o644)) + assert.Equal(t, StateModified, state(t, in, "alpha", dirs[1])) + + // Removing an orphan and forgetting it clears the lock entry once + // no copy remains. + copies, err := in.Inspect([]string{"beta"}, dirs) + require.NoError(t, err) + for _, c := range copies { + require.NoError(t, in.Delete(c)) + } + require.NoError(t, in.Forget("beta")) + _, ok := in.Lock.Get("beta") + assert.False(t, ok) + + names, err := in.InstalledLiveKitSkills(GroupSkillsDirs(env, scope, Agents)) + require.NoError(t, err) + assert.Equal(t, []string{"alpha"}, names) + }) + } +} + +func TestUntrackedSkill(t *testing.T) { + env := testEnv(t) + b := bundle(t, "c1", map[string]string{"alpha": "2.0.0"}) + in, err := NewInstaller(env, ScopeProject, b) + require.NoError(t, err) + dir := SkillsDir{Path: filepath.Join(env.Root, ".agents", "skills")} + + // A copy committed to a template: LiveKit's, an older version, no lock. + write := func(name, body string) { + require.NoError(t, os.MkdirAll(filepath.Join(dir.Path, name), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir.Path, name, "SKILL.md"), []byte(body), 0o644)) + } + write("alpha", skillMD("alpha", "1.0.0")) + write("retired", skillMD("retired", "1.0.0")) + write("mine", "---\nname: mine\ndescription: someone else's\n---\n") + assert.Equal(t, StateUntracked, state(t, in, "alpha", dir)) + assert.Equal(t, StateUntracked, state(t, in, "retired", dir)) + assert.Equal(t, StateModified, state(t, in, "mine", dir)) + + orphans, err := in.Orphans([]SkillsDir{dir}) + require.NoError(t, err) + assert.Equal(t, []string{"retired"}, orphans) + + // Once installed through lk it's tracked. + install(t, in, []string{"alpha"}, []SkillsDir{dir}) + assert.Equal(t, StateCurrent, state(t, in, "alpha", dir)) +} + +func TestInstallReplacesSymlink(t *testing.T) { + env := testEnv(t) + b := bundle(t, "c1", map[string]string{"alpha": "1.0.0"}) + in, err := NewInstaller(env, ScopeProject, b) + require.NoError(t, err) + + // `npx skills` links .claude/skills/ to .agents/skills/. + shared := filepath.Join(env.Root, ".agents", "skills") + install(t, in, []string{"alpha"}, []SkillsDir{{Path: shared}}) + claude := SkillsDir{Path: filepath.Join(env.Root, ".claude", "skills")} + require.NoError(t, os.MkdirAll(claude.Path, 0o755)) + require.NoError(t, os.Symlink(filepath.Join("..", "..", ".agents", "skills", "alpha"), filepath.Join(claude.Path, "alpha"))) + assert.Equal(t, StateCurrent, state(t, in, "alpha", claude)) + + copies, err := in.Inspect([]string{"alpha"}, []SkillsDir{claude}) + require.NoError(t, err) + require.NoError(t, in.Write(copies[0])) + info, err := os.Lstat(filepath.Join(claude.Path, "alpha")) + require.NoError(t, err) + assert.True(t, info.IsDir()) + // The link's target is untouched. + assert.FileExists(t, filepath.Join(shared, "alpha", "SKILL.md")) +} + +func TestLockPreservesOtherEntries(t *testing.T) { + env := testEnv(t) + path := filepath.Join(env.Root, "skills-lock.json") + require.NoError(t, os.WriteFile(path, []byte(`{"version":1,"skills":{"other":{"source":"acme/skills","sourceType":"github","computedHash":"x","custom":true}}}`), 0o644)) + + lock, err := LoadLock(env, ScopeProject) + require.NoError(t, err) + b := bundle(t, "c1", map[string]string{"alpha": "1.0.0"}) + lock.Record(b.Find("alpha"), DefaultRef, timeNow()) + require.NoError(t, lock.Save()) + + data, err := os.ReadFile(path) + require.NoError(t, err) + var got struct { + Version int `json:"version"` + Skills map[string]json.RawMessage `json:"skills"` + } + require.NoError(t, json.Unmarshal(data, &got)) + assert.Equal(t, 1, got.Version) + assert.JSONEq(t, `{"source":"acme/skills","sourceType":"github","computedHash":"x","custom":true}`, string(got.Skills["other"])) + assert.JSONEq(t, `{"source":"livekit/agent-skills","sourceType":"github","skillPath":"skills/alpha/SKILL.md","computedHash":"`+ContentHash(b.Find("alpha").Files)+`"}`, string(got.Skills["alpha"])) + assert.True(t, bytes.HasPrefix(data, []byte("{\n \"version\": 1,\n \"skills\": {")), string(data)) + + // Removing lk's entry keeps a lock that still has other skills. + lock.Remove("alpha") + require.NoError(t, lock.Save()) + assert.FileExists(t, path) +} + +func TestLockDeletedWhenEmpty(t *testing.T) { + env := testEnv(t) + lock, err := LoadLock(env, ScopeProject) + require.NoError(t, err) + lock.Record(bundle(t, "c1", map[string]string{"alpha": "1.0.0"}).Find("alpha"), DefaultRef, timeNow()) + require.NoError(t, lock.Save()) + lock.Remove("alpha") + require.NoError(t, lock.Save()) + assert.NoFileExists(t, lock.Path) +} + +func TestLoadLockRejectsInvalidJSON(t *testing.T) { + env := testEnv(t) + require.NoError(t, os.WriteFile(filepath.Join(env.Root, "skills-lock.json"), []byte("<<<<<<< HEAD"), 0o644)) + _, err := LoadLock(env, ScopeProject) + require.Error(t, err) +} + +func TestFetch(t *testing.T) { + data := archive(t, "deadbeef", entry{name: "skills/alpha/SKILL.md", body: skillMD("alpha", "1.0.0")}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/main" { + http.NotFound(w, r) + return + } + _, _ = w.Write(data) + })) + defer srv.Close() + orig := ArchiveURL + ArchiveURL = func(ref string) string { return srv.URL + "/" + ref } + defer func() { ArchiveURL = orig }() + + b, err := Fetch(context.Background(), srv.Client(), "main") + require.NoError(t, err) + assert.Equal(t, "deadbeef", b.Commit) + assert.Equal(t, "main", b.Ref) + + _, err = Fetch(context.Background(), srv.Client(), "nope") + require.ErrorContains(t, err, `ref "nope" not found`) +} diff --git a/pkg/skills/source.go b/pkg/skills/source.go new file mode 100644 index 00000000..359e051e --- /dev/null +++ b/pkg/skills/source.go @@ -0,0 +1,276 @@ +// 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 skills + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "net/http" + "path" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + // SourceRepo is the GitHub repo LiveKit's skills are published from. + SourceRepo = "livekit/agent-skills" + // DefaultRef is the branch installs track. Skills are written to stay + // valid as the SDKs evolve, so the tip of main is what everyone gets. + DefaultRef = "main" + + skillsRoot = "skills" + + // Guards against a runaway download. The whole repo is well under 1 MiB. + maxArchiveBytes = 32 << 20 + maxFileBytes = 8 << 20 +) + +// ArchiveURL is the tarball for a ref. It is a variable so tests can serve +// their own archive. +var ArchiveURL = func(ref string) string { + return "https://codeload.github.com/" + SourceRepo + "/tar.gz/" + ref +} + +func sourceURL() string { return "https://github.com/" + SourceRepo + ".git" } + +// File is one file of a skill, relative to the skill's directory. +type File struct { + Path string // slash-separated + Mode int64 // 0o644 or 0o755 + Data []byte +} + +// Skill is one skill as published upstream. +type Skill struct { + Name string + Description string + Files []File // sorted by Path +} + +// SkillPath is the SKILL.md path inside the source repo, as recorded in lock +// files. +func (s *Skill) SkillPath() string { return skillsRoot + "/" + s.Name + "/SKILL.md" } + +// Bundle is every skill at one commit of the source repo. +type Bundle struct { + Ref string + Commit string // full SHA, when the archive carries it + Skills []*Skill + // Skipped explains skill directories that failed validation, so one bad + // skill upstream doesn't block installing the rest. + Skipped []string +} + +// Find returns the named skill, or nil. +func (b *Bundle) Find(name string) *Skill { + for _, s := range b.Skills { + if s.Name == name { + return s + } + } + return nil +} + +// Names lists the bundle's skill names. +func (b *Bundle) Names() []string { + names := make([]string, len(b.Skills)) + for i, s := range b.Skills { + names[i] = s.Name + } + return names +} + +// Fetch downloads the source repo at ref and parses every skill in it. +func Fetch(ctx context.Context, client *http.Client, ref string) (*Bundle, error) { + if client == nil { + client = http.DefaultClient + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ArchiveURL(ref), nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("downloading skills from %s: %w", SourceRepo, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("ref %q not found in %s", ref, SourceRepo) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("downloading skills from %s: %s", SourceRepo, resp.Status) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, maxArchiveBytes+1)) + if err != nil { + return nil, fmt.Errorf("downloading skills from %s: %w", SourceRepo, err) + } + if len(data) > maxArchiveBytes { + return nil, errors.New("skills archive is unexpectedly large") + } + b, err := ParseArchive(bytes.NewReader(data)) + if err != nil { + return nil, err + } + b.Ref = ref + return b, nil +} + +// ParseArchive reads a GitHub source tarball (one top-level directory holding +// the repo) and returns the skills under skills//. +func ParseArchive(r io.Reader) (*Bundle, error) { + gz, err := gzip.NewReader(r) + if err != nil { + return nil, fmt.Errorf("reading skills archive: %w", err) + } + tr := tar.NewReader(gz) + b := &Bundle{} + files := map[string][]File{} + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("reading skills archive: %w", err) + } + // GitHub archives record the commit in the global header's comment. + if c := hdr.PAXRecords["comment"]; c != "" && b.Commit == "" { + b.Commit = c + } + // Only regular files are installed. Symlinks and anything else are + // skipped so an entry can never point outside the skill directory. + if hdr.Typeflag != tar.TypeReg { + continue + } + name, rel, ok := skillEntry(hdr.Name) + if !ok { + continue + } + if hdr.Size > maxFileBytes { + return nil, fmt.Errorf("skills archive: skill %s: %s is too large", name, rel) + } + data, err := io.ReadAll(tr) + if err != nil { + return nil, fmt.Errorf("reading skills archive: %w", err) + } + mode := int64(0o644) + if hdr.Mode&0o111 != 0 { + mode = 0o755 + } + files[name] = append(files[name], File{Path: rel, Mode: mode, Data: data}) + } + + for name, fs := range files { + s, err := newSkill(name, fs) + if err != nil { + b.Skipped = append(b.Skipped, err.Error()) + continue + } + if s != nil { + b.Skills = append(b.Skills, s) + } + } + sort.Slice(b.Skills, func(i, j int) bool { return b.Skills[i].Name < b.Skills[j].Name }) + sort.Strings(b.Skipped) + if len(b.Skills) == 0 { + return nil, fmt.Errorf("no skills found in %s", SourceRepo) + } + return b, nil +} + +// skillEntry maps an archive path like "repo-main/skills/foo/references/a.md" +// to ("foo", "references/a.md"). +func skillEntry(name string) (skill, rel string, ok bool) { + parts := strings.Split(path.Clean(name), "/") + // top-level dir, "skills", skill name, then at least one path element + if len(parts) < 4 || parts[1] != skillsRoot { + return "", "", false + } + for _, p := range parts[2:] { + if p == ".." || p == "." || p == "" { + return "", "", false + } + } + return parts[2], strings.Join(parts[3:], "/"), true +} + +// newSkill validates a skill's files. Directories without a SKILL.md are not +// skills and are ignored. +func newSkill(dir string, files []File) (*Skill, error) { + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + var manifest []byte + for _, f := range files { + if f.Path == "SKILL.md" { + manifest = f.Data + } + } + if manifest == nil { + return nil, nil + } + fm, err := ParseFrontmatter(manifest) + if err != nil { + return nil, fmt.Errorf("skill %s: %w", dir, err) + } + if fm.Name != dir { + return nil, fmt.Errorf("skill %s: name %q does not match its directory", dir, fm.Name) + } + if len(fm.Name) > 64 || !validName.MatchString(fm.Name) { + return nil, fmt.Errorf("skill %s: invalid name", dir) + } + return &Skill{ + Name: fm.Name, + Description: fm.Description, + Files: files, + }, nil +} + +// validName is the Agent Skills naming rule: lowercase letters, digits and +// single hyphens, 1-64 characters. +var validName = regexp.MustCompile(`^[a-z0-9]([a-z0-9]|-[a-z0-9]){0,63}$`) + +// Frontmatter is the subset of SKILL.md frontmatter lk reads. +type Frontmatter struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Metadata map[string]any `yaml:"metadata"` +} + +// ParseFrontmatter reads the YAML block at the top of a SKILL.md. +func ParseFrontmatter(data []byte) (*Frontmatter, error) { + text := strings.ReplaceAll(string(data), "\r\n", "\n") + if !strings.HasPrefix(text, "---\n") { + return nil, errors.New("SKILL.md has no frontmatter") + } + body, _, ok := strings.Cut(text[4:], "\n---") + if !ok { + return nil, errors.New("SKILL.md frontmatter is not terminated") + } + var fm Frontmatter + if err := yaml.Unmarshal([]byte(body), &fm); err != nil { + return nil, fmt.Errorf("SKILL.md frontmatter: %w", err) + } + if fm.Name == "" { + return nil, errors.New("SKILL.md frontmatter has no name") + } + return &fm, nil +}