From 07ff6faed72775441fbe2cf4ee2db2bf5990084e Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 30 Mar 2026 11:31:05 -0400 Subject: [PATCH] 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 --- cmd/pad/main.go | 238 ++++++++++++++++++++++++++++++++------- internal/cli/agents.go | 4 +- internal/cli/registry.go | 208 ++++++++++++++++++++++++++++++++++ 3 files changed, 408 insertions(+), 42 deletions(-) create mode 100644 internal/cli/registry.go diff --git a/cmd/pad/main.go b/cmd/pad/main.go index b3ab09c2..345453a7 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -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 ", - 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{ diff --git a/internal/cli/agents.go b/internal/cli/agents.go index 4be35118..78c3e64f 100644 --- a/internal/cli/agents.go +++ b/internal/cli/agents.go @@ -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", }, diff --git a/internal/cli/registry.go b/internal/cli/registry.go new file mode 100644 index 00000000..fed307f4 --- /dev/null +++ b/internal/cli/registry.go @@ -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 +}