package services import ( "encoding/json" "fmt" "os" "path/filepath" "regexp" "strings" "sproutclaw-web/internal/models" ) var npmSpecRe = regexp.MustCompile(`^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$`) // ListExtensions returns local extensions (enabled and disabled) plus npm packages // declared in settings.json. Local ones live in extensions/ and extensions-disabled/. func ListExtensions(agentDir string) []models.ExtensionInfo { var exts []models.ExtensionInfo exts = append(exts, scanExtensionsDir(filepath.Join(agentDir, "extensions"), true)...) exts = append(exts, scanExtensionsDir(filepath.Join(agentDir, "extensions-disabled"), false)...) exts = append(exts, listNpmExtensions(agentDir)...) return exts } func scanExtensionsDir(dir string, enabled bool) []models.ExtensionInfo { entries, err := os.ReadDir(dir) if err != nil { return nil } var exts []models.ExtensionInfo for _, e := range entries { if !e.IsDir() { continue } ext := buildExtensionInfo(filepath.Join(dir, e.Name()), e.Name()) // Anything physically under extensions/ or extensions-disabled/ is a // local extension and can be toggled, regardless of its package.json name. ext.Category = "local" ext.Toggleable = true ext.Enabled = enabled exts = append(exts, ext) } return exts } func buildExtensionInfo(path, id string) models.ExtensionInfo { info := models.ExtensionInfo{ Name: id, Path: path, } // try package.json for name/version pkgPath := filepath.Join(path, "package.json") if data, err := os.ReadFile(pkgPath); err == nil { var pkg struct { Name string `json:"name"` Version string `json:"version"` } if json.Unmarshal(data, &pkg) == nil { if pkg.Name != "" { info.Name = pkg.Name } info.Version = pkg.Version } } return info } func listNpmExtensions(agentDir string) []models.ExtensionInfo { sources := readNpmPackageSources(filepath.Join(agentDir, "settings.json")) if len(sources) == 0 { return nil } seen := make(map[string]bool, len(sources)) var exts []models.ExtensionInfo for _, source := range sources { name, ok := npmPackageName(source) if !ok || seen[name] { continue } seen[name] = true exts = append(exts, buildNpmExtensionInfo(agentDir, name, source)) } return exts } func readNpmPackageSources(settingsPath string) []string { data, err := os.ReadFile(settingsPath) if err != nil { return nil } var settings struct { Packages []json.RawMessage `json:"packages"` Extensions []string `json:"extensions"` } if err := json.Unmarshal(data, &settings); err != nil { return nil } var sources []string for _, raw := range settings.Packages { if source := parsePackageSource(raw); source != "" { sources = append(sources, source) } } for _, source := range settings.Extensions { if strings.HasPrefix(source, "npm:") { sources = append(sources, source) } } return sources } func parsePackageSource(raw json.RawMessage) string { var source string if err := json.Unmarshal(raw, &source); err == nil { return strings.TrimSpace(source) } var obj struct { Source string `json:"source"` } if err := json.Unmarshal(raw, &obj); err == nil { return strings.TrimSpace(obj.Source) } return "" } func npmPackageName(source string) (string, bool) { if !strings.HasPrefix(source, "npm:") { return "", false } spec := strings.TrimSpace(strings.TrimPrefix(source, "npm:")) if spec == "" { return "", false } name, _ := parseNpmSpec(spec) if name == "" { return "", false } return name, true } func parseNpmSpec(spec string) (name, version string) { match := npmSpecRe.FindStringSubmatch(spec) if match == nil { return spec, "" } name = match[1] version = match[2] return name, version } func buildNpmExtensionInfo(agentDir, packageName, source string) models.ExtensionInfo { modPath := filepath.Join(agentDir, "npm", "node_modules", packageName) info := models.ExtensionInfo{ Name: packageName, Path: modPath, Source: source, Category: "npm", Enabled: true, Toggleable: false, } pkgPath := filepath.Join(modPath, "package.json") if data, err := os.ReadFile(pkgPath); err == nil { var pkg struct { Name string `json:"name"` Version string `json:"version"` } if json.Unmarshal(data, &pkg) == nil { if pkg.Name != "" { info.Name = pkg.Name } info.Version = pkg.Version } } return info } // ToggleExtension moves a local extension between extensions/ and // extensions-disabled/ directories. The agent's loader only scans extensions/, // so moving a directory out of it disables that extension. npm extensions live // elsewhere and are not managed here. func ToggleExtension(agentDir, extPath string, enable bool) error { extDir := filepath.Join(agentDir, "extensions") disabledDir := filepath.Join(agentDir, "extensions-disabled") if !isUnderDir(extPath, extDir) && !isUnderDir(extPath, disabledDir) { return fmt.Errorf("extension path not in managed directories") } name := filepath.Base(extPath) var src, dst string if enable { src = filepath.Join(disabledDir, name) dst = filepath.Join(extDir, name) } else { src = filepath.Join(extDir, name) dst = filepath.Join(disabledDir, name) } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } return os.Rename(src, dst) }