From 2685c2a4c29fe955835d109a3dac9114b3aa3418 Mon Sep 17 00:00:00 2001 From: gifflet Date: Sat, 21 Mar 2026 01:47:22 -0300 Subject: [PATCH 1/2] fix(plugin): show complete installation summary after plugin install --- cmd/install/install.go | 8 ++- core/install.go | 35 +++++----- core/plugin.go | 78 +++++++++++++++++++++++ core/sync.go | 2 +- core/update.go | 4 +- tests/regression/install_behavior_test.go | 4 +- 6 files changed, 106 insertions(+), 25 deletions(-) diff --git a/cmd/install/install.go b/cmd/install/install.go index d13f986..816c446 100644 --- a/cmd/install/install.go +++ b/cmd/install/install.go @@ -71,13 +71,15 @@ Examples: Force: force, } - commandName, err := core.Install(ctx, opts) + commandName, isPlugin, err := core.Install(ctx, opts) if err != nil { return err } - output.PrintInfof("\nTo use the command, run:") - output.PrintInfof("/%s", commandName) + if !isPlugin { + output.PrintInfof("\nTo use the command, run:") + output.PrintInfof("/%s", commandName) + } return nil }, diff --git a/core/install.go b/core/install.go index 739f251..9c6183b 100644 --- a/core/install.go +++ b/core/install.go @@ -36,11 +36,11 @@ type InstallOptions struct { } // Install installs a command from a Git repository -func Install(_ context.Context, opts InstallOptions) (string, error) { +func Install(_ context.Context, opts InstallOptions) (string, bool, error) { log := logger.New() if opts.Repository == "" { - return "", errors.InvalidInput("repository URL is required") + return "", false, errors.InvalidInput("repository URL is required") } repo, version := ParseRepositorySpec(opts.Repository) @@ -54,19 +54,19 @@ func Install(_ context.Context, opts InstallOptions) (string, error) { projectRoot, err := findProjectRoot() if err != nil { - return "", errors.FileError("find project root", "", err) + return "", false, errors.FileError("find project root", "", err) } ccmdDir := filepath.Join(projectRoot, ".claude") commandsDir := filepath.Join(ccmdDir, "commands") if err := os.MkdirAll(commandsDir, 0755); err != nil { - return "", errors.FileError("create commands directory", commandsDir, err) + return "", false, errors.FileError("create commands directory", commandsDir, err) } tempDir, err := os.MkdirTemp("", "ccmd-install-*") if err != nil { - return "", errors.FileError("create temp directory", "", err) + return "", false, errors.FileError("create temp directory", "", err) } defer os.RemoveAll(tempDir) @@ -76,17 +76,18 @@ func Install(_ context.Context, opts InstallOptions) (string, error) { cloneVersion = opts.Commit } if err := gitClone(repoURL, tempDir, cloneVersion); err != nil { - return "", errors.GitError("clone", err) + return "", false, errors.GitError("clone", err) } metadataPath := filepath.Join(tempDir, "ccmd.yaml") metadata, err := readCommandMetadata(metadataPath) if err != nil { - return "", err + return "", false, err } if repoType(metadata) == "plugin" { - return installPlugin(projectRoot, tempDir, metadata, opts) + name, err := installPlugin(projectRoot, tempDir, metadata, opts) + return name, true, err } commandName := opts.Name @@ -98,17 +99,17 @@ func Install(_ context.Context, opts InstallOptions) (string, error) { } if err := validateCommandName(commandName); err != nil { - return "", err + return "", false, err } targetRepoPath := ExtractRepoPath(repoURL) existingCommand, err := findExistingCommandByRepo(projectRoot, targetRepoPath) if err != nil { - return "", errors.FileError("check existing commands", "", err) + return "", false, errors.FileError("check existing commands", "", err) } if existingCommand != "" && !opts.Force { - return "", errors.AlreadyExists(fmt.Sprintf( + return "", false, errors.AlreadyExists(fmt.Sprintf( "repository already installed as command %q, use --force to reinstall", existingCommand)) } @@ -118,7 +119,7 @@ func Install(_ context.Context, opts InstallOptions) (string, error) { if opts.Force { output.PrintInfof("Removing previous installation %q...", existingCommand) if err := removeCommandFiles(projectRoot, existingCommand); err != nil { - return "", err + return "", false, err } } @@ -126,7 +127,7 @@ func Install(_ context.Context, opts InstallOptions) (string, error) { output.PrintInfof("Installing command %q...", commandName) if err := copyDirectory(tempDir, destDir); err != nil { - return "", errors.FileError("copy command files", destDir, err) + return "", false, errors.FileError("copy command files", destDir, err) } originalVersion := metadata.Version @@ -136,7 +137,7 @@ func Install(_ context.Context, opts InstallOptions) (string, error) { if err := writeCommandMetadata(filepath.Join(destDir, "ccmd.yaml"), metadata); err != nil { os.RemoveAll(destDir) - return "", err + return "", false, err } standalonePath := filepath.Join(ccmdDir, "commands", commandName+".md") @@ -166,7 +167,7 @@ func Install(_ context.Context, opts InstallOptions) (string, error) { output.PrintSuccessf("Command %q installed successfully", commandName) } - return commandName, nil + return commandName, false, nil } // InstallFromConfig installs all commands and plugins from project's ccmd.yaml @@ -201,7 +202,7 @@ func InstallFromConfig(ctx context.Context, projectPath string, force bool) erro } output.PrintInfof("Installing %s...", cmdSpec) - if _, err := Install(ctx, opts); err != nil { + if _, _, err := Install(ctx, opts); err != nil { if stderrors.Is(err, errors.ErrAlreadyExists) { output.PrintWarningf("%s already installed, use --force to reinstall", repo) } else { @@ -223,7 +224,7 @@ func InstallFromConfig(ctx context.Context, projectPath string, force bool) erro } output.PrintInfof("Installing plugin %s...", pluginSpec) - if _, err := Install(ctx, opts); err != nil { + if _, _, err := Install(ctx, opts); err != nil { if stderrors.Is(err, errors.ErrAlreadyExists) { output.PrintWarningf("plugin %s already installed, use --force to reinstall", repo) } else { diff --git a/core/plugin.go b/core/plugin.go index c41744e..824cd1e 100644 --- a/core/plugin.go +++ b/core/plugin.go @@ -14,6 +14,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" @@ -105,6 +106,7 @@ func installPlugin(projectRoot, tempDir string, cfg *ProjectConfig, opts Install } output.PrintSuccessf("Plugin %q installed successfully", name) + printPluginComponents(scanPluginComponents(destDir)) return name, nil } @@ -364,6 +366,82 @@ func disablePlugin(projectRoot, name string) error { return updateCCMDMarketplace(projectRoot, name, false) } +type pluginComponents struct { + Commands []string + Skills []string + Agents []string + MCPServers []string +} + +func scanPluginComponents(pluginDir string) pluginComponents { + var c pluginComponents + + // Commands: .md files directly in commands/ + if entries, err := os.ReadDir(filepath.Join(pluginDir, "commands")); err == nil { + for _, e := range entries { + if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") && strings.HasSuffix(e.Name(), ".md") { + c.Commands = append(c.Commands, strings.TrimSuffix(e.Name(), ".md")) + } + } + } + + // Skills: subdirs in skills/ that contain SKILL.md + if entries, err := os.ReadDir(filepath.Join(pluginDir, "skills")); err == nil { + for _, e := range entries { + if e.IsDir() && !strings.HasPrefix(e.Name(), ".") { + skillFile := filepath.Join(pluginDir, "skills", e.Name(), "SKILL.md") + if _, err := os.Stat(skillFile); err == nil { + c.Skills = append(c.Skills, e.Name()) + } + } + } + } + + // Agents: .md files in agents/ + if entries, err := os.ReadDir(filepath.Join(pluginDir, "agents")); err == nil { + for _, e := range entries { + if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") && strings.HasSuffix(e.Name(), ".md") { + c.Agents = append(c.Agents, strings.TrimSuffix(e.Name(), ".md")) + } + } + } + + // MCP servers: read .mcp.json and extract server names + mcpPath := filepath.Join(pluginDir, ".mcp.json") + if data, err := os.ReadFile(mcpPath); err == nil { + var mcpConfig struct { + MCPServers map[string]json.RawMessage `json:"mcpServers"` + } + if json.Unmarshal(data, &mcpConfig) == nil { + for name := range mcpConfig.MCPServers { + c.MCPServers = append(c.MCPServers, name) + } + sort.Strings(c.MCPServers) + } + } + + return c +} + +func printPluginComponents(c pluginComponents) { + if len(c.Agents)+len(c.Commands)+len(c.Skills)+len(c.MCPServers) == 0 { + return + } + output.PrintInfof("Components installed:") + if len(c.Agents) > 0 { + output.PrintInfof(" Agents: %s", strings.Join(c.Agents, ", ")) + } + if len(c.Commands) > 0 { + output.PrintInfof(" Commands: /%s", strings.Join(c.Commands, ", /")) + } + if len(c.Skills) > 0 { + output.PrintInfof(" Skills: %s", strings.Join(c.Skills, ", ")) + } + if len(c.MCPServers) > 0 { + output.PrintInfof(" MCP servers: %s", strings.Join(c.MCPServers, ", ")) + } +} + // updateCCMDMarketplace maintains .claude/plugins/.claude-plugin/marketplace.json. func updateCCMDMarketplace(projectRoot, pluginName string, add bool) error { pluginsDir := filepath.Join(projectRoot, ".claude", "plugins") diff --git a/core/sync.go b/core/sync.go index 871c431..0b9373d 100644 --- a/core/sync.go +++ b/core/sync.go @@ -130,7 +130,7 @@ func Sync(ctx context.Context, opts SyncOptions) (*SyncResult, error) { Force: false, } - if _, err := Install(ctx, installOpts); err != nil { + if _, _, err := Install(ctx, installOpts); err != nil { result.Failed = append(result.Failed, SyncError{ Command: cmd.Repo, Operation: "install", diff --git a/core/update.go b/core/update.go index 8919589..3e71ff1 100644 --- a/core/update.go +++ b/core/update.go @@ -104,7 +104,7 @@ func updateAllCommands(ctx context.Context, checkOnly, force bool) (*UpdateResul Force: true, } - if _, err := Install(ctx, opts); err != nil { + if _, _, err := Install(ctx, opts); err != nil { output.PrintErrorf("Failed to update %s: %v", cmd.Name, err) result.FailedCount++ } else { @@ -242,7 +242,7 @@ func updateSingleCommand(ctx context.Context, name string, checkOnly, force bool Force: true, } - if _, err := Install(ctx, opts); err != nil { + if _, _, err := Install(ctx, opts); err != nil { result.FailedCount = 1 return result, fmt.Errorf("failed to update: %w", err) } diff --git a/tests/regression/install_behavior_test.go b/tests/regression/install_behavior_test.go index ba64d98..94e1f77 100644 --- a/tests/regression/install_behavior_test.go +++ b/tests/regression/install_behavior_test.go @@ -30,7 +30,7 @@ func TestInstallCommandBehavior(t *testing.T) { Repository: "", } - _, err := core.Install(context.Background(), opts) + _, _, err := core.Install(context.Background(), opts) require.Error(t, err) assert.Contains(t, err.Error(), "repository URL is required") }) @@ -146,7 +146,7 @@ func TestInstallCommandErrorCases(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - _, err := core.Install(context.Background(), tc.opts) + _, _, err := core.Install(context.Background(), tc.opts) if tc.errContains != "" { require.Error(t, err) From f4535792ae97b9c0e24b3bd1171a3c3709208772 Mon Sep 17 00:00:00 2001 From: gifflet Date: Sat, 21 Mar 2026 02:33:58 -0300 Subject: [PATCH 2/2] refactor(plugin): extract scan helpers for component detection --- core/plugin.go | 88 ++++++++++++++++++++++++++++---------------------- 1 file changed, 49 insertions(+), 39 deletions(-) diff --git a/core/plugin.go b/core/plugin.go index 824cd1e..f80a095 100644 --- a/core/plugin.go +++ b/core/plugin.go @@ -373,54 +373,64 @@ type pluginComponents struct { MCPServers []string } -func scanPluginComponents(pluginDir string) pluginComponents { - var c pluginComponents - - // Commands: .md files directly in commands/ - if entries, err := os.ReadDir(filepath.Join(pluginDir, "commands")); err == nil { - for _, e := range entries { - if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") && strings.HasSuffix(e.Name(), ".md") { - c.Commands = append(c.Commands, strings.TrimSuffix(e.Name(), ".md")) - } - } +func scanMDFilesInDir(dir string) []string { + entries, err := os.ReadDir(dir) + if err != nil { + return nil } - - // Skills: subdirs in skills/ that contain SKILL.md - if entries, err := os.ReadDir(filepath.Join(pluginDir, "skills")); err == nil { - for _, e := range entries { - if e.IsDir() && !strings.HasPrefix(e.Name(), ".") { - skillFile := filepath.Join(pluginDir, "skills", e.Name(), "SKILL.md") - if _, err := os.Stat(skillFile); err == nil { - c.Skills = append(c.Skills, e.Name()) - } - } + var names []string + for _, e := range entries { + if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") && strings.HasSuffix(e.Name(), ".md") { + names = append(names, strings.TrimSuffix(e.Name(), ".md")) } } + return names +} - // Agents: .md files in agents/ - if entries, err := os.ReadDir(filepath.Join(pluginDir, "agents")); err == nil { - for _, e := range entries { - if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") && strings.HasSuffix(e.Name(), ".md") { - c.Agents = append(c.Agents, strings.TrimSuffix(e.Name(), ".md")) - } - } +func scanSkillsDir(pluginDir string) []string { + entries, err := os.ReadDir(filepath.Join(pluginDir, "skills")) + if err != nil { + return nil } - - // MCP servers: read .mcp.json and extract server names - mcpPath := filepath.Join(pluginDir, ".mcp.json") - if data, err := os.ReadFile(mcpPath); err == nil { - var mcpConfig struct { - MCPServers map[string]json.RawMessage `json:"mcpServers"` + var names []string + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue } - if json.Unmarshal(data, &mcpConfig) == nil { - for name := range mcpConfig.MCPServers { - c.MCPServers = append(c.MCPServers, name) - } - sort.Strings(c.MCPServers) + skillFile := filepath.Join(pluginDir, "skills", e.Name(), "SKILL.md") + if _, err := os.Stat(skillFile); err == nil { + names = append(names, e.Name()) } } + return names +} + +func scanMCPServers(pluginDir string) []string { + data, err := os.ReadFile(filepath.Join(pluginDir, ".mcp.json")) + if err != nil { + return nil + } + var mcpConfig struct { + MCPServers map[string]json.RawMessage `json:"mcpServers"` + } + if json.Unmarshal(data, &mcpConfig) != nil { + return nil + } + names := make([]string, 0, len(mcpConfig.MCPServers)) + for name := range mcpConfig.MCPServers { + names = append(names, name) + } + sort.Strings(names) + return names +} - return c +func scanPluginComponents(pluginDir string) pluginComponents { + return pluginComponents{ + Commands: scanMDFilesInDir(filepath.Join(pluginDir, "commands")), + Skills: scanSkillsDir(pluginDir), + Agents: scanMDFilesInDir(filepath.Join(pluginDir, "agents")), + MCPServers: scanMCPServers(pluginDir), + } } func printPluginComponents(c pluginComponents) {