Skip to content
All posts
May 11, 20267 min read

Building a Claude Code Plugin from Scratch: DroidForge as a Case Study

A complete walkthrough of building a production Claude Code plugin — from plugin.json manifest to skill design to hook integration. Using DroidForge (Android build automation) as the real example, with the actual code and design decisions.

Claude CodeAIAndroidDeveloper ToolsAutomation
Share:

I've published 4 Claude Code plugins. The most complex is DroidForge — an Android build workflow engine that handles signing, versioning, Gradle DSL generation, and MVVM scaffolding across my 22-app portfolio.

Building it taught me how Claude Code's plugin system actually works, what makes skills effective vs. useless, and where the sharp edges are. Here's the full picture.


What a Claude Code Plugin Actually Is

A plugin is a directory with a

code
plugin.json
manifest, a collection of skill files, optional hooks, and optional MCP server configurations. Claude Code discovers installed plugins and makes their skills available in every session.

The directory structure:

code
DroidForge/
├── plugin.json          # Manifest — name, version, skills declared
├── skills/
│   ├── SKILL.md         # Auto-discovery index (required)
│   ├── new-android-project.md
│   ├── signing-setup.md
│   ├── version-bump.md
│   ├── gradle-dsl.md
│   └── scaffold-screen.md
├── hooks/
│   └── security-scan.py
└── README.md

The Manifest: plugin.json

json
{
  "name": "DroidForge",
  "version": "1.2.0",
  "description": "Android build workflow engine — signing, versioning, Gradle DSL, MVVM scaffolding",
  "author": "SUDARSHANCHAUDHARI",
  "skills": [
    "skills/new-android-project.md",
    "skills/signing-setup.md",
    "skills/version-bump.md",
    "skills/gradle-dsl.md",
    "skills/scaffold-screen.md"
  ],
  "hooks": [
    {
      "event": "PreToolUse",
      "automation": ["Write", "Edit", "MultiEdit"],
      "command": "python3 hooks/security-scan.py"
    }
  ]
}

Key points:

  • code
    skills
    lists every skill file. Skills not listed here aren't discoverable.
  • code
    hooks
    attach shell commands to tool events.
    code
    PreToolUse
    fires before Claude writes any file.
  • Version follows semver. Claude Code uses this to handle plugin updates.

Writing Skills That Actually Work

The most common mistake in skill writing is describing what to do instead of encoding how to do it. A skill that says "create a new Android screen following MVVM" is useless. A skill that contains the exact code templates is how you get consistent output.

Anatomy of an Effective Skill

markdown
---
name: scaffold-screen
description: Scaffold a new Jetpack Compose screen with ViewModel, sealed UiState,
             and Hilt injection. Use when adding any new screen to an Android app.
---

# Scaffold Screen

## When to use
When the user says "add a [name] screen" or "create a new screen for [feature]".

## Output

Create these files:

### 1. Screen Composable
Path: `presentation/ui/screen/[feature]/[Feature]Screen.kt`

```kotlin
@Composable
fun [Feature]Screen(
    viewModel: [Feature]ViewModel = hiltViewModel(),
    onNavigateBack: () -> Unit
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    [Feature]Content(
        uiState = uiState,
        onAction = viewModel::onAction,
        onNavigateBack = onNavigateBack
    )
}

@Composable
private fun [Feature]Content(
    uiState: [Feature]UiState,
    onAction: ([Feature]Action) -> Unit,
    onNavigateBack: () -> Unit
) {
    when (uiState) {
        is [Feature]UiState.Loading -> LoadingScreen()
        is [Feature]UiState.Success -> { /* content */ }
        is [Feature]UiState.Error -> ErrorScreen(
            message = uiState.message,
            onRetry = { onAction([Feature]Action.Retry) }
        )
    }
}

2. ViewModel

Path:

code
presentation/viewmodel/[Feature]ViewModel.kt

kotlin
@HiltViewModel
class [Feature]ViewModel @Inject constructor(
    private val repository: [Feature]Repository
) : ViewModel() {

    private val _uiState = MutableStateFlow<[Feature]UiState>([Feature]UiState.Loading)
    val uiState: StateFlow<[Feature]UiState> = _uiState.asStateFlow()

    fun onAction(action: [Feature]Action) {
        when (action) {
            is [Feature]Action.Retry -> load()
        }
    }

    private fun load() {
        viewModelScope.launch {
            _uiState.value = [Feature]UiState.Loading
            repository.get()
                .onSuccess { _uiState.value = [Feature]UiState.Success(it) }
                .onFailure { _uiState.value = [Feature]UiState.Error(it.message ?: "Error") }
        }
    }
}

3. UiState sealed interface

kotlin
sealed interface [Feature]UiState {
    data object Loading : [Feature]UiState
    data class Success(val data: [Feature]Data) : [Feature]UiState
    data class Error(val message: String) : [Feature]UiState
}

sealed interface [Feature]Action {
    data object Retry : [Feature]Action
}

Verification

After scaffolding:

  • Files compile without errors
  • code
    @HiltViewModel
    annotation present on ViewModel
  • code
    collectAsStateWithLifecycle()
    used (not
    code
    collectAsState()
    )
  • code
    when
    expression on UiState is exhaustive
code

Three things make this skill work:
1. **Exact code templates** — not prose descriptions, not pseudocode. Real Kotlin with `[Feature]` as the only placeholder.
2. **Verification checklist** — Claude checks its own output against concrete criteria.
3. **Narrow trigger condition** — "when the user says 'add a screen'" prevents the skill from activating at wrong times.

---

## The Hook: Security Credential Scanner

Every file write in my sessions goes through this hook:

```python
#!/usr/bin/env python3
# hooks/security-scan.py
import sys
import json
import re

PATTERNS = [
    (r'storePassword\s*=\s*["\'][^"\']{4,}["\']', "Keystore password"),
    (r'keyPassword\s*=\s*["\'][^"\']{4,}["\']', "Key password"),
    (r'api[_-]?key\s*[:=]\s*["\'][^"\']{8,}["\']', "API key"),
    (r'-----BEGIN.*PRIVATE KEY-----', "Private key"),
    (r'AAAA[0-9A-Za-z_-]{20,}', "Possible FCM/push token"),
]

def scan(content: str) -> list[str]:
    found = []
    for pattern, label in PATTERNS:
        if re.search(pattern, content, re.IGNORECASE | re.MULTILINE):
            found.append(label)
    return found

def main():
    try:
        data = json.load(sys.stdin)
    except json.JSONDecodeError:
        # Malformed input — allow and let Claude handle
        print(json.dumps({"decision": "allow"}))
        return

    # Only scan file write content
    content = data.get("content", "") or data.get("new_string", "")
    
    issues = scan(content)
    if issues:
        print(json.dumps({
            "decision": "block",
            "reason": f"Potential credential detected: {', '.join(issues)}. Review before writing to disk."
        }))
    else:
        print(json.dumps({"decision": "allow"}))

main()

The hook receives tool call parameters as JSON via stdin and outputs a decision JSON.

code
"decision": "block"
stops the write and shows the reason to Claude.
code
"decision": "allow"
lets it proceed.

This has caught real mistakes: generated signing config templates with placeholder passwords that matched credential patterns, hardcoded test tokens in generated test files, and once a legitimate API key that slipped into a generated config.


Version Bump Skill: Handling State Across Files

The version bump skill is more complex because it needs to coordinate changes across multiple files and validate the result:

markdown
---
name: version-bump
description: Bump app version in config/version.properties and update versionCode.
             Use when releasing or preparing a release candidate.
---

# Version Bump

## Steps

1. Read `config/version.properties`:

VERSION_MAJOR=1 VERSION_MINOR=3 VERSION_PATCH=2 VERSION_CODE=47

code

2. Apply the bump based on user's argument:
- `patch` → VERSION_PATCH +1, VERSION_CODE +1
- `minor` → VERSION_MINOR +1, VERSION_PATCH=0, VERSION_CODE +1
- `major` → VERSION_MAJOR +1, VERSION_MINOR=0, VERSION_PATCH=0, VERSION_CODE +1

3. Write the updated properties file.

4. Verify `build.gradle.kts` reads from version.properties (look for
`val versionProps = Properties()`). If it doesn't, flag this as an issue.

5. Output the new version for confirmation:

Version bumped: 1.3.2 (47) → 1.3.3 (48)

code

## Common issues
- `VERSION_CODE` must always increment, even for patch bumps — Play Store rejects downgrades
- Never edit versionCode or versionName directly in build.gradle.kts — always via version.properties

The skill works because it has the exact file format, the exact transform logic, and the verification step. Claude doesn't need to figure any of this out — it just executes the documented process.


What I Got Wrong Initially

First version mistake: skills too generic.

My initial

code
android-conventions.md
skill was a 500-line document describing my entire Android setup. It was referenced in every session but never actually helped — it was too broad for any specific task.

Better approach: one skill per specific, triggerable task.

code
new-android-project.md
,
code
scaffold-screen.md
,
code
signing-setup.md
— each focused, each with clear trigger conditions, each with concrete output templates.

Second mistake: no verification steps.

Skills without verification steps produce output that looks correct but has subtle errors — missing annotations, wrong import paths, non-exhaustive when expressions. Adding verification checklists means Claude checks its own output before handing it back.

Third mistake: inconsistent placeholder format.

I used

code
FeatureName
,
code
feature_name
, and
code
{feature}
as placeholders in different skills. Claude would sometimes mix conventions. Standardizing on
code
[Feature]
(camel case, in square brackets) across all skills eliminated this.


Installation and Distribution

bash
# Install locally for development
claude plugin install ./DroidForge --local

# Install from GitHub (published plugins)
claude plugin install github:SUDARSHANCHAUDHARI/DroidForge

# List installed plugins
claude plugin list

# Update a plugin
claude plugin update DroidForge

For local development,

code
--local
installs from a directory path so changes take effect without reinstall.


The Compounding Effect

After 6 months, DroidForge runs in every Android session without me thinking about it. New screens are scaffolded in 30 seconds. Version bumps are one command. Signing configs are generated correctly the first time.

The accumulated time savings are significant. But the real value is consistency: every app in my portfolio uses identical patterns because the skill enforces them. When I look at app 22's code, it's the same structure as app 1's. That's only possible because a documented process runs every time.

That's the real point of a plugin: not just automation, but encoded institutional knowledge that runs in every session without manual recall.

Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this