Files
multica/server/internal/daemon/local_skills.go
Jiayuan Zhang 5d9295ac65 feat(agents): add per-agent runtime skill controls (#5686)
* feat(agents): add per-agent runtime skill controls

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): renumber runtime-skill migration and broadcast agent:status on toggle

Address the MUL-5101 review blockers on PR #5686:

- Rebase onto main and renumber the runtime-skill-disable migration
  202 -> 203. main added 202_runtime_profile_add_qwen, so the pair
  collided on prefix 202 and migrations_lint_test would reject the
  duplicate. 203 is the next free prefix.
- Publish an "agent:status" event after persisting a
  disabled_runtime_skills override, mirroring the workspace-skill toggle
  in writeUpdatedAgentSkills. The realtime layer keys off this event to
  invalidate workspaceKeys.agents, so other open web/desktop/mobile
  clients now drop their stale toggle state instead of only the
  initiating tab refreshing. Reload junction-table skills before the
  broadcast so it doesn't signal cleared skills (#3459).
- Add a handler regression test proving the broadcast fires on both
  disable and enable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:38:38 +08:00

605 lines
21 KiB
Go

package daemon
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/multica-ai/multica/server/internal/skill"
)
const (
maxLocalSkillFileSize int64 = 1 << 20
maxLocalSkillBundleSize int64 = 8 << 20
// Kept in lockstep with the server-side importer's maxImportFileCount so a
// skill that imports from a URL/archive also imports from a runtime-local
// directory. The 8 MiB bundle cap is the real guard on skill size.
maxLocalSkillFileCount = 256
// Cap how deep skill discovery descends below a runtime root. opencode
// stores skills two levels deep (e.g. `release/reporter/SKILL.md`); a
// few extra levels covers any realistic future layout while bounding
// work in case an installer accidentally points us at $HOME.
maxLocalSkillDirDepth = 4
)
type runtimeLocalSkillSummary struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
SourcePath string `json:"source_path"`
Provider string `json:"provider"`
// Root classifies which discovery root surfaced this skill:
// localSkillRootProvider ("provider") for the runtime's own skill
// directory (e.g. ~/.claude/skills) or localSkillRootUniversal
// ("universal") for the cross-tool ~/.agents/skills fallback. The UI
// uses it to label a skill's origin and to hint, in the import dialog,
// whether a skill came from a provider-specific or a shared location.
// Older daemons that predate multi-root discovery omit the field; the
// server treats an empty value as "unknown" rather than a provider/
// universal assertion.
Root string `json:"root,omitempty"`
Plugin string `json:"plugin,omitempty"`
CanDisable bool `json:"can_disable,omitempty"`
FileCount int `json:"file_count"`
}
type runtimeLocalSkillBundle struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Content string `json:"content"`
SourcePath string `json:"source_path"`
Provider string `json:"provider"`
Files []SkillFileData `json:"files,omitempty"`
}
// localSkillRoot is a single discovery location plus a classifier for where
// it came from. Roots are returned in priority order by
// localSkillRootsForProvider; the kind is surfaced to the UI on each
// discovered skill (see runtimeLocalSkillSummary.Root).
type localSkillRoot struct {
path string
kind string
keyPrefix string
plugin string
}
const (
// localSkillRootProvider marks a runtime's own skill directory (e.g.
// ~/.claude/skills). These take priority over the universal root.
localSkillRootProvider = "provider"
// localSkillRootUniversal marks the cross-tool ~/.agents/skills root,
// a convention shared by Codex, Gemini CLI, Augment and others as a
// universal home-level skill store. It is always searched last so a
// same-key skill in the provider directory keeps winning.
localSkillRootUniversal = "universal"
// localSkillRootPlugin marks skills contributed by an enabled runtime
// plugin. Plugin roots use a namespace prefix so their invocation keys
// match Claude Code (for example paper-desktop:design-to-code) and never
// collide with standalone user skills.
localSkillRootPlugin = "plugin"
)
// localSkillRootsForProvider returns the ordered user-level skill roots
// scanned for each runtime/provider. The slice is in priority order:
//
// 1. the runtime's provider-specific root (backward-compatible with the
// single-root behavior that predates ~/.agents/skills support), then
// 2. the cross-tool universal root ~/.agents/skills.
//
// Listing and import both walk the roots in this order and the first match of
// a given skill key wins, so adding the universal root never changes what an
// existing provider-root skill resolves to — it only surfaces additional,
// non-conflicting skills.
//
// Keep the provider roots in sync with upstream docs / conventions:
// - GitHub Copilot: https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-skills
// - OpenCode: https://opencode.ai/docs/skills
// - OpenClaw: https://github.com/openclaw/openclaw/blob/main/docs/tools/skills.md
// - Pi: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md
// - Cursor: official forum guidance referencing the built-in /create-skill flow
// (https://forum.cursor.com/t/cursor-doesnt-know-new-skills-arens-saved/158507)
// - Hermes: ~/.hermes/skills is Hermes Agent's primary skill directory
// (https://hermes-agent.nousresearch.com/docs/user-guide/features/skills)
// - Kimi: ~/.kimi/skills mirrors Kimi CLI's project-level .kimi/skills layout
// - Kiro: project and user-level .kiro/skills directories discovered by Kiro CLI
// - Qoder: ~/.qoder/skills mirrors Qoder CLI's project-level .qoder/skills layout
// - Antigravity: ~/.gemini/antigravity-cli/skills user-level skill root
// (https://antigravity.google/docs/gcli-migration "Global skills")
// - Grok: $GROK_HOME/skills, defaulting to ~/.grok/skills
// - Qwen Code: $QWEN_HOME/skills, defaulting to ~/.qwen/skills
//
// The universal ~/.agents/skills root is documented as a cross-tool skill
// location by Codex (https://developers.openai.com/codex/skills) and Gemini
// CLI (https://geminicli.com/docs/cli/skills/), among others.
//
// Longer-term this mapping would be better colocated with the provider
// definitions under server/pkg/agent so adding a new runtime can't silently
// miss the local-skills surface.
func localSkillRootsForProvider(provider string) ([]localSkillRoot, bool, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, false, fmt.Errorf("resolve user home: %w", err)
}
var providerRoot string
switch provider {
case "claude":
providerRoot = filepath.Join(home, ".claude", "skills")
case "codebuddy":
// CodeBuddy Code is a Claude Code fork but ships its own native
// config directory; it does NOT read ~/.claude/skills unless the
// user manually symlinks it in (the vendor's documented Claude
// Code migration path). See
// https://www.codebuddy.ai/docs/cli/codebuddy-dir ("Global
// directory ~/.codebuddy/") and
// https://www.codebuddy.ai/docs/cli/skills ("User-level Skills:
// ~/.codebuddy/skills/").
providerRoot = filepath.Join(home, ".codebuddy", "skills")
case "codex":
codexHome := strings.TrimSpace(os.Getenv("CODEX_HOME"))
if codexHome == "" {
codexHome = filepath.Join(home, ".codex")
}
providerRoot = filepath.Join(codexHome, "skills")
case "copilot":
providerRoot = filepath.Join(home, ".copilot", "skills")
case "opencode":
providerRoot = filepath.Join(home, ".config", "opencode", "skills")
case "deveco":
providerRoot = filepath.Join(home, ".config", "deveco", "skills")
case "openclaw":
providerRoot = filepath.Join(home, ".openclaw", "skills")
case "pi":
providerRoot = filepath.Join(home, ".pi", "agent", "skills")
case "cursor":
providerRoot = filepath.Join(home, ".cursor", "skills")
case "hermes":
providerRoot = filepath.Join(home, ".hermes", "skills")
case "kimi":
providerRoot = filepath.Join(home, ".kimi", "skills")
case "kiro":
providerRoot = filepath.Join(home, ".kiro", "skills")
case "qoder":
providerRoot = filepath.Join(home, ".qoder", "skills")
case "traecli":
// Official TRAE CLI global skills live in ~/.traecli/skills.
// See https://docs.trae.cn/cli_skills
providerRoot = filepath.Join(home, ".traecli", "skills")
case "antigravity":
// agy inherits Gemini CLI's global skill root; see
// https://antigravity.google/docs/gcli-migration ("Global skills").
providerRoot = filepath.Join(home, ".gemini", "antigravity-cli", "skills")
case "grok":
// GROK_HOME replaces the default ~/.grok home for settings, sessions,
// and user-level skills.
grokHome := strings.TrimSpace(os.Getenv("GROK_HOME"))
if grokHome == "" {
grokHome = filepath.Join(home, ".grok")
}
providerRoot = filepath.Join(grokHome, "skills")
case "qwen":
// QWEN_HOME replaces Qwen Code's global ~/.qwen directory. It owns
// settings, sessions, credentials and personal skills; project
// .qwen/skills remains rooted in the task workdir.
qwenHome := strings.TrimSpace(os.Getenv("QWEN_HOME"))
if qwenHome == "" {
qwenHome = filepath.Join(home, ".qwen")
}
providerRoot = filepath.Join(qwenHome, "skills")
default:
return nil, false, nil
}
roots := []localSkillRoot{
{path: providerRoot, kind: localSkillRootProvider},
{path: filepath.Join(home, ".agents", "skills"), kind: localSkillRootUniversal},
}
if provider == "claude" {
for _, plugin := range listEnabledClaudePlugins(home) {
manifest, _ := readClaudePluginManifest(plugin.InstallPath)
for _, path := range claudePluginComponentPaths(
plugin.InstallPath,
manifest.Skills,
filepath.Join(plugin.InstallPath, "skills"),
) {
roots = append(roots, localSkillRoot{
path: path,
kind: localSkillRootPlugin,
keyPrefix: plugin.Name + ":",
plugin: plugin.ID,
})
}
}
}
return roots, true, nil
}
func isIgnoredLocalSkillEntry(name string) bool {
if name == "" {
return true
}
if strings.HasPrefix(name, ".") {
return true
}
switch strings.ToLower(name) {
case "license", "license.md", "license.txt":
return true
default:
return false
}
}
func normalizeLocalSkillKey(key string) (string, error) {
if strings.TrimSpace(key) == "" {
return "", fmt.Errorf("skill key is required")
}
cleaned := filepath.Clean(filepath.FromSlash(strings.TrimSpace(key)))
if cleaned == "." || filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "..") {
return "", fmt.Errorf("invalid skill key")
}
return filepath.ToSlash(cleaned), nil
}
func relativizeHomePath(path string) string {
home, err := os.UserHomeDir()
if err != nil {
return filepath.ToSlash(path)
}
if path == home {
return "~"
}
prefix := home + string(filepath.Separator)
if strings.HasPrefix(path, prefix) {
return filepath.ToSlash("~" + string(filepath.Separator) + strings.TrimPrefix(path, prefix))
}
return filepath.ToSlash(path)
}
func readLocalSkillMainFile(skillDir string) (string, error) {
mainPath := filepath.Join(skillDir, "SKILL.md")
info, err := os.Stat(mainPath)
if err != nil {
return "", err
}
if info.Size() > maxLocalSkillFileSize {
return "", fmt.Errorf("SKILL.md exceeds %d bytes", maxLocalSkillFileSize)
}
content, err := os.ReadFile(mainPath)
if err != nil {
return "", err
}
return string(content), nil
}
func collectLocalSkillFiles(skillDir string, includeContent bool) ([]SkillFileData, error) {
files := make([]SkillFileData, 0)
var totalSize int64
// filepath.WalkDir does not follow a symlinked root, so when the runtime
// root contains symlinks into a shared skill installer (e.g. lark-cli's
// ~/.agents/skills/<name>) walking from the symlink path enumerates zero
// children and every such skill ends up reporting 0 files. Resolve the
// real path first so the walk descends into the actual directory.
walkRoot := skillDir
if resolved, err := filepath.EvalSymlinks(skillDir); err == nil {
walkRoot = resolved
}
err := filepath.WalkDir(walkRoot, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return nil
}
if path == walkRoot {
return nil
}
if entry.Type()&os.ModeSymlink != 0 {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if entry.IsDir() {
if isIgnoredLocalSkillEntry(entry.Name()) {
return filepath.SkipDir
}
return nil
}
if isIgnoredLocalSkillEntry(entry.Name()) || strings.EqualFold(entry.Name(), "SKILL.md") {
return nil
}
rel, err := filepath.Rel(walkRoot, path)
if err != nil {
return nil
}
rel = filepath.Clean(rel)
if rel == "." || filepath.IsAbs(rel) || strings.HasPrefix(rel, "..") {
return nil
}
info, err := entry.Info()
if err != nil || info.Size() > maxLocalSkillFileSize {
return nil
}
if len(files) >= maxLocalSkillFileCount {
return fmt.Errorf("local skill exceeds %d files", maxLocalSkillFileCount)
}
totalSize += info.Size()
if totalSize > maxLocalSkillBundleSize {
return fmt.Errorf("local skill exceeds %d bytes in total", maxLocalSkillBundleSize)
}
file := SkillFileData{Path: filepath.ToSlash(rel)}
if includeContent {
content, err := os.ReadFile(path)
if err != nil {
return nil
}
file.Content = string(content)
}
files = append(files, file)
return nil
})
if err != nil {
return nil, err
}
sort.Slice(files, func(i, j int) bool {
return files[i].Path < files[j].Path
})
return files, nil
}
func listRuntimeLocalSkills(provider string) ([]runtimeLocalSkillSummary, bool, error) {
roots, supported, err := localSkillRootsForProvider(provider)
if err != nil || !supported {
return nil, supported, err
}
// Walk each runtime root with two extensions over filepath.WalkDir:
// - Follow symlinks at every level. Installers like lark-cli ship
// each skill as a symlink into a shared ~/.agents/skills/<name>;
// the previous WalkDir path silently dropped them via the
// os.ModeSymlink early return.
// - Allow nested layouts. opencode stores skills as
// `release/reporter/SKILL.md`, and `loadRuntimeLocalSkillBundle`
// already accepts slash-delimited keys, so the list endpoint
// must surface those nested skills too.
skills := make([]runtimeLocalSkillSummary, 0)
// Dedupe strictly by Key. Roots are visited in priority order
// (provider-specific first, ~/.agents/skills last); the first
// occurrence of a Key wins. This keeps backward compatibility provable:
// every skill visible under the single-root behavior keeps its Key,
// SourcePath and FileCount, and we only ever *add* non-conflicting Keys
// discovered under the universal root.
seenKeys := make(map[string]bool)
for _, root := range roots {
if _, err := os.Stat(root.path); err != nil {
if os.IsNotExist(err) {
continue
}
return nil, true, err
}
// Each root gets its OWN `visited` set. It must NOT be shared across
// roots: a user can deliberately expose the same on-disk skill under
// two names by symlinking, e.g. ~/.claude/skills/bar ->
// ~/.agents/skills/foo. With per-root visited sets both `bar` (claude
// root) and `foo` (agents root) are listed; a shared set would mark
// the resolved real path visited from the first root and silently
// drop the legitimate second entry.
rootSkills := make([]runtimeLocalSkillSummary, 0)
visited := make(map[string]bool)
enumerateLocalSkills(provider, root, root.path, root.path, 0, visited, &rootSkills)
for _, s := range rootSkills {
if seenKeys[s.Key] {
continue
}
seenKeys[s.Key] = true
skills = append(skills, s)
}
}
// Sort once, after every root has been merged, so the result is stable
// regardless of how many roots contributed.
sort.Slice(skills, func(i, j int) bool {
return skills[i].Key < skills[j].Key
})
return skills, true, nil
}
// enumerateLocalSkills walks `currentDir` looking for skill directories
// (directories that contain a SKILL.md). When one is found it is registered
// at a key relative to `walkRoot` and the recursion stops at that branch —
// we never descend into a directory that already qualifies as a skill, even
// if it happens to contain nested SKILL.md files of its own.
//
// `visited` keys on the resolved (symlink-followed) absolute path so a
// cyclic symlink can't loop forever; this is the only reason we eagerly
// EvalSymlinks up front. Errors from EvalSymlinks just stop the descent on
// that branch — most often it's a dangling link, which we want to ignore.
func enumerateLocalSkills(
provider string,
root localSkillRoot,
walkRoot, currentDir string,
depth int,
visited map[string]bool,
skills *[]runtimeLocalSkillSummary,
) {
if depth > maxLocalSkillDirDepth {
return
}
resolved, err := filepath.EvalSymlinks(currentDir)
if err != nil {
return
}
if visited[resolved] {
return
}
visited[resolved] = true
entries, err := os.ReadDir(currentDir)
if err != nil {
return
}
for _, entry := range entries {
name := entry.Name()
if isIgnoredLocalSkillEntry(name) {
continue
}
path := filepath.Join(currentDir, name)
info, statErr := os.Stat(path) // follows symlinks
if statErr != nil || !info.IsDir() {
continue
}
mainPath := filepath.Join(path, "SKILL.md")
if _, err := os.Stat(mainPath); err == nil {
rel, err := filepath.Rel(walkRoot, path)
if err != nil {
continue
}
key, err := normalizeLocalSkillKey(rel)
if err != nil {
continue
}
key = root.keyPrefix + key
content, err := readLocalSkillMainFile(path)
if err != nil {
continue
}
skillName, description := skill.ParseSkillFrontmatter(content)
if root.plugin != "" {
skillName = key
} else if skillName == "" {
skillName = filepath.Base(path)
}
files, err := collectLocalSkillFiles(path, false)
if err != nil {
continue
}
*skills = append(*skills, runtimeLocalSkillSummary{
Key: key,
Name: skillName,
Description: description,
SourcePath: relativizeHomePath(path),
Provider: provider,
Root: root.kind,
Plugin: root.plugin,
CanDisable: provider == "codex" || provider == "claude",
// `files` is the supporting bundle (collectLocalSkillFiles
// intentionally excludes SKILL.md so the bundle's `Content`
// field can carry it without duplication on import). For the
// list summary the user expects the total file count, so add
// one back for SKILL.md itself.
FileCount: len(files) + 1,
})
continue
}
// No SKILL.md here — descend looking for nested skills.
enumerateLocalSkills(provider, root, walkRoot, path, depth+1, visited, skills)
}
}
func loadRuntimeLocalSkillBundle(provider, skillKey string) (*runtimeLocalSkillBundle, bool, error) {
roots, supported, err := localSkillRootsForProvider(provider)
if err != nil || !supported {
return nil, supported, err
}
key, err := normalizeLocalSkillKey(skillKey)
if err != nil {
return nil, true, err
}
// Walk the roots in the same priority order as listRuntimeLocalSkills so
// import resolves to exactly the skill the list endpoint surfaced. The
// guiding invariant is list/load agreement: a root "has" the skill at this
// key only when it is a directory carrying a SKILL.md — the exact
// condition listRuntimeLocalSkills registers on. Anything that is not a
// valid skill at this key (no entry, not a directory, or a directory
// without a SKILL.md) means "this root doesn't have it" and we fall
// through to the next root. Only a genuine IO/permission fault is
// returned, so we never silently substitute a different-content same-key
// skill from a lower-priority root.
for _, root := range roots {
rootKey := key
if root.keyPrefix != "" {
if !strings.HasPrefix(key, root.keyPrefix) {
continue
}
rootKey = strings.TrimPrefix(key, root.keyPrefix)
}
skillDir := filepath.Join(root.path, filepath.FromSlash(rootKey))
info, err := os.Stat(skillDir)
if err != nil {
// IsNotExist => this root simply lacks the skill, try the next.
// Any other stat error (permission, IO) is returned as-is rather
// than silently skipped, since skipping could load a DIFFERENT
// same-key skill from a lower-priority root that does not match
// what the user picked in the list (Eve review #1).
if os.IsNotExist(err) {
continue
}
return nil, true, err
}
if !info.IsDir() {
// Not a directory: listRuntimeLocalSkills never surfaces a non-dir
// as a skill, so this root has no skill at this key. Fall through
// to the next root instead of erroring.
continue
}
// A directory only counts as a skill when it actually contains a
// SKILL.md. A same-key directory WITHOUT one (e.g. ~/.claude/skills/foo
// that is just an empty/unrelated folder) is NOT this skill — list
// would have descended past it — so it must not shadow a valid
// ~/.agents/skills/foo. Treat a missing SKILL.md as "this root doesn't
// have it" and continue; only a non-IsNotExist stat error on the
// SKILL.md (permission, IO) is returned.
mainPath := filepath.Join(skillDir, "SKILL.md")
if _, err := os.Stat(mainPath); err != nil {
if os.IsNotExist(err) {
continue
}
return nil, true, err
}
content, err := readLocalSkillMainFile(skillDir)
if err != nil {
return nil, true, err
}
name, description := skill.ParseSkillFrontmatter(content)
if root.plugin != "" {
name = key
} else if name == "" {
name = filepath.Base(skillDir)
}
files, err := collectLocalSkillFiles(skillDir, true)
if err != nil {
return nil, true, err
}
return &runtimeLocalSkillBundle{
Name: name,
Description: description,
Content: content,
SourcePath: relativizeHomePath(skillDir),
Provider: provider,
Files: files,
}, true, nil
}
return nil, true, fmt.Errorf("local skill not found")
}