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:
xarmian
2026-03-30 11:31:05 -04:00
committed by GitHub
parent a6befde8bf
commit 07ff6faed7
3 changed files with 408 additions and 42 deletions
+198 -40
View File
@@ -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{
+2 -2
View File
@@ -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",
},
+208
View File
@@ -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, &reg); err != nil {
// Corrupted file — start fresh
return &Registry{}, nil
}
return &reg, 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
}