mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
feat: global skill installation registry and detection fixes (#25)
Track all skill installations in ~/.pad/installations.json so `pad install --update` can update stale skill files across every project in one shot. Also fixes false Copilot detection on projects that have .github/ for CI but don't use Copilot. - Add Installation registry (internal/cli/registry.go) with record, prune, status, and update-all operations - Record installations from all install code paths (install, init, skills install, interactive, --all, --update) - `pad install --list` / `pad skills status` now show tracked installations across all projects with freshness indicators - `pad install --update` / `pad skills update` now update stale files globally, not just the current directory - `pad skills update` and `pad skills status` delegate to the same logic as `pad install --update` and `pad install --list` - Fix Copilot detection: use .github/copilot or .github/instructions instead of .github (which exists on most projects for CI) - Add .codex to agents detection directories - `pad init` on already-linked workspaces now records existing installations in the registry
This commit is contained in:
+198
-40
@@ -858,6 +858,7 @@ Use 'pad workspaces' to see available workspaces.`,
|
||||
ws, err := client.GetWorkspace(existingSlug)
|
||||
if err == nil && ws != nil {
|
||||
fmt.Printf("Already linked to workspace %q (slug: %s)\n", ws.Name, ws.Slug)
|
||||
offerSkillInstall()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -922,6 +923,13 @@ func offerSkillInstall() {
|
||||
}
|
||||
|
||||
if allInstalled && len(detected) > 0 {
|
||||
// Ensure existing installations are tracked in the registry
|
||||
for _, tool := range detected {
|
||||
path := cli.ToolSkillPath(tool)
|
||||
if path != "" {
|
||||
recordInstallation(tool.Name, path)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\n/pad skill already installed for %d tool(s). Run 'pad install --update' to update.\n", len(detected))
|
||||
return
|
||||
}
|
||||
@@ -939,6 +947,7 @@ func offerSkillInstall() {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Installed /pad skill for %s → %s\n", tool.Label, path)
|
||||
recordInstallation(tool.Name, path)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -967,6 +976,11 @@ func offerSkillInstall() {
|
||||
fmt.Println()
|
||||
for _, tool := range detected {
|
||||
if cli.ToolInstalled(tool) {
|
||||
// Already installed — just ensure it's tracked in the registry
|
||||
path := cli.ToolSkillPath(tool)
|
||||
if path != "" {
|
||||
recordInstallation(tool.Name, path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
content := cli.FormatForTool(tool, pad.PadSkill)
|
||||
@@ -977,6 +991,7 @@ func offerSkillInstall() {
|
||||
}
|
||||
color.New(color.FgGreen).Printf(" ✓ %s", tool.Label)
|
||||
fmt.Printf(" → %s\n", color.New(color.Faint).Sprint(path))
|
||||
recordInstallation(tool.Name, path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1170,42 +1185,17 @@ func skillsCmd() *cobra.Command {
|
||||
|
||||
updateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Update installed skills to the version bundled in this binary",
|
||||
Short: "Update all installed skills across all projects",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
location, installed := cli.SkillsInstalled()
|
||||
if !installed {
|
||||
return fmt.Errorf("skills not installed. Run 'pad skills install' first")
|
||||
}
|
||||
outdated, _ := cli.SkillsOutdated(pad.PadSkill)
|
||||
if !outdated {
|
||||
fmt.Println("Skills are already up to date.")
|
||||
return nil
|
||||
}
|
||||
path, err := cli.InstallSkill(pad.PadSkill, location)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Updated /pad skill at %s\n", path)
|
||||
return nil
|
||||
return installUpdate()
|
||||
},
|
||||
}
|
||||
|
||||
statusSubCmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Check if Claude Code skills are installed and up to date",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
location, installed := cli.SkillsInstalled()
|
||||
if !installed {
|
||||
fmt.Println("Skills not installed. Run 'pad skills install' to install.")
|
||||
return
|
||||
}
|
||||
outdated, _ := cli.SkillsOutdated(pad.PadSkill)
|
||||
if outdated {
|
||||
fmt.Printf("Skills installed (%s) — UPDATE AVAILABLE\n", location)
|
||||
fmt.Println(" Run 'pad skills update' to update.")
|
||||
} else {
|
||||
fmt.Printf("Skills installed (%s) — up to date\n", location)
|
||||
}
|
||||
Short: "Show skill installation status across all projects",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return installList()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1271,6 +1261,7 @@ Examples:
|
||||
}
|
||||
|
||||
func installList() error {
|
||||
// Show local tool status (current directory)
|
||||
detected := map[string]bool{}
|
||||
for _, t := range cli.DetectTools() {
|
||||
detected[t.Name] = true
|
||||
@@ -1293,11 +1284,54 @@ func installList() error {
|
||||
}
|
||||
fmt.Printf(" %-12s %s%s%s%s\n", tool.Name, tool.Label, aliases, det, status)
|
||||
}
|
||||
|
||||
// Show global installation registry
|
||||
reg, err := cli.LoadRegistry()
|
||||
if err != nil || len(reg.Installations) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
reg.Prune()
|
||||
_ = reg.Save()
|
||||
|
||||
statuses := reg.Status(pad.PadSkill)
|
||||
if len(statuses) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Tracked installations:")
|
||||
fmt.Println()
|
||||
|
||||
outdatedCount := 0
|
||||
for _, s := range statuses {
|
||||
tool := cli.ResolveTool(s.Tool)
|
||||
toolLabel := s.Tool
|
||||
if tool != nil {
|
||||
toolLabel = tool.Label
|
||||
}
|
||||
|
||||
state := "✓ up to date"
|
||||
if !s.Exists {
|
||||
state = "✗ missing"
|
||||
} else if s.Outdated {
|
||||
state = "⟳ update available"
|
||||
outdatedCount++
|
||||
}
|
||||
|
||||
fmt.Printf(" %-40s %-28s %s\n", s.ProjectPath, toolLabel, state)
|
||||
}
|
||||
|
||||
if outdatedCount > 0 {
|
||||
fmt.Printf("\n %d installation(s) can be updated. Run 'pad install --update' to update all.\n", outdatedCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func installUpdate() error {
|
||||
updated := 0
|
||||
// Phase 1: Update tools installed in the current directory
|
||||
localUpdated := 0
|
||||
for _, tool := range cli.SupportedTools {
|
||||
if !cli.ToolInstalled(tool) {
|
||||
continue
|
||||
@@ -1309,14 +1343,71 @@ func installUpdate() error {
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" ✓ Updated %s → %s\n", tool.Label, path)
|
||||
updated++
|
||||
recordInstallation(tool.Name, path)
|
||||
localUpdated++
|
||||
}
|
||||
if updated == 0 {
|
||||
fmt.Println("No tools installed. Run 'pad install' first.")
|
||||
|
||||
// Phase 2: Update all tracked installations across other projects
|
||||
reg, err := cli.LoadRegistry()
|
||||
if err != nil {
|
||||
if localUpdated == 0 {
|
||||
fmt.Println("No tools installed. Run 'pad install' first.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
reg.Prune()
|
||||
globalUpdated, updateErrors := reg.UpdateAll(pad.PadSkill, version)
|
||||
_ = reg.Save()
|
||||
|
||||
for _, e := range updateErrors {
|
||||
fmt.Fprintf(os.Stderr, " warning: %v\n", e)
|
||||
}
|
||||
|
||||
// Subtract local updates that were also counted as global (same project path)
|
||||
overlapCount := 0
|
||||
for _, inst := range reg.Installations {
|
||||
if inst.ProjectPath == cwd {
|
||||
overlapCount++
|
||||
}
|
||||
}
|
||||
|
||||
remoteUpdated := globalUpdated
|
||||
total := localUpdated + remoteUpdated
|
||||
if total == 0 {
|
||||
if localUpdated == 0 && len(reg.Installations) == 0 {
|
||||
fmt.Println("No tools installed. Run 'pad install' first.")
|
||||
} else {
|
||||
fmt.Println("All installations are up to date.")
|
||||
}
|
||||
} else {
|
||||
if remoteUpdated > 0 {
|
||||
fmt.Printf("\nUpdated %d installation(s) across all projects.\n", total)
|
||||
} else {
|
||||
fmt.Printf("\nUpdated %d tool(s) in current project.\n", localUpdated)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordInstallation stores a skill install in the global registry (~/.pad/installations.json).
|
||||
func recordInstallation(toolName, skillPath string) {
|
||||
reg, err := cli.LoadRegistry()
|
||||
if err != nil {
|
||||
return // best-effort — don't break install on registry errors
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ws, _ := cli.DetectWorkspace("")
|
||||
reg.Record(cwd, ws, toolName, skillPath, version)
|
||||
_ = reg.Save()
|
||||
}
|
||||
|
||||
func installForTool(name string) error {
|
||||
tool := cli.ResolveTool(name)
|
||||
if tool == nil {
|
||||
@@ -1329,6 +1420,7 @@ func installForTool(name string) error {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Installed /pad skill for %s → %s\n", tool.Label, path)
|
||||
recordInstallation(tool.Name, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1347,6 +1439,7 @@ func installAll() error {
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" ✓ %s → %s\n", tool.Label, path)
|
||||
recordInstallation(tool.Name, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1409,6 +1502,7 @@ func installInteractive() error {
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" ✓ %s → %s\n", tool.Label, path)
|
||||
recordInstallation(tool.Name, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1523,15 +1617,18 @@ func createCmd() *cobra.Command {
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <collection> <title>",
|
||||
Short: "Create a new item in a collection",
|
||||
Use: "create <collection> <title>",
|
||||
Aliases: []string{"save"},
|
||||
Short: "Create a new item in a collection",
|
||||
Long: `Create a new item in the specified collection.
|
||||
|
||||
Examples:
|
||||
pad create task "Fix OAuth redirect" --priority high
|
||||
pad create idea "Real-time collaboration" --category infrastructure
|
||||
pad create phase "API Redesign" --status active
|
||||
pad create doc "Payment Architecture" --category architecture --stdin`,
|
||||
pad create doc "Payment Architecture" --category architecture --stdin
|
||||
|
||||
Run with --help-collections to see available collections and their status values.`,
|
||||
ValidArgsFunction: completeCollectionNames,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -1635,6 +1732,13 @@ Examples:
|
||||
return []string{"low", "medium", "high", "critical"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
// Override help to append available collections with status values
|
||||
defaultHelp := cmd.HelpFunc()
|
||||
cmd.SetHelpFunc(func(c *cobra.Command, args []string) {
|
||||
defaultHelp(c, args)
|
||||
printAvailableCollections()
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1810,9 +1914,10 @@ func printItemsGroupedByCollection(items []models.Item) {
|
||||
|
||||
func showCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "show <ref>",
|
||||
Short: "Show item detail (fields + content)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "show <ref>",
|
||||
Aliases: []string{"read"},
|
||||
Short: "Show item detail (fields + content)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _ := getClient()
|
||||
ws := getWorkspace()
|
||||
@@ -3431,6 +3536,59 @@ func completeCollectionNames(cmd *cobra.Command, args []string, toComplete strin
|
||||
return names, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// printAvailableCollections fetches collections from the API and prints them
|
||||
// with their descriptions and valid status values. Used by create --help.
|
||||
// Fails silently if the server is unreachable or no workspace is configured.
|
||||
func printAvailableCollections() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if cli.EnsureServer(cfg) != nil {
|
||||
return
|
||||
}
|
||||
client := cli.NewClientFromURL(cfg.BaseURL())
|
||||
ws, err := cli.DetectWorkspace(workspaceFlag)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
colls, err := client.ListCollections(ws)
|
||||
if err != nil || len(colls) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("\nAvailable collections (this workspace):")
|
||||
for _, coll := range colls {
|
||||
icon := coll.Icon
|
||||
if icon == "" {
|
||||
icon = " "
|
||||
}
|
||||
desc := coll.Description
|
||||
if len(desc) > 50 {
|
||||
desc = desc[:47] + "..."
|
||||
}
|
||||
|
||||
// Parse schema to find status field options
|
||||
var schema models.CollectionSchema
|
||||
statusInfo := ""
|
||||
if err := json.Unmarshal([]byte(coll.Schema), &schema); err == nil {
|
||||
for _, field := range schema.Fields {
|
||||
if field.Key == "status" && len(field.Options) > 0 {
|
||||
statusInfo = " [" + strings.Join(field.Options, ", ") + "]"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if desc != "" {
|
||||
fmt.Printf(" %s %-16s %s%s\n", icon, coll.Slug, desc, statusInfo)
|
||||
} else {
|
||||
fmt.Printf(" %s %-16s%s\n", icon, coll.Slug, statusInfo)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// normalizeCollectionSlug maps common singular/short forms to actual collection slugs.
|
||||
func normalizeCollectionSlug(input string) string {
|
||||
aliases := map[string]string{
|
||||
|
||||
@@ -38,7 +38,7 @@ var SupportedTools = []AgentTool{
|
||||
Name: "agents",
|
||||
Label: "Codex / Cursor / Windsurf",
|
||||
Aliases: []string{"codex", "cursor", "windsurf"},
|
||||
DetectDirs: []string{".cursor", ".windsurf", ".agents"},
|
||||
DetectDirs: []string{".cursor", ".windsurf", ".codex", ".agents"},
|
||||
SkillDir: filepath.Join(".agents", "skills", "pad"),
|
||||
SkillFile: "SKILL.md",
|
||||
},
|
||||
@@ -46,7 +46,7 @@ var SupportedTools = []AgentTool{
|
||||
Name: "copilot",
|
||||
Label: "GitHub Copilot",
|
||||
Aliases: []string{"github-copilot"},
|
||||
DetectDirs: []string{".github"},
|
||||
DetectDirs: []string{filepath.Join(".github", "copilot"), filepath.Join(".github", "instructions")},
|
||||
SkillDir: filepath.Join(".github", "instructions"),
|
||||
SkillFile: "pad.instructions.md",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Installation records where a skill was installed for a specific tool in a specific project.
|
||||
type Installation struct {
|
||||
// ProjectPath is the absolute path to the project directory.
|
||||
ProjectPath string `json:"project_path"`
|
||||
// Workspace is the workspace slug (from .pad.toml), if known.
|
||||
Workspace string `json:"workspace,omitempty"`
|
||||
// Tool is the canonical tool name (e.g., "claude", "agents", "copilot").
|
||||
Tool string `json:"tool"`
|
||||
// SkillPath is the full path to the installed skill file.
|
||||
SkillPath string `json:"skill_path"`
|
||||
// InstalledAt is when the skill was last installed or updated.
|
||||
InstalledAt time.Time `json:"installed_at"`
|
||||
// Version is the pad binary version that wrote this installation.
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// Registry tracks all skill installations across projects for a user.
|
||||
type Registry struct {
|
||||
Installations []Installation `json:"installations"`
|
||||
}
|
||||
|
||||
// registryPath returns ~/.pad/installations.json.
|
||||
func registryPath() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, ".pad", "installations.json"), nil
|
||||
}
|
||||
|
||||
// LoadRegistry reads the installation registry from disk.
|
||||
// Returns an empty registry if the file doesn't exist.
|
||||
func LoadRegistry() (*Registry, error) {
|
||||
path, err := registryPath()
|
||||
if err != nil {
|
||||
return &Registry{}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &Registry{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read registry: %w", err)
|
||||
}
|
||||
|
||||
var reg Registry
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
// Corrupted file — start fresh
|
||||
return &Registry{}, nil
|
||||
}
|
||||
return ®, nil
|
||||
}
|
||||
|
||||
// Save writes the registry to disk.
|
||||
func (r *Registry) Save() error {
|
||||
path, err := registryPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure ~/.pad/ exists
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return fmt.Errorf("create registry directory: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal registry: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
// Record adds or updates an installation entry.
|
||||
func (r *Registry) Record(projectPath, workspace, tool, skillPath, version string) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Update existing entry if same project + tool
|
||||
for i := range r.Installations {
|
||||
inst := &r.Installations[i]
|
||||
if inst.ProjectPath == projectPath && inst.Tool == tool {
|
||||
inst.SkillPath = skillPath
|
||||
inst.Workspace = workspace
|
||||
inst.InstalledAt = now
|
||||
inst.Version = version
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Add new entry
|
||||
r.Installations = append(r.Installations, Installation{
|
||||
ProjectPath: projectPath,
|
||||
Workspace: workspace,
|
||||
Tool: tool,
|
||||
SkillPath: skillPath,
|
||||
InstalledAt: now,
|
||||
Version: version,
|
||||
})
|
||||
}
|
||||
|
||||
// Prune removes entries whose skill files no longer exist on disk.
|
||||
func (r *Registry) Prune() int {
|
||||
pruned := 0
|
||||
kept := r.Installations[:0]
|
||||
for _, inst := range r.Installations {
|
||||
if _, err := os.Stat(inst.SkillPath); err == nil {
|
||||
kept = append(kept, inst)
|
||||
} else {
|
||||
pruned++
|
||||
}
|
||||
}
|
||||
r.Installations = kept
|
||||
return pruned
|
||||
}
|
||||
|
||||
// InstallationStatus describes the state of a tracked installation.
|
||||
type InstallationStatus struct {
|
||||
Installation
|
||||
Exists bool `json:"exists"`
|
||||
Outdated bool `json:"outdated"`
|
||||
}
|
||||
|
||||
// Status checks each tracked installation and returns its current state.
|
||||
// embeddedContent is the raw embedded skill bytes, used for freshness comparison.
|
||||
func (r *Registry) Status(embeddedContent []byte) []InstallationStatus {
|
||||
var results []InstallationStatus
|
||||
for _, inst := range r.Installations {
|
||||
s := InstallationStatus{Installation: inst}
|
||||
|
||||
data, err := os.ReadFile(inst.SkillPath)
|
||||
if err != nil {
|
||||
s.Exists = false
|
||||
s.Outdated = true
|
||||
results = append(results, s)
|
||||
continue
|
||||
}
|
||||
|
||||
s.Exists = true
|
||||
|
||||
// Resolve what the content *should* be for this tool
|
||||
tool := ResolveTool(inst.Tool)
|
||||
if tool == nil {
|
||||
// Unknown tool — compare raw
|
||||
s.Outdated = !bytes.Equal(data, embeddedContent)
|
||||
} else {
|
||||
expected := FormatForTool(*tool, embeddedContent)
|
||||
s.Outdated = !bytes.Equal(data, expected)
|
||||
}
|
||||
|
||||
results = append(results, s)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// UpdateAll updates all tracked installations that are outdated.
|
||||
// Returns the number of installations updated and any errors encountered.
|
||||
func (r *Registry) UpdateAll(embeddedContent []byte, version string) (updated int, errors []error) {
|
||||
for i := range r.Installations {
|
||||
inst := &r.Installations[i]
|
||||
|
||||
tool := ResolveTool(inst.Tool)
|
||||
if tool == nil {
|
||||
errors = append(errors, fmt.Errorf("%s: unknown tool %q", inst.ProjectPath, inst.Tool))
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
currentData, err := os.ReadFile(inst.SkillPath)
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Errorf("%s (%s): file missing, skipping", inst.ProjectPath, tool.Label))
|
||||
continue
|
||||
}
|
||||
|
||||
expected := FormatForTool(*tool, embeddedContent)
|
||||
if bytes.Equal(currentData, expected) {
|
||||
continue // already up to date
|
||||
}
|
||||
|
||||
// Ensure directory exists (in case it was partially deleted)
|
||||
if err := os.MkdirAll(filepath.Dir(inst.SkillPath), 0755); err != nil {
|
||||
errors = append(errors, fmt.Errorf("%s (%s): %w", inst.ProjectPath, tool.Label, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.WriteFile(inst.SkillPath, expected, 0644); err != nil {
|
||||
errors = append(errors, fmt.Errorf("%s (%s): %w", inst.ProjectPath, tool.Label, err))
|
||||
continue
|
||||
}
|
||||
|
||||
inst.InstalledAt = time.Now().UTC()
|
||||
inst.Version = version
|
||||
updated++
|
||||
}
|
||||
|
||||
return updated, errors
|
||||
}
|
||||
Reference in New Issue
Block a user