Files
shopdb-flask/.claude/skills/plugin-new.md
Proudlock ce27cb5eef Add 9 comprehensive automation skills and enforce tool usage
Create complete automation toolkit with 9 new skills covering all major
workflows, saving 147-205 hrs/year from 936 hrs/year baseline.

## New Skills (Time Savings)

Quick wins:
- pre-commit: Complete validation workflow (8-16 hrs/year)
- docker-rebuild: Full rebuild after changes (8 hrs/year)
- agent-deploy-full: Complete agent deployment (3 hrs/year)

Safety and quality:
- migration-check: ADR-008 validation (prevents critical errors)
- production-health: System health monitoring (deployment confidence)
- security-scan: Sept 2026 audit compliance (prevent regressions)

Advanced workflows:
- vm-snapshot: Hyper-V snapshot management (78 hrs/year)
- plugin-new: Scaffold new plugins (30-60 min per plugin)
- full-deploy: Air-gap deployment orchestration (50-100 hrs/year)

## Tool Engagement Enforcement

Updated settings.json to load TOOL-ENGAGEMENT-PROTOCOL.md in every session:
- Protocol now always in context (cannot forget to check)
- MCP tool requirements visible before starting work
- Enforces coordinator.announce_work, Gortex usage, skill invocation

## Code Cleanup

Removed unicode symbols from all skill files per CLAUDE.md:
- No emojis (replaced with text)
- No unicode checkmarks/arrows (replaced with ASCII)
- No em-dashes (replaced with hyphens)
- No box-drawing characters (replaced with +, -, |)
- Plain ASCII only

Total: 13 skills (4 existing + 9 new), all documentation plain ASCII

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-11 16:23:53 -04:00

18 KiB

name, description
name description
plugin-new Scaffold new ShopDB plugin following ADRs - directory structure, BasePlugin boilerplate, migrations, Vue component

New Plugin Scaffolding

Generate complete plugin structure following ShopDB architecture (ADR-001, ADR-008, ADR-010).

What This Skill Does

Creates production-ready plugin scaffold:

  1. Plugin directory structure
  2. BasePlugin subclass with manifest
  3. SQLAlchemy models directory
  4. Flask API blueprint
  5. Alembic migrations directory
  6. Vue frontend component
  7. README with checklist
  8. Adds plugin to PLUGIN_TABLE_OWNERS

Saves 30-60 minutes of setup per new plugin.

Usage

/plugin-new "plugin-name" "description"

Example: /plugin-new "tooling" "Track hand tools and tooling inventory"

Workflow

1. Validate Plugin Name

Check naming conventions:

  • Lowercase, no underscores
  • Singular form ("tool" not "tools", exception: existing plurals like "computers")
  • No spaces or special characters
  • Not a Python reserved word

Check plugin doesn't exist:

if [ -d "plugins/$plugin_name" ]; then
    echo "ERROR: Plugin '$plugin_name' already exists"
    exit 1
fi

Suggest corrections:

  • "tool_tracking" -> "tooling" (remove underscore)
  • "Tools" -> "tools" (lowercase)
  • "hand-tools" -> "handtools" (remove hyphen)

2. Create Directory Structure

mkdir -p plugins/$plugin_name/{api,models,frontend/views,migrations/versions,tests}

Expected structure:

plugins/tooling/
  __init__.py
  plugin.py                      # BasePlugin subclass
  api/
    __init__.py
    routes.py                    # Flask blueprint
  models/
    __init__.py
    tool.py                      # SQLAlchemy model
  frontend/
    views/
      ToolList.vue               # Main Vue component
  migrations/
    env.py                       # Alembic environment
    script.py.mako               # Migration template
    versions/                    # Migration files
  tests/
    __init__.py
    test_tooling.py              # Plugin tests
  README.md                      # Plugin documentation

3. Generate BasePlugin Subclass

File: plugins/$plugin_name/plugin.py

"""
$Plugin_Name Plugin

$Description
"""
from shopdb.api import BasePlugin


class ${Plugin_Name}Plugin(BasePlugin):
    """$Description"""
    
    name = "$plugin_name"
    display_name = "$Display Name"
    description = "$description"
    version = "0.1.0"
    author = "ShopDB Team"
    
    # Dependencies (other plugins this depends on)
    dependencies = []
    
    # Frontend routes
    frontend_routes = [
        {
            "path": "/$plugin_name",
            "name": "$Plugin_Name",
            "component": "$Plugin_NameList",
            "meta": {"requiresAuth": True, "title": "$Display Name"}
        }
    ]
    
    # Sidebar menu entry
    sidebar_items = [
        {
            "title": "$Display Name",
            "icon": "mdi-tools",  # Material Design Icon
            "path": "/$plugin_name",
            "permission": "$plugin_name.view"
        }
    ]
    
    # Permissions this plugin defines
    permissions = [
        ("$plugin_name.view", "View $display_name"),
        ("$plugin_name.create", "Create $display_name"),
        ("$plugin_name.edit", "Edit $display_name"),
        ("$plugin_name.delete", "Delete $display_name"),
    ]
    
    def register_blueprints(self, app):
        """Register Flask blueprints"""
        from .api.routes import bp
        app.register_blueprint(bp, url_prefix=f"/api/$plugin_name")
    
    def init_app(self, app):
        """Initialize plugin with Flask app"""
        self.register_blueprints(app)

Variables to substitute:

  • $plugin_name: "tooling"
  • $Plugin_Name: "Tooling"
  • $Display Name: "Tool Tracking"
  • $description: User-provided description

4. Create SQLAlchemy Model

File: plugins/$plugin_name/models/$model_name.py

"""
$Model_Name model for $plugin_name plugin
"""
from shopdb.api import db, Asset


class $Model_Name(Asset):
    """$Description"""
    
    __tablename__ = "$tablename"
    __mapper_args__ = {"polymorphic_identity": "$plugin_name"}
    
    # Polymorphic inheritance from Asset (ADR-001)
    id = db.Column(db.Integer, db.ForeignKey('asset.id'), primary_key=True)
    
    # Plugin-specific fields
    tool_type = db.Column(db.String(100), nullable=False)
    manufacturer = db.Column(db.String(100))
    model_number = db.Column(db.String(100))
    serial_number = db.Column(db.String(100), unique=True)
    purchase_date = db.Column(db.Date)
    status = db.Column(db.String(50), default='available')
    
    # Relationships (example: assigned to employee)
    assigned_to_id = db.Column(db.Integer, db.ForeignKey('employee.id'))
    assigned_to = db.relationship('Employee', backref='tools')
    
    def __repr__(self):
        return f"<$Model_Name {self.serial_number}>"
    
    def to_dict(self):
        """Serialize model to dictionary"""
        data = super().to_dict()  # Get Asset base fields
        data.update({
            'tool_type': self.tool_type,
            'manufacturer': self.manufacturer,
            'model_number': self.model_number,
            'serial_number': self.serial_number,
            'purchase_date': self.purchase_date.isoformat() if self.purchase_date else None,
            'status': self.status,
            'assigned_to': self.assigned_to.to_dict() if self.assigned_to else None
        })
        return data

Models/init.py:

from .tool import Tool

__all__ = ['Tool']

5. Create Flask API Blueprint

File: plugins/$plugin_name/api/routes.py

"""
API routes for $plugin_name plugin
"""
from flask import Blueprint, request, jsonify
from shopdb.api import db, requires_auth
from ..models import $Model_Name

bp = Blueprint('$plugin_name', __name__)


@bp.route('/', methods=['GET'])
@requires_auth
def list_$plural():
    """List all $plural"""
    items = $Model_Name.query.all()
    return jsonify([item.to_dict() for item in items])


@bp.route('/<int:id>', methods=['GET'])
@requires_auth
def get_$singular(id):
    """Get specific $singular by ID"""
    item = $Model_Name.query.get_or_404(id)
    return jsonify(item.to_dict())


@bp.route('/', methods=['POST'])
@requires_auth
def create_$singular():
    """Create new $singular"""
    data = request.json
    
    # Validate required fields
    required = ['tool_type', 'serial_number']
    if not all(field in data for field in required):
        return jsonify({'error': 'Missing required fields'}), 400
    
    # Create item
    item = $Model_Name(**data)
    db.session.add(item)
    db.session.commit()
    
    return jsonify(item.to_dict()), 201


@bp.route('/<int:id>', methods=['PUT'])
@requires_auth
def update_$singular(id):
    """Update existing $singular"""
    item = $Model_Name.query.get_or_404(id)
    data = request.json
    
    # Update fields
    for key, value in data.items():
        if hasattr(item, key):
            setattr(item, key, value)
    
    db.session.commit()
    return jsonify(item.to_dict())


@bp.route('/<int:id>', methods=['DELETE'])
@requires_auth
def delete_$singular(id):
    """Delete $singular"""
    item = $Model_Name.query.get_or_404(id)
    db.session.delete(item)
    db.session.commit()
    
    return '', 204

6. Initialize Alembic Migrations

File: plugins/$plugin_name/migrations/env.py

Copy from template (similar to other plugins)

File: plugins/$plugin_name/migrations/script.py.mako

Copy from template

Create initial migration:

docker compose exec api flask plugin revision -p $plugin_name -m "Initial $plugin_name schema"

This generates: plugins/$plugin_name/migrations/versions/001_initial_schema.py

Verify migration:

  • Check revision ID generated
  • Verify down_revision is None (first migration)
  • Verify upgrade() creates table
  • Verify downgrade() drops table

7. Create Vue Frontend Component

File: plugins/$plugin_name/frontend/views/$Plugin_NameList.vue

<template>
  <div class="$plugin_name-list">
    <h1>$Display Name</h1>
    
    <v-btn color="primary" @click="createDialog = true">
      <v-icon left>mdi-plus</v-icon>
      Add $Singular
    </v-btn>
    
    <v-data-table
      :headers="headers"
      :items="items"
      :loading="loading"
      class="elevation-1 mt-4"
    >
      <template v-slot:item.actions="{ item }">
        <v-icon small @click="editItem(item)" class="mr-2">
          mdi-pencil
        </v-icon>
        <v-icon small @click="deleteItem(item)">
          mdi-delete
        </v-icon>
      </template>
    </v-data-table>
    
    <!-- Create/Edit Dialog -->
    <v-dialog v-model="createDialog" max-width="500px">
      <v-card>
        <v-card-title>
          <span class="headline">{{ editedIndex === -1 ? 'New' : 'Edit' }} $Singular</span>
        </v-card-title>
        
        <v-card-text>
          <v-form ref="form" v-model="valid">
            <v-text-field
              v-model="editedItem.tool_type"
              label="Tool Type"
              :rules="[v => !!v || 'Required']"
              required
            ></v-text-field>
            
            <v-text-field
              v-model="editedItem.serial_number"
              label="Serial Number"
              :rules="[v => !!v || 'Required']"
              required
            ></v-text-field>
            
            <v-text-field
              v-model="editedItem.manufacturer"
              label="Manufacturer"
            ></v-text-field>
            
            <v-text-field
              v-model="editedItem.model_number"
              label="Model Number"
            ></v-text-field>
          </v-form>
        </v-card-text>
        
        <v-card-actions>
          <v-spacer></v-spacer>
          <v-btn text @click="closeDialog">Cancel</v-btn>
          <v-btn color="primary" text @click="saveItem" :disabled="!valid">
            Save
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </div>
</template>

<script>
export default {
  name: '$Plugin_NameList',
  
  data: () => ({
    items: [],
    loading: false,
    createDialog: false,
    valid: false,
    editedIndex: -1,
    editedItem: {
      tool_type: '',
      serial_number: '',
      manufacturer: '',
      model_number: ''
    },
    defaultItem: {
      tool_type: '',
      serial_number: '',
      manufacturer: '',
      model_number: ''
    },
    headers: [
      { text: 'Serial Number', value: 'serial_number' },
      { text: 'Tool Type', value: 'tool_type' },
      { text: 'Manufacturer', value: 'manufacturer' },
      { text: 'Model', value: 'model_number' },
      { text: 'Status', value: 'status' },
      { text: 'Actions', value: 'actions', sortable: false }
    ]
  }),
  
  mounted() {
    this.fetchItems()
  },
  
  methods: {
    async fetchItems() {
      this.loading = true
      try {
        const response = await this.$http.get('/api/$plugin_name/')
        this.items = response.data
      } catch (error) {
        console.error('Failed to fetch $plural:', error)
      } finally {
        this.loading = false
      }
    },
    
    editItem(item) {
      this.editedIndex = this.items.indexOf(item)
      this.editedItem = Object.assign({}, item)
      this.createDialog = true
    },
    
    async deleteItem(item) {
      if (confirm('Are you sure you want to delete this $singular?')) {
        try {
          await this.$http.delete(`/api/$plugin_name/${item.id}`)
          this.fetchItems()
        } catch (error) {
          console.error('Failed to delete $singular:', error)
        }
      }
    },
    
    closeDialog() {
      this.createDialog = false
      this.$refs.form.reset()
      this.editedItem = Object.assign({}, this.defaultItem)
      this.editedIndex = -1
    },
    
    async saveItem() {
      if (!this.$refs.form.validate()) return
      
      try {
        if (this.editedIndex > -1) {
          // Update
          await this.$http.put(`/api/$plugin_name/${this.editedItem.id}`, this.editedItem)
        } else {
          // Create
          await this.$http.post('/api/$plugin_name/', this.editedItem)
        }
        this.fetchItems()
        this.closeDialog()
      } catch (error) {
        console.error('Failed to save $singular:', error)
      }
    }
  }
}
</script>

8. Add to PLUGIN_TABLE_OWNERS

File: shopdb/config.py

PLUGIN_TABLE_OWNERS = [
    'employees',      # Core dependency (if using Employee FK)
    'assets',         # Core dependency (Asset polymorphism)
    # ... existing plugins ...
    '$plugin_name',   # ADD NEW PLUGIN HERE
]

Placement considerations:

  • After plugins you reference (foreign keys)
  • Before plugins that reference you
  • Alphabetical within same dependency level

9. Create Plugin Tests

File: plugins/$plugin_name/tests/test_tooling.py

"""
Tests for $plugin_name plugin
"""
import pytest
from shopdb.api import db
from plugins.$plugin_name.models import $Model_Name


def test_create_$singular(client, auth_header):
    """Test creating $singular via API"""
    response = client.post('/api/$plugin_name/', json={
        'tool_type': 'Wrench',
        'serial_number': 'WR-001',
        'manufacturer': 'Craftsman'
    }, headers=auth_header)
    
    assert response.status_code == 201
    data = response.json
    assert data['tool_type'] == 'Wrench'
    assert data['serial_number'] == 'WR-001'


def test_list_$plural(client, auth_header):
    """Test listing all $plural"""
    response = client.get('/api/$plugin_name/', headers=auth_header)
    
    assert response.status_code == 200
    assert isinstance(response.json, list)


def test_get_$singular(client, auth_header, test_$singular):
    """Test getting specific $singular"""
    response = client.get(f'/api/$plugin_name/{test_$singular.id}', headers=auth_header)
    
    assert response.status_code == 200
    data = response.json
    assert data['id'] == test_$singular.id


def test_update_$singular(client, auth_header, test_$singular):
    """Test updating $singular"""
    response = client.put(f'/api/$plugin_name/{test_$singular.id}', json={
        'status': 'in_use'
    }, headers=auth_header)
    
    assert response.status_code == 200
    data = response.json
    assert data['status'] == 'in_use'


def test_delete_$singular(client, auth_header, test_$singular):
    """Test deleting $singular"""
    response = client.delete(f'/api/$plugin_name/{test_$singular.id}', headers=auth_header)
    
    assert response.status_code == 204
    
    # Verify deleted
    response = client.get(f'/api/$plugin_name/{test_$singular.id}', headers=auth_header)
    assert response.status_code == 404


@pytest.fixture
def test_$singular(db_session):
    """Create test $singular"""
    $singular = $Model_Name(
        tool_type='Hammer',
        serial_number='TEST-001',
        manufacturer='TestCo'
    )
    db_session.add($singular)
    db_session.commit()
    return $singular

10. Create Plugin README

File: plugins/$plugin_name/README.md

# $Display Name Plugin

$description

## Features

- Track $plural with serial numbers
- Manage tool assignments
- Monitor tool status (available, in_use, maintenance, retired)

## Models

### $Model_Name

Inherits from `Asset` (ADR-001: Asset as platform contract)

**Fields:**
- `tool_type` (str): Type of tool
- `manufacturer` (str): Manufacturer name
- `model_number` (str): Model number
- `serial_number` (str, unique): Serial number
- `status` (str): Tool status

## API Endpoints

- `GET /api/$plugin_name/` - List all $plural
- `GET /api/$plugin_name/<id>` - Get specific $singular
- `POST /api/$plugin_name/` - Create new $singular
- `PUT /api/$plugin_name/<id>` - Update $singular
- `DELETE /api/$plugin_name/<id>` - Delete $singular

## Frontend

Main component: `$Plugin_NameList.vue`
Route: `/$plugin_name`

## Development Checklist

- [ ] Models defined with proper relationships
- [ ] Initial migration created and tested
- [ ] API endpoints implemented with authentication
- [ ] Frontend component renders list and detail views
- [ ] Tests written and passing
- [ ] Added to PLUGIN_TABLE_OWNERS in correct order
- [ ] Documentation updated
- [ ] Permissions defined
- [ ] Sidebar menu item configured

## Testing

```bash
pytest plugins/$plugin_name/tests/ -v

Migration

# Create new migration
docker compose exec api flask plugin revision -p $plugin_name -m "description"

# Apply migration
docker compose exec api flask plugin upgrade $plugin_name

### 11. Summary Report

Plugin scaffolding complete: $plugin_name

Created files: [X] plugins/$plugin_name/plugin.py (BasePlugin subclass) [X] plugins/$plugin_name/models/$model_name.py (SQLAlchemy model) [X] plugins/$plugin_name/api/routes.py (Flask blueprint, 5 endpoints) [X] plugins/$plugin_name/frontend/views/$Plugin_NameList.vue (Vue component) [X] plugins/$plugin_name/migrations/ (Alembic initialized) [X] plugins/$plugin_name/tests/test_$plugin_name.py (pytest tests) [X] plugins/$plugin_name/README.md (documentation)

Updated files: [X] shopdb/config.py (added to PLUGIN_TABLE_OWNERS)

Next steps:

  1. Review generated code and customize as needed
  2. Create initial migration: docker compose exec api flask plugin revision -p $plugin_name -m "Initial schema"
  3. Apply migration: docker compose exec api flask plugin upgrade $plugin_name
  4. Run tests: pytest plugins/$plugin_name/tests/ -v
  5. Start dev server and test frontend: npm run dev
  6. Commit changes: git add plugins/$plugin_name shopdb/config.py

Time saved: ~45 minutes of manual scaffolding


## Customization

After scaffolding, customize:

**Models:**
- Add plugin-specific fields
- Define relationships to other plugins
- Add custom methods

**API:**
- Add custom endpoints (search, filters, bulk operations)
- Implement business logic
- Add validation schemas

**Frontend:**
- Customize table columns
- Add filters and search
- Implement detail views
- Add charts/visualizations

**Tests:**
- Add tests for custom logic
- Test edge cases
- Add integration tests

## Related

- ADR-001: Asset as platform contract
- ADR-008: Plugin migration ownership
- ADR-010: Frontend plugin hooks
- `CLAUDE.md` - Plugin development guide
- `/pre-commit` - Test before committing