Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions cmd/install/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
35 changes: 18 additions & 17 deletions core/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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))
}
Expand All @@ -118,15 +119,15 @@ 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
}
}

destDir := filepath.Join(commandsDir, commandName)

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
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
88 changes: 88 additions & 0 deletions core/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -364,6 +366,92 @@ func disablePlugin(projectRoot, name string) error {
return updateCCMDMarketplace(projectRoot, name, false)
}

type pluginComponents struct {
Commands []string
Skills []string
Agents []string
MCPServers []string
}

func scanMDFilesInDir(dir string) []string {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
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
}

func scanSkillsDir(pluginDir string) []string {
entries, err := os.ReadDir(filepath.Join(pluginDir, "skills"))
if err != nil {
return nil
}
var names []string
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
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
}

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) {
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")
Expand Down
2 changes: 1 addition & 1 deletion core/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions core/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions tests/regression/install_behavior_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
Expand Down Expand Up @@ -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)
Expand Down
Loading