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
53 changes: 48 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <repo>` | 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 <repo>` | 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 <command>` | Update a specific command |
| `ccmd remove <command>` | Remove an installed command |
| `ccmd remove <command>` | Remove an installed command or plugin |
| `ccmd search <keyword>` | Search for commands in the registry |
| `ccmd info <command>` | Show detailed command information |

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

Expand Down
87 changes: 74 additions & 13 deletions cmd/init/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -89,7 +94,6 @@ func runInit() error {
ProjectPath: currentDir,
}

// Generate preview
preview, err := core.GenerateConfigPreview(opts, existingCommands)
if err != nil {
return err
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions cmd/init/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
22 changes: 16 additions & 6 deletions cmd/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@
}

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
}

Expand Down Expand Up @@ -90,21 +90,23 @@
return nil
}

func printSimpleList(commands []core.CommandDetail) {

Check failure on line 93 in cmd/list/list.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=gifflet_ccmd&issues=AZ0C7k2Mp0gnGQq54bbT&open=AZ0C7k2Mp0gnGQq54bbT&pullRequest=18
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)
Expand All @@ -130,6 +132,12 @@
version = version[:versionWidth-3] + "..."
}

// Format type
cmdType := cmd.Type
if cmdType == "" {
cmdType = "command"
}

// Format description
description := cmd.Description
if description == "" {
Expand All @@ -146,17 +154,18 @@
}

// 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)
}
}

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 {
Expand All @@ -166,6 +175,7 @@
// 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))

Expand Down
Loading
Loading