From 901febb46ba304efb9eda47c48ff6a4ebf0d8b9c Mon Sep 17 00:00:00 2001 From: gifflet Date: Wed, 18 Mar 2026 17:21:02 -0300 Subject: [PATCH 1/3] feat(plugin): add Claude Code plugin support --- cmd/init/init.go | 87 +++++++-- cmd/init/init_test.go | 6 +- core/init.go | 83 ++++++++- core/install.go | 77 ++++++-- core/list.go | 47 ++++- core/metadata.go | 45 +++++ core/plugin.go | 415 ++++++++++++++++++++++++++++++++++++++++++ core/remove.go | 31 +++- core/types.go | 39 +++- 9 files changed, 790 insertions(+), 40 deletions(-) create mode 100644 core/plugin.go diff --git a/cmd/init/init.go b/cmd/init/init.go index 758a123..3f034cd 100644 --- a/cmd/init/init.go +++ b/cmd/init/init.go @@ -24,10 +24,12 @@ import ( // NewCommand creates a new init command. func NewCommand() *cobra.Command { + var plugin bool + cmd := &cobra.Command{ Use: "init", Short: "Initialize a new Claude Code Command project", - Long: `Initialize a new Claude Code Command project by creating the necessary + Long: `Initialize a new Claude Code Command project by creating the necessary configuration files and directory structure. This interactive command guides you through setting up a new ccmd project. It will @@ -36,39 +38,43 @@ description, author, and repository information. The command then generates a properly formatted ccmd.yaml file with your specifications. Additionally, it creates the .claude/commands directory structure required for -storing and managing Claude Code commands in your project.`, +storing and managing Claude Code commands in your project. + +Use --plugin to initialize as a Claude Code plugin instead.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return runInit() + return runInit(plugin) }, } + cmd.Flags().BoolVarP(&plugin, "plugin", "p", false, "Initialize as a Claude Code plugin") + return cmd } -func runInit() error { +func runInit(plugin bool) error { scanner := bufio.NewScanner(os.Stdin) + if plugin { + return runPluginInit(scanner) + } + output.Printf("This utility will walk you through creating a ccmd.yaml file.") output.Printf("Press ^C at any time to quit.\n") - // Get current directory currentDir, err := os.Getwd() if err != nil { return fmt.Errorf("failed to get current directory: %w", err) } - // Load existing config if any defaults, existingCommands, err := core.LoadExistingConfig(currentDir) if err != nil { output.PrintWarningf("Warning: %v", err) - // Continue with fresh defaults defaults = core.InitDefaults(currentDir) } else if existingCommands != nil { output.Printf("Loaded existing ccmd.yaml file.") } - // Prompt for each field name := promptUser(scanner, "name", defaults.Name) version := promptUser(scanner, "version", defaults.Version) description := promptUser(scanner, "description", defaults.Description) @@ -77,7 +83,6 @@ func runInit() error { entry := promptUser(scanner, "entry", defaults.Entry) tagsInput := promptUser(scanner, "tags (comma-separated)", core.FormatTags(defaults.Tags)) - // Create options opts := core.InitOptions{ Name: name, Version: version, @@ -89,7 +94,6 @@ func runInit() error { ProjectPath: currentDir, } - // Generate preview preview, err := core.GenerateConfigPreview(opts, existingCommands) if err != nil { return err @@ -98,14 +102,12 @@ func runInit() error { output.Printf("\nAbout to write to %s:\n", filepath.Join(currentDir, "ccmd.yaml")) output.Printf("%s", preview) - // Confirm confirm := promptUser(scanner, "\nIs this OK?", "yes") if !isConfirmation(confirm) { output.PrintWarningf("Canceled.") return nil } - // Initialize project if existingCommands != nil { err = core.InitProjectWithCommands(opts, existingCommands) } else { @@ -120,12 +122,71 @@ func runInit() error { output.PrintSuccessf("āœ“ Created ccmd.yaml") output.Printf("\nšŸŽ‰ ccmd project initialized!") - // Show next steps showNextSteps(opts) return nil } +func runPluginInit(scanner *bufio.Scanner) error { + output.Printf("This utility will walk you through creating a Claude Code plugin.") + output.Printf("Press ^C at any time to quit.\n") + + currentDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + + defaults := core.InitDefaults(currentDir) + + name := promptUser(scanner, "name", defaults.Name) + version := promptUser(scanner, "version", defaults.Version) + description := promptUser(scanner, "description", defaults.Description) + author := promptUser(scanner, "author", defaults.Author) + repository := promptUser(scanner, "repository", defaults.Repository) + tagsInput := promptUser(scanner, "tags (comma-separated)", core.FormatTags(defaults.Tags)) + + opts := core.InitOptions{ + Name: name, + Version: version, + Description: description, + Author: author, + Repository: repository, + Tags: core.ParseTags(tagsInput), + ProjectPath: currentDir, + Plugin: true, + } + + output.Printf("\nAbout to create plugin structure in %s:", currentDir) + output.Printf(" ccmd.yaml (type: plugin)") + output.Printf(" .claude-plugin/plugin.json") + output.Printf(" .claude/plugins/") + output.Printf(" commands/.gitkeep") + + confirm := promptUser(scanner, "\nIs this OK?", "yes") + if !isConfirmation(confirm) { + output.PrintWarningf("Canceled.") + return nil + } + + if err := core.InitPlugin(opts); err != nil { + return err + } + + output.PrintSuccessf("āœ“ Created ccmd.yaml (type: plugin)") + output.PrintSuccessf("āœ“ Created .claude-plugin/plugin.json") + output.PrintSuccessf("āœ“ Created .claude/plugins/ directory") + output.PrintSuccessf("āœ“ Created commands/.gitkeep") + output.Printf("\nšŸŽ‰ Claude Code plugin initialized!") + output.Printf("\nšŸš€ Publish your plugin:") + output.Printf(" 1. git add ccmd.yaml .claude-plugin/ commands/") + output.Printf(" 2. git commit -m \"feat: add %s plugin\"", name) + output.Printf(" 3. git push origin main") + output.Printf("\n✨ Then install with:") + output.PrintInfof(core.GetInstallCommand(repository)) + + return nil +} + func showNextSteps(opts core.InitOptions) { // Check if entry file exists entryPath := filepath.Join(opts.ProjectPath, opts.Entry) diff --git a/cmd/init/init_test.go b/cmd/init/init_test.go index 6c7856b..8308e76 100644 --- a/cmd/init/init_test.go +++ b/cmd/init/init_test.go @@ -127,7 +127,7 @@ yes defer func() { os.Stdin = oldStdin }() // Run the init command - err := runInit() + err := runInit(false) if err != nil { t.Fatalf("runInit() error = %v", err) } @@ -210,7 +210,7 @@ no defer func() { os.Stdin = oldStdin }() // Run the init command - err := runInit() + err := runInit(false) if err != nil { t.Fatalf("runInit() error = %v", err) } @@ -248,7 +248,7 @@ func TestRunInitDefaults(t *testing.T) { defer func() { os.Stdin = oldStdin }() // Run the init command - err := runInit() + err := runInit(false) if err != nil { t.Fatalf("runInit() error = %v", err) } diff --git a/core/init.go b/core/init.go index 1e0df5d..0ccd2b5 100644 --- a/core/init.go +++ b/core/init.go @@ -10,6 +10,7 @@ package core import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -31,6 +32,7 @@ type InitOptions struct { Tags []string ProjectPath string CommandMode bool // true for command mode, false for project mode + Plugin bool // true to initialize as a Claude Code plugin } // InitDefaults returns default values for init based on current directory @@ -138,7 +140,7 @@ func createOrderedConfig(opts InitOptions, existingCommands interface{}) ordered func InitProject(opts InitOptions) error { // Create .claude/commands directory claudeDir := filepath.Join(opts.ProjectPath, ".claude", "commands") - if err := os.MkdirAll(claudeDir, 0755); err != nil { + if err := os.MkdirAll(claudeDir, 0o750); err != nil { return errors.FileError("create .claude directory", claudeDir, err) } @@ -165,7 +167,7 @@ func InitProject(opts InitOptions) error { func InitProjectWithCommands(opts InitOptions, existingCommands interface{}) error { // Create .claude/commands directory claudeDir := filepath.Join(opts.ProjectPath, ".claude", "commands") - if err := os.MkdirAll(claudeDir, 0755); err != nil { + if err := os.MkdirAll(claudeDir, 0o750); err != nil { return errors.FileError("create .claude directory", claudeDir, err) } @@ -180,7 +182,7 @@ func InitProjectWithCommands(opts InitOptions, existingCommands interface{}) err // Write file configPath := filepath.Join(opts.ProjectPath, ConfigFileName) - if err := os.WriteFile(configPath, data, 0644); err != nil { + if err := os.WriteFile(configPath, data, 0o600); err != nil { return errors.FileError("write config", configPath, err) } @@ -222,6 +224,81 @@ func FormatTags(tags []string) string { return strings.Join(tags, ", ") } +// pluginManifest mirrors ccmd.yaml metadata for .claude-plugin/plugin.json +type pluginManifest struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Author map[string]string `json:"author,omitempty"` + Repository string `json:"repository,omitempty"` + License string `json:"license,omitempty"` +} + +// InitPlugin creates a new Claude Code plugin project with the given options. +func InitPlugin(opts InitOptions) error { + // Create .claude/plugins directory + pluginsDir := filepath.Join(opts.ProjectPath, ".claude", "plugins") + if err := os.MkdirAll(pluginsDir, 0o750); err != nil { + return errors.FileError("create .claude/plugins directory", pluginsDir, err) + } + + // Create .claude-plugin directory + claudePluginDir := filepath.Join(opts.ProjectPath, ".claude-plugin") + if err := os.MkdirAll(claudePluginDir, 0o750); err != nil { + return errors.FileError("create .claude-plugin directory", claudePluginDir, err) + } + + // Create commands directory with .gitkeep + commandsDir := filepath.Join(opts.ProjectPath, "commands") + if err := os.MkdirAll(commandsDir, 0o750); err != nil { + return errors.FileError("create commands directory", commandsDir, err) + } + gitkeepPath := filepath.Join(commandsDir, ".gitkeep") + if !fileExists(gitkeepPath) { + if err := os.WriteFile(gitkeepPath, []byte{}, 0o600); err != nil { + return errors.FileError("create .gitkeep", gitkeepPath, err) + } + } + + // Write ccmd.yaml with type: plugin + config := &ProjectConfig{ + Type: "plugin", + Name: opts.Name, + Version: opts.Version, + Description: opts.Description, + Author: opts.Author, + Repository: opts.Repository, + Tags: opts.Tags, + } + if err := SaveProjectConfig(opts.ProjectPath, config); err != nil { + return err + } + + // Write .claude-plugin/plugin.json + manifest := pluginManifest{ + Name: opts.Name, + Version: opts.Version, + Description: opts.Description, + Repository: opts.Repository, + License: "MIT", + } + if opts.Author != "" { + manifest.Author = map[string]string{"name": opts.Author} + } + + manifestData, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return errors.FileError("marshal plugin.json", "", err) + } + + pluginJSONPath := filepath.Join(claudePluginDir, "plugin.json") + if err := os.WriteFile(pluginJSONPath, manifestData, 0o600); err != nil { + return errors.FileError("write plugin.json", pluginJSONPath, err) + } + + return nil +} + // GetInstallCommand generates the install command for the repository func GetInstallCommand(repository string) string { if repository == "" { diff --git a/core/install.go b/core/install.go index 216781e..739f251 100644 --- a/core/install.go +++ b/core/install.go @@ -11,6 +11,7 @@ package core import ( "context" + stderrors "errors" "fmt" "io" "os" @@ -84,6 +85,10 @@ func Install(_ context.Context, opts InstallOptions) (string, error) { return "", err } + if repoType(metadata) == "plugin" { + return installPlugin(projectRoot, tempDir, metadata, opts) + } + commandName := opts.Name if commandName == "" { commandName = metadata.Name @@ -164,14 +169,14 @@ func Install(_ context.Context, opts InstallOptions) (string, error) { return commandName, nil } -// InstallFromConfig installs all commands from project's ccmd.yaml +// InstallFromConfig installs all commands and plugins from project's ccmd.yaml func InstallFromConfig(ctx context.Context, projectPath string, force bool) error { config, err := LoadProjectConfig(projectPath) if err != nil { return err } - if len(config.Commands) == 0 { + if len(config.Commands) == 0 && len(config.Plugins) == 0 { output.PrintInfof("No commands found in ccmd.yaml") return nil } @@ -183,19 +188,32 @@ func InstallFromConfig(ctx context.Context, projectPath string, force bool) erro } var installErrors []error + for _, cmdSpec := range config.Commands { repo, version := ParseCommandSpec(cmdSpec) + commitToInstall := resolveCommitFromLock(lockFile, repo, false) - commitToInstall := "" - if lockFile != nil { - normalizedRepo := NormalizeRepositoryURL(repo) - for _, lockCmd := range lockFile.Commands { - if NormalizeRepositoryURL(lockCmd.Source) == normalizedRepo { - commitToInstall = lockCmd.Commit - break - } + opts := InstallOptions{ + Repository: repo, + Version: version, + Commit: commitToInstall, + Force: force, + } + + output.PrintInfof("Installing %s...", cmdSpec) + if _, err := Install(ctx, opts); err != nil { + if stderrors.Is(err, errors.ErrAlreadyExists) { + output.PrintWarningf("%s already installed, use --force to reinstall", repo) + } else { + installErrors = append(installErrors, fmt.Errorf("%s: %w", repo, err)) + output.PrintErrorf("Failed to install %s: %v", repo, err) } } + } + + for _, pluginSpec := range config.Plugins { + repo, version := ParseCommandSpec(pluginSpec) + commitToInstall := resolveCommitFromLock(lockFile, repo, true) opts := InstallOptions{ Repository: repo, @@ -204,20 +222,51 @@ func InstallFromConfig(ctx context.Context, projectPath string, force bool) erro Force: force, } - output.PrintInfof("Installing %s...", cmdSpec) + output.PrintInfof("Installing plugin %s...", pluginSpec) if _, err := Install(ctx, opts); err != nil { - installErrors = append(installErrors, fmt.Errorf("%s: %w", repo, err)) - output.PrintErrorf("Failed to install %s: %v", repo, err) + if stderrors.Is(err, errors.ErrAlreadyExists) { + output.PrintWarningf("plugin %s already installed, use --force to reinstall", repo) + } else { + installErrors = append(installErrors, fmt.Errorf("%s: %w", repo, err)) + output.PrintErrorf("Failed to install plugin %s: %v", repo, err) + } } } if len(installErrors) > 0 { - return fmt.Errorf("failed to install %d commands", len(installErrors)) + return fmt.Errorf("failed to install %d package(s)", len(installErrors)) } return nil } +// resolveCommitFromLock finds the locked commit hash for a given repo spec. +// When isPlugin is true, it searches the Plugins map; otherwise Commands. +func resolveCommitFromLock(lockFile *LockFile, repo string, isPlugin bool) string { + if lockFile == nil { + return "" + } + + normalizedRepo := NormalizeRepositoryURL(repo) + + if isPlugin { + for _, lockPlugin := range lockFile.Plugins { + if NormalizeRepositoryURL(lockPlugin.Source) == normalizedRepo { + return lockPlugin.Commit + } + } + return "" + } + + for _, lockCmd := range lockFile.Commands { + if NormalizeRepositoryURL(lockCmd.Source) == normalizedRepo { + return lockCmd.Commit + } + } + + return "" +} + // Helper functions func readCommandMetadata(path string) (*ProjectConfig, error) { diff --git a/core/list.go b/core/list.go index bbdb2af..2624e6c 100644 --- a/core/list.go +++ b/core/list.go @@ -19,7 +19,7 @@ import ( "github.com/gifflet/ccmd/pkg/errors" ) -// CommandDetail represents detailed information about an installed command +// CommandDetail represents detailed information about an installed command or plugin type CommandDetail struct { Name string Version string @@ -30,6 +30,7 @@ type CommandDetail struct { InstalledAt string BrokenStructure bool StructureError string + Type string // "command" or "plugin" // Additional metadata from ccmd.yaml Tags []string License string @@ -85,6 +86,7 @@ func List(opts ListOptions) ([]CommandDetail, error) { UpdatedAt: info.UpdatedAt.Format(time.RFC3339), InstalledAt: info.InstalledAt.Format(time.RFC3339), Resolved: info.Resolved, + Type: "command", } // Check command structure @@ -103,7 +105,6 @@ func List(opts ListOptions) ([]CommandDetail, error) { if dirExists(cmdDir) { metadataPath := filepath.Join(cmdDir, "ccmd.yaml") if metadata, err := readCommandMetadata(metadataPath); err == nil { - // Use metadata values if available if metadata.Description != "" { cmd.Description = metadata.Description } @@ -117,7 +118,47 @@ func List(opts ListOptions) ([]CommandDetail, error) { cmd.License = metadata.License cmd.Homepage = metadata.Homepage cmd.Entry = metadata.Entry - // Requires field doesn't exist in current metadata model + } + } + + commands = append(commands, cmd) + } + + pluginsDir := filepath.Join(projectRoot, ".claude", "plugins") + + for name, info := range lockData.Plugins { + cmd := CommandDetail{ + Name: name, + Version: info.Version, + Repository: info.Source, + UpdatedAt: info.UpdatedAt.Format(time.RFC3339), + InstalledAt: info.InstalledAt.Format(time.RFC3339), + Resolved: info.Resolved, + Type: "plugin", + } + + pluginDir := filepath.Join(pluginsDir, name) + + if !dirExists(pluginDir) { + cmd.BrokenStructure = true + cmd.StructureError = "plugin directory not found" + } + + if dirExists(pluginDir) { + metadataPath := filepath.Join(pluginDir, "ccmd.yaml") + if metadata, err := readCommandMetadata(metadataPath); err == nil { + if metadata.Description != "" { + cmd.Description = metadata.Description + } + if metadata.Author != "" { + cmd.Author = metadata.Author + } + if metadata.Version != "" && cmd.Version == "" { + cmd.Version = metadata.Version + } + cmd.Tags = metadata.Tags + cmd.License = metadata.License + cmd.Homepage = metadata.Homepage } } diff --git a/core/metadata.go b/core/metadata.go index f39ae0e..709cf8b 100644 --- a/core/metadata.go +++ b/core/metadata.go @@ -10,6 +10,7 @@ package core import ( + "encoding/json" "os" "path/filepath" @@ -84,9 +85,53 @@ func ReadLockFile(path string) (*LockFile, error) { lock.Commands = make(map[string]*LockCommand) } + if lock.Plugins == nil { + lock.Plugins = make(map[string]*LockPlugin) + } + return &lock, nil } +// ReadClaudeSettings reads the .claude/settings.json file. +func ReadClaudeSettings(claudeDir string) (*ClaudeSettings, error) { + settingsPath := filepath.Join(claudeDir, "settings.json") + + data, err := os.ReadFile(settingsPath) + if err != nil { + if os.IsNotExist(err) { + return &ClaudeSettings{}, nil + } + return nil, errors.FileError("read claude settings", settingsPath, err) + } + + var settings ClaudeSettings + if err := json.Unmarshal(data, &settings); err != nil { + return nil, errors.FileError("parse claude settings", settingsPath, err) + } + + return &settings, nil +} + +// WriteClaudeSettings writes the .claude/settings.json file. +func WriteClaudeSettings(claudeDir string, s *ClaudeSettings) error { + settingsPath := filepath.Join(claudeDir, "settings.json") + + if err := os.MkdirAll(claudeDir, 0o750); err != nil { + return errors.FileError("create claude directory", claudeDir, err) + } + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return errors.FileError("marshal claude settings", settingsPath, err) + } + + if err := os.WriteFile(settingsPath, data, 0o600); err != nil { + return errors.FileError("write claude settings", settingsPath, err) + } + + return nil +} + // WriteLockFile writes the lock file to disk func WriteLockFile(path string, lockFile *LockFile) error { data, err := yaml.Marshal(lockFile) diff --git a/core/plugin.go b/core/plugin.go new file mode 100644 index 0000000..c41744e --- /dev/null +++ b/core/plugin.go @@ -0,0 +1,415 @@ +/* + * This file is part of ccmd. + * + * Copyright (c) 2025 Guilherme Silva Sousa + * + * Licensed under the MIT License + * See LICENSE file in the project root for full license information. + */ + +package core + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gifflet/ccmd/pkg/errors" + "github.com/gifflet/ccmd/pkg/output" +) + +func repoType(cfg *ProjectConfig) string { + if cfg.Type == "plugin" { + return "plugin" + } + return "command" +} + +// installPlugin copies a cloned plugin repo into .claude/plugins/{name} and +// registers it in .claude/settings.json and ccmd-lock.yaml. +func installPlugin(projectRoot, tempDir string, cfg *ProjectConfig, opts InstallOptions) (string, error) { + name := opts.Name + if name == "" { + name = cfg.Name + } + if name == "" { + name = extractCommandName(opts.Repository) + } + + if err := validateCommandName(name); err != nil { + return "", err + } + + pluginsDir := filepath.Join(projectRoot, ".claude", "plugins") + if err := os.MkdirAll(pluginsDir, 0o750); err != nil { + return "", errors.FileError("create plugins directory", pluginsDir, err) + } + + targetRepoPath := ExtractRepoPath(opts.Repository) + existingPlugin, err := findExistingPluginByRepo(projectRoot, targetRepoPath) + if err != nil { + return "", errors.FileError("check existing plugins", "", err) + } + + if existingPlugin != "" && !opts.Force { + return "", errors.AlreadyExists(fmt.Sprintf( + "repository already installed as plugin %q, use --force to reinstall", + existingPlugin)) + } + + if opts.Force && existingPlugin != "" { + output.PrintInfof("Removing previous installation %q...", existingPlugin) + if err := removePlugin(projectRoot, existingPlugin); err != nil { + return "", err + } + } + + destDir := filepath.Join(pluginsDir, name) + output.PrintInfof("Installing plugin %q...", name) + if err := copyDirectory(tempDir, destDir); err != nil { + return "", errors.FileError("copy plugin files", destDir, err) + } + + originalVersion := cfg.Version + cfg.Name = name + cfg.Repository = opts.Repository + + if err := writeCommandMetadata(filepath.Join(destDir, "ccmd.yaml"), cfg); err != nil { + if removeErr := os.RemoveAll(destDir); removeErr != nil { + output.PrintWarningf("Failed to cleanup plugin directory: %v", removeErr) + } + return "", err + } + + if err := enablePlugin(projectRoot, name); err != nil { + output.PrintWarningf("Failed to register plugin in settings.json: %v", err) + } + + if err := updatePluginLockFile(projectRoot, name, cfg, originalVersion, opts.Version); err != nil { + output.PrintWarningf("Failed to update lock file: %v", err) + } + + repoSpec := opts.Repository + if strings.Contains(repoSpec, "://") || strings.HasPrefix(repoSpec, "git@") { + repoSpec = ExtractRepoPath(repoSpec) + } + versionForConfig := opts.Version + if isCommitHash(versionForConfig) && len(versionForConfig) > 7 { + versionForConfig = versionForConfig[:7] + } + if err := addPluginToConfig(projectRoot, name, repoSpec, versionForConfig); err != nil { + output.PrintWarningf("Failed to update ccmd.yaml: %v", err) + } + + output.PrintSuccessf("Plugin %q installed successfully", name) + return name, nil +} + +// removePlugin deletes a plugin installation and removes it from settings and lock file. +func removePlugin(projectRoot, name string) error { + pluginDir := filepath.Join(projectRoot, ".claude", "plugins", name) + + if dirExists(pluginDir) { + output.PrintInfof("Removing plugin directory...") + if err := os.RemoveAll(pluginDir); err != nil { + return errors.FileError("remove plugin directory", pluginDir, err) + } + } + + if err := disablePlugin(projectRoot, name); err != nil { + output.PrintWarningf("Failed to remove plugin from settings.json: %v", err) + } + + lockPath := filepath.Join(projectRoot, LockFileName) + if fileExists(lockPath) { + lockFile, err := ReadLockFile(lockPath) + if err == nil { + delete(lockFile.Plugins, name) + if writeErr := WriteLockFile(lockPath, lockFile); writeErr != nil { + output.PrintWarningf("Failed to update lock file: %v", writeErr) + } + } + } + + return nil +} + +func findExistingPluginByRepo(projectRoot, targetRepoPath string) (string, error) { + pluginsDir := filepath.Join(projectRoot, ".claude", "plugins") + entries, err := os.ReadDir(pluginsDir) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + metadataPath := filepath.Join(pluginsDir, entry.Name(), "ccmd.yaml") + if metadata, err := readCommandMetadata(metadataPath); err == nil && metadata.Repository != "" { + if ExtractRepoPath(metadata.Repository) == targetRepoPath { + return entry.Name(), nil + } + } + } + + return "", nil +} + +func addPluginToConfig(projectRoot, _, repository, version string) error { + var config *ProjectConfig + if ProjectConfigExists(projectRoot) { + var err error + config, err = LoadProjectConfig(projectRoot) + if err != nil { + return err + } + } else { + config = &ProjectConfig{} + } + + pluginSpec := repository + if version != "" { + pluginSpec = fmt.Sprintf("%s@%s", repository, version) + } + + currentRepo := ExtractRepoPath(repository) + found := false + + for i, spec := range config.Plugins { + repo, _ := ParseCommandSpec(spec) + if ExtractRepoPath(repo) == currentRepo { + config.Plugins[i] = pluginSpec + found = true + break + } + } + + if !found { + config.Plugins = append(config.Plugins, pluginSpec) + } + + return SaveProjectConfig(projectRoot, config) +} + +func removePluginFromConfig(projectRoot, name, repository string) error { + configPath := filepath.Join(projectRoot, ConfigFileName) + if !fileExists(configPath) { + return nil + } + + config, err := LoadProjectConfig(projectRoot) + if err != nil { + return err + } + + currentRepo := ExtractRepoPath(repository) + newPlugins := make([]string, 0, len(config.Plugins)) + for _, spec := range config.Plugins { + repo, _ := ParseCommandSpec(spec) + if ExtractRepoPath(repo) == currentRepo || extractCommandName(repo) == name { + continue + } + newPlugins = append(newPlugins, spec) + } + + config.Plugins = newPlugins + return SaveProjectConfig(projectRoot, config) +} + +func updatePluginLockFile( + projectRoot, name string, + cfg *ProjectConfig, + originalVersion, requestedVersion string, +) error { + lockPath := filepath.Join(projectRoot, LockFileName) + now := time.Now() + + var lockFile *LockFile + if fileExists(lockPath) { + var err error + lockFile, err = ReadLockFile(lockPath) + if err != nil { + return err + } + } else { + lockFile = &LockFile{ + Version: "1.0", + LockfileVersion: 1, + Commands: make(map[string]*LockCommand), + Plugins: make(map[string]*LockPlugin), + } + } + + commitHash := "unknown" + pluginPath := filepath.Join(projectRoot, ".claude", "plugins", name) + if hash, err := gitGetCurrentCommit(pluginPath); err == nil { + commitHash = hash + } + + resolved := cfg.Repository + if requestedVersion != "" { + resolved = fmt.Sprintf("%s@%s", cfg.Repository, requestedVersion) + } else { + if defaultBranch, err := gitGetDefaultBranch(pluginPath); err == nil { + resolved = fmt.Sprintf("%s@%s", cfg.Repository, defaultBranch) + } else if commitHash != "unknown" && len(commitHash) >= 7 { + resolved = fmt.Sprintf("%s@%s", cfg.Repository, commitHash[:7]) + } + } + + repoPath := ExtractRepoPath(cfg.Repository) + var existingKey string + var existingPlugin *LockPlugin + + for key, p := range lockFile.Plugins { + if ExtractRepoPath(p.Source) == repoPath { + existingKey = key + existingPlugin = p + break + } + } + + installedAt := now + if existingPlugin != nil && !existingPlugin.InstalledAt.IsZero() { + installedAt = existingPlugin.InstalledAt + } + + if existingKey != "" && existingKey != name { + delete(lockFile.Plugins, existingKey) + } + + lockFile.Plugins[name] = &LockPlugin{ + Name: name, + Version: originalVersion, + Source: cfg.Repository, + Resolved: resolved, + Commit: commitHash, + InstalledAt: installedAt, + UpdatedAt: now, + } + + return WriteLockFile(lockPath, lockFile) +} + +type ccmdMarketplace struct { + Name string `json:"name"` + Owner map[string]string `json:"owner"` + Plugins []ccmdMarketplacePlugin `json:"plugins"` +} + +type ccmdMarketplacePlugin struct { + Name string `json:"name"` + Source string `json:"source"` + Description string `json:"description,omitempty"` +} + +// enablePlugin adds the plugin to .claude/settings.json enabledPlugins and +// registers the ccmd marketplace in extraKnownMarketplaces. +func enablePlugin(projectRoot, name string) error { + claudeDir := filepath.Join(projectRoot, ".claude") + settings, err := ReadClaudeSettings(claudeDir) + if err != nil { + return err + } + + if settings.EnabledPlugins == nil { + settings.EnabledPlugins = make(map[string]bool) + } + settings.EnabledPlugins[fmt.Sprintf("%s@ccmd", name)] = true + + pluginsDir := filepath.Join(projectRoot, ".claude", "plugins") + absPluginsDir, absErr := filepath.Abs(pluginsDir) + if absErr != nil { + absPluginsDir = pluginsDir + } + + if settings.ExtraKnownMarketplaces == nil { + settings.ExtraKnownMarketplaces = make(map[string]MarketplaceEntry) + } + settings.ExtraKnownMarketplaces["ccmd"] = MarketplaceEntry{ + Source: MarketplaceSource{ + Source: "directory", + Path: absPluginsDir, + }, + } + + if err := WriteClaudeSettings(claudeDir, settings); err != nil { + return err + } + + return updateCCMDMarketplace(projectRoot, name, true) +} + +// disablePlugin removes the plugin from .claude/settings.json enabledPlugins. +func disablePlugin(projectRoot, name string) error { + claudeDir := filepath.Join(projectRoot, ".claude") + settings, err := ReadClaudeSettings(claudeDir) + if err != nil { + return err + } + + delete(settings.EnabledPlugins, fmt.Sprintf("%s@ccmd", name)) + + if err := WriteClaudeSettings(claudeDir, settings); err != nil { + return err + } + + return updateCCMDMarketplace(projectRoot, name, false) +} + +// updateCCMDMarketplace maintains .claude/plugins/.claude-plugin/marketplace.json. +func updateCCMDMarketplace(projectRoot, pluginName string, add bool) error { + pluginsDir := filepath.Join(projectRoot, ".claude", "plugins") + marketplaceDir := filepath.Join(pluginsDir, ".claude-plugin") + if err := os.MkdirAll(marketplaceDir, 0o750); err != nil { + return errors.FileError("create marketplace directory", marketplaceDir, err) + } + + marketplacePath := filepath.Join(marketplaceDir, "marketplace.json") + + var marketplace ccmdMarketplace + if data, err := os.ReadFile(marketplacePath); err == nil { + _ = json.Unmarshal(data, &marketplace) + } + + if marketplace.Name == "" { + marketplace.Name = "ccmd" + marketplace.Owner = map[string]string{"name": "ccmd"} + } + + updated := make([]ccmdMarketplacePlugin, 0, len(marketplace.Plugins)) + for _, p := range marketplace.Plugins { + if p.Name != pluginName { + updated = append(updated, p) + } + } + + if add { + description := "" + metadataPath := filepath.Join(pluginsDir, pluginName, "ccmd.yaml") + if metadata, err := readCommandMetadata(metadataPath); err == nil { + description = metadata.Description + } + updated = append(updated, ccmdMarketplacePlugin{ + Name: pluginName, + Source: "./" + pluginName, + Description: description, + }) + } + + marketplace.Plugins = updated + + data, err := json.MarshalIndent(marketplace, "", " ") + if err != nil { + return errors.FileError("marshal marketplace", marketplacePath, err) + } + + return os.WriteFile(marketplacePath, data, 0o600) +} diff --git a/core/remove.go b/core/remove.go index 77b4723..beb5574 100644 --- a/core/remove.go +++ b/core/remove.go @@ -49,9 +49,34 @@ func Remove(opts RemoveOptions) error { return err } - cmdInfo, exists := lockFile.Commands[opts.Name] - if !exists { - return errors.NotFound(fmt.Sprintf("command %q", opts.Name)) + cmdInfo, isCommand := lockFile.Commands[opts.Name] + pluginInfo, isPlugin := lockFile.Plugins[opts.Name] + + if !isCommand && !isPlugin { + return errors.NotFound(fmt.Sprintf("command or plugin %q", opts.Name)) + } + + if isPlugin { + output.PrintInfof("Will remove plugin %q", opts.Name) + output.PrintInfof("Repository: %s", pluginInfo.Source) + if pluginInfo.Version != "" { + output.PrintInfof("Version: %s", pluginInfo.Version) + } + + if err := removePlugin(projectRoot, opts.Name); err != nil { + return err + } + + if opts.UpdateFiles { + if err := removePluginFromConfig(projectRoot, opts.Name, pluginInfo.Source); err != nil { + output.PrintWarningf("Failed to update ccmd.yaml: %v", err) + } else { + output.PrintInfof("Updated ccmd.yaml") + } + } + + output.PrintSuccessf("Plugin %q removed successfully", opts.Name) + return nil } if err := removeCommandFiles(projectRoot, opts.Name); err != nil { diff --git a/core/types.go b/core/types.go index 5ff65b0..b6649b8 100644 --- a/core/types.go +++ b/core/types.go @@ -24,6 +24,7 @@ type LockFile struct { Version string `yaml:"version"` LockfileVersion int `yaml:"lockfileVersion"` Commands map[string]*LockCommand `yaml:"commands"` + Plugins map[string]*LockPlugin `yaml:"plugins,omitempty"` } // LockCommand represents a command entry in the lock file @@ -37,6 +38,36 @@ type LockCommand struct { UpdatedAt time.Time `yaml:"updated_at"` } +// LockPlugin represents a plugin entry in the lock file +type LockPlugin struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Source string `yaml:"source"` + Resolved string `yaml:"resolved"` + Commit string `yaml:"commit"` + InstalledAt time.Time `yaml:"installed_at"` + UpdatedAt time.Time `yaml:"updated_at"` +} + +// MarketplaceSource represents the source configuration for a plugin marketplace +type MarketplaceSource struct { + Source string `json:"source"` + Path string `json:"path,omitempty"` + Repo string `json:"repo,omitempty"` + URL string `json:"url,omitempty"` +} + +// MarketplaceEntry represents an entry in extraKnownMarketplaces +type MarketplaceEntry struct { + Source MarketplaceSource `json:"source"` +} + +// ClaudeSettings represents the .claude/settings.json structure +type ClaudeSettings struct { + EnabledPlugins map[string]bool `json:"enabledPlugins,omitempty"` + ExtraKnownMarketplaces map[string]MarketplaceEntry `json:"extraKnownMarketplaces,omitempty"` +} + // InstalledCommand represents an installed command type InstalledCommand struct { Name string @@ -60,8 +91,14 @@ type ProjectConfig struct { License string `yaml:"license,omitempty" json:"license,omitempty"` Homepage string `yaml:"homepage,omitempty" json:"homepage,omitempty"` + // Type indicates whether this is a "plugin" or command (default) + Type string `yaml:"type,omitempty" json:"type,omitempty"` + // Commands list (when ccmd.yaml is for a project) Commands []string `yaml:"commands,omitempty" json:"commands,omitempty"` + + // Plugins list (when ccmd.yaml is for a project) + Plugins []string `yaml:"plugins,omitempty" json:"plugins,omitempty"` } // ConfigCommand represents a command in the configuration @@ -95,7 +132,7 @@ func (pc *ProjectConfig) Validate() error { if pc.Repository == "" { return errors.InvalidInput("repository is required") } - if pc.Entry == "" { + if pc.Entry == "" && pc.Type != "plugin" { return errors.InvalidInput("entry is required") } } From 0443c8f924d3d97ace2743fa9afcf5d80453d82e Mon Sep 17 00:00:00 2001 From: gifflet Date: Wed, 18 Mar 2026 17:49:11 -0300 Subject: [PATCH 2/3] docs(plugins): add plugin support documentation and examples --- README.md | 53 +++++++- examples/ccmd-lock-example.yaml | 11 +- examples/creating_plugins.md | 94 ++++++++++++++ examples/install_plugin_example.md | 64 +++++++++ hugo-docs/content/en/plugins/_index.md | 171 +++++++++++++++++++++++++ hugo-docs/content/en/usage/_index.md | 81 +++++++----- 6 files changed, 435 insertions(+), 39 deletions(-) create mode 100644 examples/creating_plugins.md create mode 100644 examples/install_plugin_example.md create mode 100644 hugo-docs/content/en/plugins/_index.md diff --git a/README.md b/README.md index be71f0d..1f79d86 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Managing custom Claude Code commands across multiple projects can be challenging - **Easy sharing**: Share commands with your team or the community through Git repositories - **Simple management**: Install, update, and remove commands with familiar package manager semantics -Think of ccmd as "npm for Claude Code commands" - centralize your AI tooling configurations and use them anywhere. +Think of ccmd as "npm for Claude Code commands and plugins" - centralize your AI tooling configurations and use them anywhere. ## Installation @@ -62,11 +62,12 @@ That's it! You've just installed and used your first ccmd command. | Command | Description | |---------|-------------| | `ccmd init` | Initialize a new command project | -| `ccmd install ` | Install a command from a Git repository | -| `ccmd install` | Install all commands from ccmd.yaml | -| `ccmd list` | List installed commands | +| `ccmd init --plugin` | Initialize a new plugin project | +| `ccmd install ` | Install a command or plugin from a Git repository (auto-detected) | +| `ccmd install` | Install all commands and plugins from ccmd.yaml | +| `ccmd list` | List installed commands and plugins | | `ccmd update ` | Update a specific command | -| `ccmd remove ` | Remove an installed command | +| `ccmd remove ` | Remove an installed command or plugin | | `ccmd search ` | Search for commands in the registry | | `ccmd info ` | Show detailed command information | @@ -107,6 +108,38 @@ entry: index.md # Optional, defaults to index.md > For complete guide with examples, see [Creating Commands](docs/creating-commands.md) +## Plugin Support + +ccmd also manages Claude Code plugins — packages that extend Claude Code itself rather than defining slash commands. + +### Installing a Plugin + +```bash +ccmd install gifflet/review-plugin +``` + +ccmd automatically detects whether a repository is a plugin or a command by reading the `type` field in its `ccmd.yaml`. No special flags are needed. + +### Creating a Plugin + +```bash +mkdir my-plugin && cd my-plugin +ccmd init --plugin +``` + +### Example ccmd.yaml for a Plugin + +```yaml +type: plugin +name: my-plugin +version: 1.0.0 +description: Extends Claude Code with custom capabilities +author: Your Name +repository: https://github.com/username/my-plugin +``` + +> For complete guide, see [Creating Plugins](docs/creating-commands.md) + ## Example Commands Here are some commands you can install and try: @@ -116,10 +149,20 @@ Here are some commands you can install and try: ccmd install https://github.com/gifflet/hello-world ``` +## Example Plugins + +Here are some plugins you can install and try: + +- **review-plugin**: AI-powered code review plugin for Claude Code + ```bash + ccmd install gifflet/review-plugin + ``` + ## Documentation - **[Full Documentation](docs/)** - Complete guides and references - **[Command Creation Guide](docs/creating-commands.md)** - Create your own commands +- **[Plugin Creation Guide](examples/creating_plugins.md)** - Create your own plugins ## Community diff --git a/examples/ccmd-lock-example.yaml b/examples/ccmd-lock-example.yaml index aeff09d..0533ddc 100644 --- a/examples/ccmd-lock-example.yaml +++ b/examples/ccmd-lock-example.yaml @@ -40,4 +40,13 @@ commands: file_size: 87654321 checksum: fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321 metadata: - build_tags: extended \ No newline at end of file + build_tags: extended + +plugins: + review-plugin: + name: review-plugin + repository: github.com/gifflet/review-plugin + version: v1.0.0 + commit_hash: aabbccddeeff00112233445566778899aabbccdd + installed_at: 2024-01-16T08:00:00Z + updated_at: 2024-01-16T08:00:00Z \ No newline at end of file diff --git a/examples/creating_plugins.md b/examples/creating_plugins.md new file mode 100644 index 0000000..a6848bd --- /dev/null +++ b/examples/creating_plugins.md @@ -0,0 +1,94 @@ +# Creating Claude Code Plugins + +This guide walks through creating a ccmd-compatible Claude Code plugin, using [review-plugin](https://github.com/gifflet/review-plugin) as a reference example. + +## Plugins vs Commands + +| | Commands | Plugins | +|-|----------|---------| +| Installation dir | `.claude/commands/{name}` | `.claude/plugins/{name}` | +| `ccmd.yaml` type | (omitted) | `type: plugin` | +| Entry field | Required | Not required | +| Integration | Slash commands (`/name`) | Claude Code plugin system | +| Marketplace | No | Yes | + +Use a **command** when you want to define a reusable slash command (`/my-command`) that Claude follows. + +Use a **plugin** when you want to extend Claude Code itself — adding tools, context sources, or integrations. + +## Quick Start + +```bash +mkdir my-plugin && cd my-plugin +ccmd init --plugin +``` + +This creates the following structure: + +``` +my-plugin/ +ā”œā”€ā”€ ccmd.yaml # Plugin metadata with type: plugin +ā”œā”€ā”€ .claude-plugin/ +│ └── plugin.json # Claude Code plugin manifest +└── README.md # Plugin documentation +``` + +## ccmd.yaml for a Plugin + +The key difference from a command is `type: plugin`. The `entry` field is not required. + +```yaml +type: plugin +name: review-plugin +version: 1.0.0 +description: AI-powered code review plugin for Claude Code +author: Your Name +repository: https://github.com/username/review-plugin +tags: + - code-review + - quality +license: MIT +``` + +## Plugin Manifest (.claude-plugin/plugin.json) + +This file is read by Claude Code to register the plugin: + +```json +{ + "name": "review-plugin", + "version": "1.0.0", + "description": "AI-powered code review plugin for Claude Code", + "author": { + "name": "Your Name" + }, + "repository": "https://github.com/username/review-plugin", + "license": "MIT" +} +``` + +## Example: review-plugin Structure + +The [gifflet/review-plugin](https://github.com/gifflet/review-plugin) follows this structure: + +``` +review-plugin/ +ā”œā”€ā”€ ccmd.yaml # type: plugin +ā”œā”€ā”€ .claude-plugin/ +│ └── plugin.json # Plugin manifest +└── README.md +``` + +To install and try it: + +```bash +ccmd install gifflet/review-plugin +``` + +## Publishing Your Plugin + +1. Push your repository to GitHub +2. Create a release tag: `git tag v1.0.0 && git push --tags` +3. Users install it with: `ccmd install username/my-plugin` + +ccmd resolves the tag automatically and records it in `ccmd-lock.yaml`. diff --git a/examples/install_plugin_example.md b/examples/install_plugin_example.md new file mode 100644 index 0000000..fc3ed3d --- /dev/null +++ b/examples/install_plugin_example.md @@ -0,0 +1,64 @@ +# Plugin Installation Examples + +This document shows how to install Claude Code plugins using ccmd. + +## Basic Plugin Installation + +ccmd automatically detects whether a repository is a plugin or a command by reading the `type` field in the repository's `ccmd.yaml`. No special flags are needed. + +```bash +# Install a plugin (auto-detected via type: plugin in the repo's ccmd.yaml) +ccmd install gifflet/review-plugin + +# Install a specific version +ccmd install gifflet/review-plugin@1.0.0 + +# Install using full URL +ccmd install https://github.com/gifflet/review-plugin +``` + +## Install All Plugins and Commands from ccmd.yaml + +```bash +# Installs all entries in the plugins: and commands: sections +ccmd install +``` + +## Force Plugin Installation + +The `--plugin` flag is optional and only needed to force installation as a plugin when the repository does not have `type: plugin` in its `ccmd.yaml`. + +```bash +# Force installation as plugin (use only when auto-detection is not available) +ccmd install user/some-repo --plugin +``` + +## Project ccmd.yaml with Plugins + +When you install a plugin, ccmd updates your project's `ccmd.yaml` automatically: + +```yaml +name: my-project +version: 1.0.0 +commands: + - gifflet/hello-world@1.0.0 +plugins: + - gifflet/review-plugin@1.0.0 +``` + +## What Happens During Plugin Installation + +1. Plugin repository is cloned to `.claude/plugins/{name}/` +2. Plugin is registered in `.claude/settings.json` under `enabledPlugins` +3. A marketplace entry is created/updated at `.claude/plugins/.claude-plugin/marketplace.json` +4. Claude Code discovers the plugin automatically on next startup + +## After Installation + +Plugins appear in `ccmd list` with `plugin` in the Type column: + +``` +NAME VERSION TYPE DESCRIPTION +hello-world 1.0.0 command Simple demo command +review-plugin 1.0.0 plugin AI-powered code review +``` diff --git a/hugo-docs/content/en/plugins/_index.md b/hugo-docs/content/en/plugins/_index.md new file mode 100644 index 0000000..889082f --- /dev/null +++ b/hugo-docs/content/en/plugins/_index.md @@ -0,0 +1,171 @@ +--- +title: "Plugin Support" +linkTitle: "Plugins" +weight: 35 +type: docs +description: > + Manage Claude Code plugins with ccmd. Install, create, and share plugins that extend Claude Code's capabilities. +keywords: ["ccmd plugins", "Claude Code plugins", "plugin manager", "Claude plugin", "AI plugins"] +--- + +ccmd supports not only slash commands but also Claude Code plugins — packages that extend Claude Code's own capabilities through its plugin system. + +## Commands vs Plugins + +| | Commands | Plugins | +|-|----------|---------| +| **Purpose** | Define reusable slash commands (`/name`) | Extend Claude Code itself | +| **Installation dir** | `.claude/commands/{name}` | `.claude/plugins/{name}` | +| **`ccmd.yaml` type** | (omitted, default) | `type: plugin` | +| **Entry field** | Required | Not required | +| **Registration** | Lock file only | Lock file + `settings.json` | +| **Marketplace** | No | Yes | + +Use **commands** to define AI instructions invoked via `/slash-command`. + +Use **plugins** to add tools, integrations, or context sources that Claude Code loads at startup. + +## Installing a Plugin + +ccmd automatically detects whether a repository is a plugin or a command by reading the `type` field in its `ccmd.yaml`. No special flags required. + +```bash +# Install a plugin (auto-detected) +ccmd install gifflet/review-plugin + +# Install a specific version +ccmd install gifflet/review-plugin@1.0.0 +``` + +After installation, the plugin appears in `ccmd list` with type `plugin`: + +``` +NAME VERSION TYPE DESCRIPTION +hello-world 1.0.0 command Simple demo command +review-plugin 1.0.0 plugin AI-powered code review +``` + +### What Happens During Installation + +1. Plugin repository is cloned to `.claude/plugins/{name}/` +2. Plugin is registered in `.claude/settings.json`: + ```json + { + "enabledPlugins": { + "review-plugin@ccmd": true + }, + "extraKnownMarketplaces": { + "ccmd": { ... } + } + } + ``` +3. Marketplace is updated at `.claude/plugins/.claude-plugin/marketplace.json` +4. Claude Code discovers the plugin automatically on next startup + +## Installing All Plugins from ccmd.yaml + +When your project declares plugins in `ccmd.yaml`, run: + +```bash +ccmd install +``` + +This installs all `commands` and `plugins` entries simultaneously. + +### Project ccmd.yaml Example + +```yaml +name: my-project +version: 1.0.0 +commands: + - gifflet/hello-world@1.0.0 +plugins: + - gifflet/review-plugin@1.0.0 +``` + +## Removing a Plugin + +```bash +ccmd remove review-plugin +``` + +This removes the plugin directory, unregisters it from `settings.json`, and updates the marketplace. + +## Creating a Plugin + +### 1. Initialize + +```bash +mkdir my-plugin && cd my-plugin +ccmd init --plugin +# or shorthand +ccmd init -p +``` + +### 2. Resulting Structure + +``` +my-plugin/ +ā”œā”€ā”€ ccmd.yaml # Plugin metadata with type: plugin +ā”œā”€ā”€ .claude-plugin/ +│ └── plugin.json # Claude Code plugin manifest +└── README.md +``` + +### 3. ccmd.yaml + +```yaml +type: plugin +name: my-plugin +version: 1.0.0 +description: My Claude Code plugin +author: Your Name +repository: https://github.com/username/my-plugin +tags: + - productivity +license: MIT +``` + +### 4. Plugin Manifest (.claude-plugin/plugin.json) + +```json +{ + "name": "my-plugin", + "version": "1.0.0", + "description": "My Claude Code plugin", + "author": { + "name": "Your Name" + }, + "repository": "https://github.com/username/my-plugin", + "license": "MIT" +} +``` + +### 5. Publishing + +```bash +git init && git add . && git commit -m "feat: initial plugin" +git remote add origin https://github.com/username/my-plugin +git push -u origin main +git tag v1.0.0 && git push --tags +``` + +Users install it with: + +```bash +ccmd install username/my-plugin +``` + +## Example Plugin + +[**review-plugin**](https://github.com/gifflet/review-plugin) — AI-powered code review plugin for Claude Code. + +```bash +ccmd install gifflet/review-plugin +``` + +## See Also + +- [Command Reference](/usage/) - All ccmd commands including plugin flags +- [Creating Commands](/creating-commands/) - Guide for creating slash commands +- [Examples](/examples/) - More usage examples diff --git a/hugo-docs/content/en/usage/_index.md b/hugo-docs/content/en/usage/_index.md index 78eaeca..06fe88c 100644 --- a/hugo-docs/content/en/usage/_index.md +++ b/hugo-docs/content/en/usage/_index.md @@ -57,32 +57,38 @@ These options are available for all ccmd commands: ## ccmd init -Initialize a new Claude Code Command project by creating the necessary configuration files and directory structure. +Initialize a new Claude Code Command or Plugin project by creating the necessary configuration files and directory structure. ### Usage ```bash -ccmd init +ccmd init [flags] ``` ### Description -This interactive command guides you through setting up a new ccmd project. It prompts for essential metadata about your command and generates: +This interactive command guides you through setting up a new ccmd project. It prompts for essential metadata and generates the appropriate project structure. + +**For commands**, generates: - `ccmd.yaml` - Command configuration file - `.claude/commands/` - Directory structure for commands +**For plugins** (`--plugin` flag), generates: +- `ccmd.yaml` - Plugin configuration file with `type: plugin` +- `.claude-plugin/plugin.json` - Claude Code plugin manifest + ### Options -This command has no additional flags. It runs interactively. +- `-p, --plugin` - Initialize as a Claude Code plugin instead of a command ### Interactive Prompts -- **name**: Command name (defaults to current directory name) +- **name**: Command/plugin name (defaults to current directory name) - **version**: Semantic version (defaults to "1.0.0") -- **description**: Brief description of what your command does +- **description**: Brief description of what your command/plugin does - **author**: Your name or organization - **repository**: Git repository URL -- **entry**: Entry point file (defaults to "index.md") +- **entry**: Entry point file (defaults to "index.md") — not prompted for plugins - **tags**: Comma-separated list of tags ### Examples @@ -92,25 +98,22 @@ This command has no additional flags. It runs interactively. cd my-command ccmd init -# Example interaction: -# name: (my-command) -# version: (1.0.0) -# description: Automates common development tasks -# author: Jane Doe -# repository: https://github.com/janedoe/my-command -# entry: (index.md) -# tags (comma-separated): automation, dev-tools +# Initialize a new plugin project +cd my-plugin +ccmd init --plugin +# or shorthand +ccmd init -p ``` ### Notes - If a `ccmd.yaml` file already exists, it will load existing values as defaults -- The command creates the `.claude/commands` directory structure automatically -- After initialization, create your `index.md` file with command instructions +- After initializing a command, create your `index.md` file with command instructions +- After initializing a plugin, edit `.claude-plugin/plugin.json` with your plugin manifest ## ccmd install -Install a command from a Git repository or install all commands from ccmd.yaml. +Install a command or plugin from a Git repository, or install all entries from ccmd.yaml. ### Usage @@ -120,22 +123,29 @@ ccmd install [repository] [flags] ### Description -When no repository is provided, installs all commands defined in the project's ccmd.yaml file. When a repository is provided, installs the command and adds it to ccmd.yaml and ccmd-lock.yaml. +When no repository is provided, installs all commands and plugins defined in the project's ccmd.yaml. When a repository is provided, ccmd reads the repository's `ccmd.yaml` and automatically determines whether to install it as a command or a plugin based on the `type` field. + +- `type: plugin` → installed to `.claude/plugins/` and registered in Claude Code settings +- no type (default) → installed to `.claude/commands/` as a slash command ### Options - `-v, --version ` - Version/tag to install (defaults to latest) -- `-n, --name ` - Override command name +- `-n, --name ` - Override command/plugin name - `-f, --force` - Force reinstall if already exists +- `--plugin` - Force installation as a plugin (optional; only needed when the repository does not declare `type: plugin`) ### Examples ```bash -# Install all commands from ccmd.yaml +# Install all commands and plugins from ccmd.yaml ccmd install -# Install latest version of a command -ccmd install github.com/user/repo +# Install a command (auto-detected) +ccmd install github.com/user/my-command + +# Install a plugin (auto-detected via type: plugin in the repo's ccmd.yaml) +ccmd install gifflet/review-plugin # Install specific version ccmd install github.com/user/repo@v1.0.0 @@ -159,7 +169,7 @@ ccmd install github.com/user/repo --force ## ccmd list -List all commands managed by ccmd with their versions, sources, and metadata. +List all commands and plugins managed by ccmd with their versions, sources, and metadata. ### Usage @@ -169,7 +179,7 @@ ccmd list [flags] ### Description -Shows only commands that are tracked in the ccmd-lock.yaml file and have entries in the .claude/commands/ directory. +Shows commands and plugins tracked in the ccmd-lock.yaml file. Commands must have entries in `.claude/commands/` and plugins in `.claude/plugins/`. ### Options @@ -178,7 +188,7 @@ Shows only commands that are tracked in the ccmd-lock.yaml file and have entries ### Examples ```bash -# List commands in table format +# List all commands and plugins in table format ccmd list # Show detailed information @@ -188,8 +198,9 @@ ccmd list --long ### Output Format **Simple format** shows: -- NAME - Command name +- NAME - Command or plugin name - VERSION - Installed version +- TYPE - `command` or `plugin` - DESCRIPTION - Brief description - UPDATED - Last update time @@ -204,7 +215,7 @@ ccmd list --long ### Notes -- Commands with broken structure are marked with ⚠ +- Entries with broken structure are marked with ⚠ - Use `--long` flag to see details about structure issues ## ccmd update @@ -252,17 +263,17 @@ ccmd update my-command --force ## ccmd remove -Remove an installed command and clean up all associated files. +Remove an installed command or plugin and clean up all associated files. ### Usage ```bash -ccmd remove [flags] +ccmd remove [flags] ``` ### Description -Removes a command from the .claude/commands directory and optionally updates configuration files. +Removes a command from `.claude/commands/` or a plugin from `.claude/plugins/`. For plugins, also removes the entry from `.claude/settings.json` and updates the marketplace registry. Optionally updates configuration files. ### Options @@ -272,9 +283,12 @@ Removes a command from the .claude/commands directory and optionally updates con ### Examples ```bash -# Remove with confirmation prompt +# Remove a command with confirmation prompt ccmd remove my-command +# Remove a plugin with confirmation prompt +ccmd remove review-plugin + # Force removal without confirmation ccmd remove my-command --force @@ -285,7 +299,7 @@ ccmd remove my-command --save ### Confirmation Unless `--force` is used, the command will display: -- Command name and version +- Name and version - Description (if available) - Confirmation prompt @@ -485,6 +499,7 @@ ccmd update my-command ## See Also +- [Plugin Support](/plugins/) - Installing and creating Claude Code plugins - [Creating Commands](/creating-commands/) - Guide for creating your own commands - [Examples](/examples/) - Real-world use cases and patterns - [FAQ](/faq/) - Common questions and troubleshooting \ No newline at end of file From cde8e06c0909799e289507af3031d39df96eceb3 Mon Sep 17 00:00:00 2001 From: gifflet Date: Wed, 18 Mar 2026 18:18:31 -0300 Subject: [PATCH 3/3] feat(list): add type column to list command output --- cmd/list/list.go | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/cmd/list/list.go b/cmd/list/list.go index a70c945..69bc2b7 100644 --- a/cmd/list/list.go +++ b/cmd/list/list.go @@ -60,8 +60,8 @@ func runList(long bool) error { } if len(details) == 0 { - output.PrintInfof("No commands installed yet.") - output.PrintInfof("Use 'ccmd install' to install commands.") + output.PrintInfof("No commands or plugins installed yet.") + output.PrintInfof("Use 'ccmd install' to install commands or plugins.") return nil } @@ -91,20 +91,22 @@ func runList(long bool) error { } func printSimpleList(commands []core.CommandDetail) { - output.PrintInfof("Found %d command(s) managed by ccmd:\n", len(commands)) + output.PrintInfof("Found %d item(s) managed by ccmd:\n", len(commands)) // Define column widths const ( nameWidth = 20 versionWidth = 10 + typeWidth = 9 descriptionWidth = 40 updatedWidth = 20 ) // Print header - header := fmt.Sprintf("%-*s %-*s %-*s %-*s", + header := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s", nameWidth, "NAME", versionWidth, "VERSION", + typeWidth, "TYPE", descriptionWidth, "DESCRIPTION", updatedWidth, "UPDATED") output.Printf(header) @@ -130,6 +132,12 @@ func printSimpleList(commands []core.CommandDetail) { version = version[:versionWidth-3] + "..." } + // Format type + cmdType := cmd.Type + if cmdType == "" { + cmdType = "command" + } + // Format description description := cmd.Description if description == "" { @@ -146,9 +154,10 @@ func printSimpleList(commands []core.CommandDetail) { } // Print row - row := fmt.Sprintf("%-*s %-*s %-*s %-*s", + row := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s", nameWidth, name, versionWidth, version, + typeWidth, cmdType, descriptionWidth, description, updatedWidth, updated) output.Printf(row) @@ -156,7 +165,7 @@ func printSimpleList(commands []core.CommandDetail) { } func printLongList(commands []core.CommandDetail) { - output.PrintInfof("Found %d command(s) managed by ccmd:\n", len(commands)) + output.PrintInfof("Found %d item(s) managed by ccmd:\n", len(commands)) for i, cmd := range commands { if i > 0 { @@ -166,6 +175,7 @@ func printLongList(commands []core.CommandDetail) { // Basic info output.Printf("Name: %s", cmd.Name) output.Printf("Version: %s", formatOrDash(cmd.Version)) + output.Printf("Type: %s", formatOrDash(cmd.Type)) output.Printf("Source: %s", formatOrDash(cmd.Repository)) output.Printf("Description: %s", formatOrDash(cmd.Description))